From 49d3fae4bb2e494e1352f5ce3e7a92cbb3d30d0b Mon Sep 17 00:00:00 2001 From: alex-mextner <111590326+alex-mextner@users.noreply.github.com> Date: Wed, 4 Nov 2026 23:23:09 +0100 Subject: [PATCH 1/8] feat(slicing): thumbnails are added after every slice, in the slicer settings and in 3MF plate pictures (Refs #722) The Orca CLI writes no thumbnails. Every code path that runs a slicer now asks it for 48x48/96x96/300x300 PNG (project/machine preset, PrusaSlicer flags) and draws what is missing from the toolpaths before the G-code is hashed or moved: 3d slice, orca_cli (3d prep, calibration), 3d retarget --gcode, 3d gcode-resume. A G-code without a thumbnails spec requires the three default sizes. A 3MF gets its missing Metadata/plate_N.png. tests/test_thumbnail_guard.py fails a module that runs a slicer without the layer. The legacy over-limit functions of the touched files were split into helpers (behavior-preserving) so the whole-file lint gate passes (#707). Known gap: the U1 plate generator, #724. --- AGENTS.md | 5 + docs/architecture/lib-map.md | 3 +- docs/commands/gcode-thumbnails.md | 29 +- docs/printers/u1-orca-cli.md | 2 +- docs/workflows/print-pipeline.md | 4 +- lib/commands/gcode_resume.py | 68 ++-- lib/commands/gcode_thumbnails.py | 12 +- lib/commands/retarget.py | 259 +++++++++------ lib/commands/slice.py | 233 +++++++++----- lib/gcode_thumbnails.py | 501 +++++++++++++++++++++--------- lib/slicing/orca_cli.py | 191 +++++++----- lib/slicing/thumbnails.py | 341 ++++++++++++++++++++ tests/test_gcode_thumbnails.py | 9 +- tests/test_thumbnail_guard.py | 74 +++++ tests/test_thumbnail_layers.py | 320 +++++++++++++++++++ tests/thumbnail_fixtures.py | 75 +++++ 16 files changed, 1692 insertions(+), 434 deletions(-) create mode 100644 lib/slicing/thumbnails.py create mode 100644 tests/test_thumbnail_guard.py create mode 100644 tests/test_thumbnail_layers.py create mode 100644 tests/thumbnail_fixtures.py diff --git a/AGENTS.md b/AGENTS.md index 4a9b497a..c4fea61d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -405,6 +405,11 @@ skills (`atomic-commits`, `ai-review-before-commit`, `pre-commit-gate`, recorded in `NAME.slice-warnings.json` (`3d slice-warnings record`, or `3d slice`) and resolved or accepted with a reason. The full model → slice → G-code → start order is `docs/workflows/print-pipeline.md`. +- A G-code never lacks its thumbnails (the Orca CLI writes none): every code path that runs a + slicer calls `slicing.thumbnails.finalize_*` on its output before the G-code is hashed or moved, + `3d printer upload|start` call `fix_in_place`, `print-preflight --fix` repairs. A new slicing + entry point without it fails `tests/test_thumbnail_guard.py`; layers in + `docs/commands/gcode-thumbnails.md`. ## Verification before "done" diff --git a/docs/architecture/lib-map.md b/docs/architecture/lib-map.md index 0b29ff75..977e3528 100644 --- a/docs/architecture/lib-map.md +++ b/docs/architecture/lib-map.md @@ -98,6 +98,7 @@ Target package: `lib/geometry/`, with subpackages only when a real boundary appe ### Slicing / Print Workflow - `lib/slicing/printing.py` +- `lib/slicing/thumbnails.py` — the layers that keep a sliced G-code from missing its thumbnails: slicer settings that ask for 48x48/96x96/300x300 (`spec_for`, Prusa flags), `finalize_*` after every slice (G-code blocks, `.3mf` plate pictures), `fix_in_place` for `printer upload|start`; `tests/test_thumbnail_guard.py` fails a slicing module that skips it. - `lib/slicing/orca_cli.py` — the one place for Snapmaker Orca / OrcaSlicer CLI knowledge: binary discovery, the slicing copy (no `` block), headless `slice_project` under the heavy lock, failure classification and the `KNOWN_QUIRKS` catalogue behind `docs/printers/u1-orca-cli.md` (#431). - `lib/prep/` — `3d prep` (#431): `pipeline.py` (ordered, cached steps; `cache.py`), `steps.py` (adapters), `cli.py` (flags + spec file), and one library per step: `meshfix.py` (diagnose/repair + backend interface for the public meshfix library), `orient.py`, `overhang.py`, `adapt.py` (cavity fill, 45 degree wedges), `voxel.py` (scan-converted occupancy), `fit.py` (item-table fit envelope), `stability.py`, `regions.py` (landmark boxes -> colour parts/mixes), `layers.py` (adaptive layer-height plan), `project3mf.py` (Orca project 3MF + manifest), `gcode_gates.py` / `gcode_view.py`, and the proof pack (`raster.py`, `panels.py`, `report.py`, `proof.py`). Results follow `contracts.StepResult`. - `prep_cli.py` — entry script `3d prep` runs through `cli.pyrun` with the prep dependencies (`uv sync --extra prep`). @@ -285,7 +286,7 @@ Every current root Python module is listed here so future additions are intentio - `gcode_scan.py` — one streaming pass over a sliced G-code, layer by layer: per-layer Z / height / extrusion runs (type, tool, XY) handed to consumers, plus M600 pauses, tool changes, grams per tool and feature type and footer settings; the whole file is never held; numpy only. - `stripe_contrast.py` — perceptual layer-stripe contrast of a stack of single-colour layers at their real heights: eye-blurred profile vs its period mean, worst CIEDE2000 per layer (the `stripe contrast` check of `3d gcode-mix-gate`). - `gcode_mix_gate.py` — the multi-colour gates of `3d gcode-mix-gate`: colour volumes attributed from the part STLs (pure / mixed per layer on the layer's own outer-wall evidence / accent / thin), pause, heads and prime tower, pocket void, layer heights, footer settings, and the `--mix-teeth` / `--cavity-teeth` controls that must FAIL (a gate that cannot fail is vacuous); numpy/scipy/trimesh. -- `gcode_thumbnails.py` — printer-screen thumbnail blocks in G-code: stdlib-only check against the profile's `thumbnails` spec, Orca-format block writer, z-buffered toolpath preview render (numpy); backs `3d gcode-thumbnails`. +- `gcode_thumbnails.py` — printer-screen thumbnail blocks in G-code: stdlib-only check against the profile's `thumbnails` spec (48x48/96x96/300x300 PNG when it names none), Orca-format block writer, z-buffered toolpath preview render (numpy); backs `3d gcode-thumbnails`. - `frame_check.py` — render framing checks. - `ha_deploy.py` — `3d ha deploy`: manifest/snippet/compose/vendor checks for `integrations/homeassistant/`, plan from a host probe, backup → submodule checkout → symlinks/copies → check_config → reloads → service recreate → post-checks with rollback, HA-repo deploy PR. - `ha_vision.py` — `3d ha vision`: generates the Home Assistant side of camera vision (vision sensors, alert frame grabber, go2rtc stream, camera proxy service, dashboard camera cards, ghost switch) for every fleet printer with a camera, from `fleet/printers.yaml`; checks the committed files for drift and missing entities. diff --git a/docs/commands/gcode-thumbnails.md b/docs/commands/gcode-thumbnails.md index e300030c..811ab900 100644 --- a/docs/commands/gcode-thumbnails.md +++ b/docs/commands/gcode-thumbnails.md @@ -5,16 +5,21 @@ A printer screen (Snapmaker U1, any Klipper/Moonraker host, Creality K1 stock fi ## Usage ``` -3d gcode-thumbnails check ... [--json] -3d gcode-thumbnails add ... [--image PNG] [--size WxH/FMT]... [--out FILE] [--replace] [--preview PNG] [--json] +3d gcode-thumbnails check ... [--json] +3d gcode-thumbnails add ... [--image PNG] [--size WxH/FMT]... [--out FILE] [--replace] [--preview PNG] [--3mf X.3mf [--plate N]] [--json] +3d gcode-thumbnails add ... [--image PNG] [--json] ``` +You rarely need to run this yourself: every path that produces or sends a G-code adds the thumbnails on its own (see [Layers](#layers-a-thumbnail-cannot-be-missed)). + | Subcommand / option | What | |---|---| -| `check` | PASS/FAIL per file. PASS = every size of the profile's `; thumbnails =` is present as a valid block in the first MiB (what Moonraker reads). No spec in the file: PASS when at least one thumbnail is present. Exit 1 on any FAIL. | +| `check` | PASS/FAIL per file. PASS = every size of the profile's `; thumbnails =` is present as a valid block in the first MiB (what Moonraker reads). No spec in the file: 48x48, 96x96 and 300x300 PNG are required. A `.3mf`: every plate has `Metadata/plate_N.png` and `plate_N_small.png`. Exit 1 on any FAIL. | | `add` | Insert the missing sizes. The file is rewritten atomically with its mode and mtime kept (a staleness check "G-code older than its project" must not be fooled). A file that already passes is left byte-identical. | | `--image PNG` | Use this picture (e.g. a plate render; transparent margins trimmed) instead of drawing one from the G-code. Needs Pillow. | -| `--size WxH[/FMT]` | Sizes for a G-code without a `; thumbnails =` spec (repeatable). 1..4096 px per side, otherwise exit 2 (also applies to the profile's own spec). | +| `--size WxH[/FMT]` | Sizes added on top of the required ones (repeatable). 1..4096 px per side, otherwise exit 2 (also applies to the profile's own spec). | +| `--3mf X.3mf [--plate N]` | Also add the missing `Metadata/plate_N.png` (512 px) and `plate_N_small.png` (128 px) to this project, drawn from the same render (written atomically, the project's mtime kept so it does not look newer than the G-code). Plate 1 also gets the package thumbnail relationship. | +| `add X.3mf` | A `.gcode.3mf` / sliced project: the missing plate pictures, drawn from each embedded `Metadata/plate_N.gcode` (or from `--image`). | | `--out FILE` | Write the result to `FILE`, leave the input untouched (one input only). | | `--preview PNG` | Also save the full-size G-code render. | | `--replace` | Redraw every size, dropping the thumbnail blocks already in the head (e.g. after a better `--image`). | @@ -27,6 +32,20 @@ A printer screen (Snapmaker U1, any Klipper/Moonraker host, Creality K1 stock fi 3d gcode-thumbnails add other.gcode --size 300x300/PNG --size 32x32/PNG ``` +## Layers: a thumbnail cannot be missing + +The Orca CLI writes no thumbnails, so nothing may depend on somebody remembering `add`. Five independent layers; each one alone gets a bare G-code to all required sizes, and none changes a G-code that already has them (it stays byte-identical; a block that is there is never duplicated or redrawn). The code is `lib/slicing/thumbnails.py`. + +| Layer | Where | What it does | +|---|---|---| +| (a) ask the slicer | `slice_project` / `slice_stl_with_profiles` (`3d prep`, calibration), `3d slice` of a `.3mf`, `3d retarget --gcode`, PrusaSlicer via `--thumbnails 48x48,96x96,300x300` | The slicer's `thumbnails` setting also asks for 48x48, 96x96 and 300x300 PNG on top of what the preset names (the preset's own sizes and string/list shape are kept; a copy is slicing-ready, the user's file is never rewritten). A slicer that writes thumbnails (Orca GUI, PrusaSlicer, Bambu Studio) does; for every other one the config block names the sizes the checks require. | +| (b) draw after the slice | `3d slice`, `3d prep`, `3d retarget --gcode`, `3d calibrate gen` (all kinds), `3d gcode-resume` (its output copies the head of a G-code that may have had none) | Before the G-code is hashed, moved or recorded, the missing sizes are drawn from the toolpaths and inserted. A `.gcode.3mf` / `sliced.3mf` gets its missing plate pictures from the same render. A G-code that extrudes nothing visible (console macros) gets a neutral grey tile. A failure is a failed slice (`3d slice` exits 1, `3d prep` reports `thumbnails`) and names the repair command. `--dry-run` keeps no G-code and renders nothing. | +| (c) before upload / start | `3d printer upload`, `3d printer start` | The missing sizes are added to FILE **in place** (atomic, mode and mtime kept, a current slice-warnings record follows the new SHA-256) before the snapshot is hashed; the old and new SHA-256 are printed. The remote name is content-addressed, so the fixed bytes have a name of their own: a `start` after such a fix finds no stored copy and asks you to upload again. | +| (d) preflight | `3d print-preflight` | FAIL on a missing size, naming the exact command (`3d gcode-thumbnails add 'FILE'`). `--fix` runs it before the checks (the one option that writes). | +| (e) the gate | `tests/test_thumbnail_guard.py` (part of `dev run test` and the pre-commit fast gate) | Fails when a module of `lib/` runs a slicer (`--slice`, `--outputdir`, `-g --output`) without calling a layer-(b) function, unless it is listed as exempt with a reason (`slice-check` discards its G-code; the mix tester only reads a probe project back). | + +Not covered by (b): the U1 calibration plate generator (`calibration_plates/u1_underlay.py`, [#724](https://github.com/alex-mextner/3d-cli/issues/724): the module is over the repo's lint file-length gate and cannot be committed until it is split; its G-code gets thumbnails from layers (c) and (d)), and a G-code that some other program writes straight to disk, outside `lib/`. Layers (c) and (d) catch it before it reaches a printer. A `3d mix tester` project is sliced in the Orca GUI, which writes its own thumbnails. + ## Format written Exactly Orca's own block (GCode/Thumbnails.hpp), right after `; HEADER_BLOCK_END` and its blank line (after existing thumbnail blocks, if any; at the file start when there is no header) — always before the first move: @@ -52,4 +71,4 @@ Without `--image`, every visible extrusion move (walls, surfaces, bridges, brim, ## Printer preflights -`lib/gcode_thumbnails.py` is importable: `check(path) -> ThumbCheck(ok, required, present, missing, reason)` (stdlib only, raises `InputNotFound` for a missing file) and `ensure(path, image=None, sizes=None, out=None) -> ThumbCheck` (numpy). +`lib/gcode_thumbnails.py` is importable: `check(path) -> ThumbCheck(ok, required, present, missing, reason)` (stdlib only, raises `InputNotFound` for a missing file) and `ensure(path, image=None, sizes=None, out=None) -> ThumbCheck` (numpy). `lib/slicing/thumbnails.py` has the layer entry points: `finalize_slice(gcode, three_mf=None)` / `finalize_gcode` / `finalize_workdir` (b), `fix_in_place` (c), `spec_for` / `with_project_spec` / `with_machine_spec` (a). diff --git a/docs/printers/u1-orca-cli.md b/docs/printers/u1-orca-cli.md index 2f8e1908..e9aae89b 100644 --- a/docs/printers/u1-orca-cli.md +++ b/docs/printers/u1-orca-cli.md @@ -76,7 +76,7 @@ Each row: what you see, why, what to do, where it was observed. | `arrange-moves-objects` | objects overlap, slice fails with -101 | without `--arrange 0` the CLI arranges the plate itself | pass `--arrange 0` | babushkin-dom `gen/verify_slices.py` (P1S roofs, error -101) | | `inherits-one-level` | settings of grandparent presets are lost | `--load-settings` resolves one `inherits` level | flatten the chain (`SystemPresets.flat`) | babushkin-dom `gen/build_orca_3mf.py` | | `application-tag-newer` | the CLI refuses a project we wrote: `result.json` `return_code` -24, "Unsupported 3MF version" | an `Application` tag newer than the CLI's build number is rejected (even `Snapmaker_Orca-2.4.0`, the tag the CLI wrote itself; reproduced by `test_real_cli_refuses_its_own_unstamped_project`) | stamp the build number (`01.10.01.50`) | babushkin-dom `gen/build_orca_3mf.py` `orca_version()` | -| `no-thumbnails` | sliced G-code has no preview image | `export_gcode` gets a null thumbnail callback | `3d gcode-thumbnails add FILE` | babushkin-dom `gen/verify_slices.py` | +| `no-thumbnails` | sliced G-code has no preview image | `export_gcode` gets a null thumbnail callback | nothing: `slicing.orca_cli` asks for 48x48/96x96/300x300 and draws what is missing after every slice (by hand: `3d gcode-thumbnails add FILE`) | babushkin-dom `gen/verify_slices.py` | | `host-noise-warnings` | a clean slice lists warnings (version banner, OpenGL / GLFW / Wayland) | headless thumbnail renderer and banner logged as warnings / errors | ignore; `SliceOutcome.warnings` omits them (`slice_warnings.HOST_NOISE`) | `lib/slicing/slice_warnings.py` | ## Related diff --git a/docs/workflows/print-pipeline.md b/docs/workflows/print-pipeline.md index d8824a2d..f53c69dd 100644 --- a/docs/workflows/print-pipeline.md +++ b/docs/workflows/print-pipeline.md @@ -32,7 +32,7 @@ it keeps the orientation, supports and tuned settings and fails loudly when the Every slice records the slicer's own warnings next to the G-code, bound to its SHA-256: `3d slice` does it itself; a project script that runs the slicer CLI keeps its stdout+stderr, `result.json` and (with `--export-3mf`) the sliced 3MF, and after the last edit of the G-code -(`3d gcode-thumbnails add` moves a current record along) calls +(`3d gcode-thumbnails add` moves a current record along; `3d prep`, `3d slice` and `3d retarget` add the thumbnails themselves) calls [`3d slice-warnings record`](../commands/slice-warnings.md): ```bash @@ -57,7 +57,7 @@ All of these run inside `3d print-preflight FILE.gcode`; each is also a command | 4 | head sequence | [`3d slice-check PLATE.3mf --gcode FILE.gcode --check head-sequence`](../commands/slice-check.md) | an object printed on the wrong head on some layer (mixed-filament patterns); needs the project 3MF | | 4a | mix face | [`3d mix check PLATE.3mf`](../commands/mix.md) (the same check, on the project 3MF next to the G-code) | a part in a mixed filament whose top outweighs half its walls; by the project 3MF (FAIL), else by the G-code alone for objects printed by several heads (WARN) | | 4b | mix layers | [`3d mix gcode FILE.gcode --project PLATE.3mf`](../commands/mix.md) (the same check) | the additional colour printed on consecutive layers, a colour band or a mixed layer thicker than 0.12 mm, measured on the real per-layer heads and Z; works without the project for objects that alternate one head per layer | -| 5 | thumbnails | [`3d gcode-thumbnails check FILE.gcode`](../commands/gcode-thumbnails.md) | missing printer-screen previews | +| 5 | thumbnails | [`3d gcode-thumbnails check FILE.gcode`](../commands/gcode-thumbnails.md) | missing printer-screen previews (added automatically by every slicing wrapper, `upload`/`start` and `print-preflight --fix`) | | 6 | visible faces | [`3d visible-faces --gcode FILE.gcode --visible 'WALL*:y<104.8'`](../commands/visible-faces.md) | support or sagging islands on a visible side, in the real toolpaths | ## 4. `print-preflight`, then `printer start` diff --git a/lib/commands/gcode_resume.py b/lib/commands/gcode_resume.py index 5c3fd547..8cd32a1e 100644 --- a/lib/commands/gcode_resume.py +++ b/lib/commands/gcode_resume.py @@ -16,9 +16,15 @@ """ from __future__ import annotations +from pathlib import Path +from typing import TYPE_CHECKING + from cli.registry import Command from errors import InputNotFound, UsageError +if TYPE_CHECKING: + from printer_ops.devices import ResumeProfile + USAGE = """3d gcode-resume FILE.gcode --printer NAME (--top-z Z | --layer N) [options] 3d gcode-resume FILE.gcode --where BYTE 3d gcode-resume FILE.gcode --table FIRST:LAST @@ -76,15 +82,8 @@ def _number(opts: dict[str, str], key: str, kind: type[int] | type[float]) -> in command="gcode-resume") from exc -def run(argv: list[str]) -> int: - if not argv: - print(USAGE) - return 1 - if argv[0] in ("-h", "--help"): - print(USAGE) - return 0 - from pathlib import Path - +def _parse_args(argv: list[str]) -> tuple[list[str], dict[str, str]]: + """Split argv into positionals and an option dict (`-o` is stored as `--out`).""" pos: list[str] = [] opts: dict[str, str] = {} i = 0 @@ -104,6 +103,11 @@ def run(argv: list[str]) -> int: else: pos.append(a) i += 1 + return pos, opts + + +def _input_gcode(pos: list[str], opts: dict[str, str]) -> Path: + """Validate the single input file and that exactly one mode was chosen.""" if len(pos) != 1: raise UsageError(f"expected one G-code file, got {len(pos)}", command="gcode-resume") gcode = Path(pos[0]) @@ -113,16 +117,11 @@ def run(argv: list[str]) -> int: if len(modes) != 1: raise UsageError(f"give exactly one of --where, --table, --top-z, --layer (got {', '.join(modes) or 'none'})", command="gcode-resume", remediation=["See `3d gcode-resume --help`."]) + return gcode - import gcode_resume as gr - where = _number(opts, "--where", int) - if where is not None: - print(gr.where(gr.Walk.read(gcode), int(where))) - return 0 - if "--table" in opts: - print(gr.table(gr.Walk.read(gcode), opts["--table"])) - return 0 +def _resume_device(opts: dict[str, str]) -> ResumeProfile: + """The --printer device's `resume:` block (required to build).""" if "--printer" not in opts: raise UsageError("building a resume needs --printer NAME (its `resume:` block in printer-devices.yaml)", command="gcode-resume", remediation=["Run `3d printer list` to see configured printers."]) @@ -132,10 +131,18 @@ def run(argv: list[str]) -> int: if device.resume is None: raise UsageError(f"printer {device.name!r} has no `resume:` block in {device.source}", command="gcode-resume", remediation=["Add one; the keys are listed in `3d gcode-resume --help`."]) + return device.resume + + +def _build(gcode: Path, opts: dict[str, str]) -> int: + """Build the resume G-code, write it beside the input (never over it) and print the summary.""" + import gcode_resume as gr + + profile = _resume_device(opts) top_z, layer = _number(opts, "--top-z", float), _number(opts, "--layer", int) paused = _number(opts, "--paused-at", int) tower_top = _number(opts, "--tower-top", float) - res = gr.build(gr.Walk.read(gcode), device.resume, name=gcode.name, top_z=top_z, + res = gr.build(gr.Walk.read(gcode), profile, name=gcode.name, top_z=top_z, layer=None if layer is None else int(layer), paused_at=None if paused is None else int(paused), tower_top=None if tower_top is None else float(tower_top), no_tower="--no-tower" in opts, home_xy="--home-xy" in opts) @@ -144,10 +151,35 @@ def run(argv: list[str]) -> int: raise UsageError(f"--out {dest} is the input G-code; the original must stay", command="gcode-resume", remediation=["Give another -o path, or omit it for _resume_L.gcode."]) dest.write_bytes(b"\n".join(res.lines)) + from slicing.thumbnails import finalize_gcode + + finalize_gcode(dest) # the resume copies the head of a G-code that may have had none print(res.summary(dest)) return 0 +def run(argv: list[str]) -> int: + if not argv: + print(USAGE) + return 1 + if argv[0] in ("-h", "--help"): + print(USAGE) + return 0 + pos, opts = _parse_args(argv) + gcode = _input_gcode(pos, opts) + + import gcode_resume as gr + + where = _number(opts, "--where", int) + if where is not None: + print(gr.where(gr.Walk.read(gcode), int(where))) + return 0 + if "--table" in opts: + print(gr.table(gr.Walk.read(gcode), opts["--table"])) + return 0 + return _build(gcode, opts) + + COMMAND = Command( name="gcode-resume", group="SLICING", diff --git a/lib/commands/gcode_thumbnails.py b/lib/commands/gcode_thumbnails.py index 7c04e9eb..0d77b720 100644 --- a/lib/commands/gcode_thumbnails.py +++ b/lib/commands/gcode_thumbnails.py @@ -9,6 +9,8 @@ WHY: the Orca-family CLI (`--slice`) exports G-code without thumbnails — it passes a null thumbnail callback to export_gcode — so every headless slice shows a blank preview on the Snapmaker U1 / Klipper / Creality K1 screen even though the profile sets `thumbnails`. + Nobody has to remember this command: the slicing wrappers, `3d printer upload|start` and + `3d print-preflight --fix` run it themselves (docs/commands/gcode-thumbnails.md, "Layers"). ACCESSED VIA: `3d gcode-thumbnails check|add`; printer preflights import lib/gcode_thumbnails.py `check()` directly. `check` runs in-process (stdlib only); `add` @@ -25,14 +27,19 @@ for a job comes from base64 blocks in the G-code header. Orca's CLI never writes them. check FILE... PASS/FAIL per file: every size in the profile's `; thumbnails =` - is present (no spec: at least one thumbnail). Exit 1 on FAIL. + is present (no spec: 48x48, 96x96 and 300x300 PNG). A `.3mf`: + every plate has Metadata/plate_N.png and plate_N_small.png. + Exit 1 on FAIL. add FILE... insert the missing sizes after `; HEADER_BLOCK_END` (before the first move), file rewritten atomically, mtime kept. A file that already has them is left untouched. --image PNG picture to use (e.g. a plate render) instead of drawing the preview from the G-code's extrusion moves - --size WxH[/FMT] sizes for a G-code without a `; thumbnails =` spec (repeatable); + --size WxH[/FMT] sizes added on top of the required ones (repeatable); 1..4096 px per side (the G-code render is capped at 2048 px) + --3mf X.3mf [--plate N] also add the missing Metadata/plate_N.png/_small.png to this project + (drawn from the same render); `add X.3mf` alone draws them from + the plate G-codes embedded in a `.gcode.3mf` --out FILE write the result there instead of in place (one input only) --preview PNG also save the full-size G-code render --replace redraw every size, dropping the thumbnails already there @@ -42,6 +49,7 @@ 3d gcode-thumbnails add print/*.gcode 3d gcode-thumbnails add plate.gcode --image plate.png 3d gcode-thumbnails add other.gcode --size 300x300/PNG --size 32x32/PNG + 3d gcode-thumbnails add sliced.gcode --3mf sliced.3mf """ diff --git a/lib/commands/retarget.py b/lib/commands/retarget.py index 5e838d67..f6526b2e 100644 --- a/lib/commands/retarget.py +++ b/lib/commands/retarget.py @@ -34,7 +34,7 @@ if TYPE_CHECKING: from slicing.orca_presets import PresetRef - from slicing.retarget_3mf import Report + from slicing.retarget_3mf import PresetSource, Report USAGE = """3d retarget --to PRINTER [options] Retarget a slicer project .3mf (Orca / Snapmaker Orca / Bambu Studio) to another printer: @@ -121,6 +121,55 @@ def _margin(value: str) -> float: return margin +def _store_value(opts: _Options, arg: str, value: str) -> None: + """Apply one `--flag VALUE` pair to the options.""" + if arg == "--margin": + opts.margin = _margin(value) + elif arg in _VALUED: + setattr(opts, _VALUED[arg], value) + else: + getattr(opts, _REPEATED[arg]).append(value) + + +def _store_bare(opts: _Options, arg: str) -> None: + """Apply a value-less argument: a boolean flag, or the single positional project path.""" + if arg == "--include-non-printable": + opts.include_non_printable = True + elif arg == "--dry-run": + opts.dry_run = True + elif arg in ("-v", "--verbose"): + opts.verbose = True + elif arg.startswith("-"): + raise UsageError( + f"unknown option {arg}", + command="retarget", + remediation=["Run '3d retarget --help'."], + ) + elif opts.src: + raise UsageError( + f"unexpected extra argument {arg!r}", + command="retarget", + remediation=["One project .3mf per run."], + ) + else: + opts.src = arg + + +def _check_required(opts: _Options) -> None: + if not opts.src: + raise UsageError( + "missing project .3mf", + command="retarget", + remediation=["3d retarget --to PRINTER"], + ) + if not opts.to: + raise UsageError( + "missing --to PRINTER", + command="retarget", + remediation=["Name the target machine preset, e.g. --to k1se"], + ) + + def _parse(argv: list[str]) -> _Options: opts = _Options() i = 0 @@ -133,48 +182,12 @@ def _parse(argv: list[str]) -> _Options: command="retarget", remediation=["Run '3d retarget --help'."], ) - value = argv[i + 1] + _store_value(opts, arg, argv[i + 1]) i += 2 - if arg == "--margin": - opts.margin = _margin(value) - elif arg in _VALUED: - setattr(opts, _VALUED[arg], value) - else: - getattr(opts, _REPEATED[arg]).append(value) continue - if arg == "--include-non-printable": - opts.include_non_printable = True - elif arg == "--dry-run": - opts.dry_run = True - elif arg in ("-v", "--verbose"): - opts.verbose = True - elif arg.startswith("-"): - raise UsageError( - f"unknown option {arg}", - command="retarget", - remediation=["Run '3d retarget --help'."], - ) - elif opts.src: - raise UsageError( - f"unexpected extra argument {arg!r}", - command="retarget", - remediation=["One project .3mf per run."], - ) - else: - opts.src = arg + _store_bare(opts, arg) i += 1 - if not opts.src: - raise UsageError( - "missing project .3mf", - command="retarget", - remediation=["3d retarget --to PRINTER"], - ) - if not opts.to: - raise UsageError( - "missing --to PRINTER", - command="retarget", - remediation=["Name the target machine preset, e.g. --to k1se"], - ) + _check_required(opts) return opts @@ -182,20 +195,8 @@ def _slug(text: str) -> str: return re.sub(r"[^a-z0-9]+", "-", text.lower()).strip("-") or "target" -def run(argv: list[str]) -> int: - if not argv: - print(USAGE) - return 1 - if argv[0] in ("-h", "--help"): - print(USAGE) - return 0 - opts = _parse(argv) - - # Imported lazily so `3d help` and command discovery stay light. - from slicing import orca_presets, retarget_3mf as rt - - started = time.perf_counter() - src = Path(opts.src) +def _output_path(opts: _Options, src: Path) -> Path: + """Resolve the output project path and refuse to overwrite the source or the G-code target.""" out = ( Path(opts.out) if opts.out @@ -219,12 +220,14 @@ def run(argv: list[str]) -> int: command="retarget", extra="The G-code would overwrite a project file; pick another name.", ) - for pname in opts.setting_sets: - if pname not in rt.PRESETS: - raise InvalidArgument( - "--preset", pname, sorted(rt.PRESETS), command="retarget" - ) - overrides = [rt.parse_assignment(s) for s in opts.sets] + return out + + +def _resolve_sources(opts: _Options) -> tuple[ + dict[str, PresetRef], dict[str, list[str]], dict[str, PresetSource] +]: + """Look up the machine/process/filament presets: (refs, other matches, flattened sources).""" + from slicing import orca_presets, retarget_3mf as rt catalog = orca_presets.PresetCatalog([Path(p) for p in opts.preset_dirs]) queries = {"machine": opts.to, "process": opts.process, "filament": opts.filament} @@ -244,6 +247,54 @@ def run(argv: list[str]) -> int: kind: rt.PresetSource(ref.name, catalog.flatten(ref)) for kind, ref in refs.items() } + return refs, others, sources + + +def _finish( + problems: list[str], opts: _Options, out: Path, elapsed: float +) -> None: + """Print the validation verdict; a failed validation raises.""" + if problems: + print(f"VALIDATION: FAIL ({len(problems)} problem(s))") + for p in problems: + print(f" - {p}") + raise GateFailure( + "retargeted 3MF failed validation; no file kept", + command="retarget", + silent=True, + ) + size = "" if opts.dry_run else f" ({out.stat().st_size / 1024:.0f} KiB)" + print( + "VALIDATION: PASS (XML/JSON well-formed, required settings present, extruder/filament count, footprint inside bed)" + ) + print( + f"RESULT: PASS {'dry run, nothing written' if opts.dry_run else out}{size} in {elapsed:.1f} s" + ) + + +def run(argv: list[str]) -> int: + if not argv: + print(USAGE) + return 1 + if argv[0] in ("-h", "--help"): + print(USAGE) + return 0 + opts = _parse(argv) + + # Imported lazily so `3d help` and command discovery stay light. + from slicing import retarget_3mf as rt + + started = time.perf_counter() + src = Path(opts.src) + out = _output_path(opts, src) + for pname in opts.setting_sets: + if pname not in rt.PRESETS: + raise InvalidArgument( + "--preset", pname, sorted(rt.PRESETS), command="retarget" + ) + overrides = [rt.parse_assignment(s) for s in opts.sets] + + refs, others, sources = _resolve_sources(opts) machine = sources["machine"] parts, report = rt.retarget( @@ -267,23 +318,7 @@ def run(argv: list[str]) -> int: report.problems = problems _print_report(report, refs, opts, others) - elapsed = time.perf_counter() - started - if problems: - print(f"VALIDATION: FAIL ({len(problems)} problem(s))") - for p in problems: - print(f" - {p}") - raise GateFailure( - "retargeted 3MF failed validation; no file kept", - command="retarget", - silent=True, - ) - size = "" if opts.dry_run else f" ({out.stat().st_size / 1024:.0f} KiB)" - print( - "VALIDATION: PASS (XML/JSON well-formed, required settings present, extruder/filament count, footprint inside bed)" - ) - print( - f"RESULT: PASS {'dry run, nothing written' if opts.dry_run else out}{size} in {elapsed:.1f} s" - ) + _finish(problems, opts, out, time.perf_counter() - started) if opts.gcode and not opts.dry_run: return _slice_and_check( @@ -347,6 +382,7 @@ def _slice_and_check( from commands import gcode_bounds from commands.slice import _record_warnings from slicing import retarget_3mf as rt + from slicing.thumbnails import spec_for as thumbnail_spec_for binary = _find_orca_cli() presets = rt.split_project_settings( @@ -358,6 +394,8 @@ def _slice_and_check( work = Path(tmp) paths = {kind: work / f"{kind}.json" for kind in presets} for kind, body in presets.items(): + if kind == "machine": # layer (a): the machine preset also asks for the thumbnail sizes + body = {**body, "thumbnails": thumbnail_spec_for(body)} paths[kind].write_text(json.dumps(body, indent=1), encoding="utf-8") stl = work / "part.stl" stl.write_bytes(rt.export_stl(parts)) @@ -380,6 +418,9 @@ def _slice_and_check( command="retarget", remediation=[tail or "no slicer output"], ) + from slicing.thumbnails import finalize_slice + + finalize_slice(produced[0]) # the CLI writes no thumbnails; before the move, so the sidecar hashes the final bytes shutil.move(produced[0], gcode) _record_warnings(str(gcode), proc.stdout + proc.stderr, str(work)) print(f"sliced OK -> {gcode} ({gcode.stat().st_size} bytes)") @@ -395,14 +436,9 @@ def _fmt(value: object, limit: int = 40) -> str: return text if text else "''" -def _print_report( - report: Report, - refs: dict[str, PresetRef], - opts: _Options, - others: dict[str, list[str]], +def _print_presets( + refs: dict[str, PresetRef], others: dict[str, list[str]] ) -> None: - print("=" * 72) - print(f"retarget: {report.source} -> {report.target}") for kind, ref in refs.items(): print(f" {kind:<8} preset: {ref.name} [{ref.origin}]") if kind in others: @@ -410,9 +446,9 @@ def _print_report( ", ..." if len(others[kind]) > 4 else "" ) print(f" also matched: {also} (pass the exact name to choose)") - for label in report.objects: - state = "KEPT" if int(label.split(":")[0]) in report.kept else "dropped" - print(f" object {label} -> {state}") + + +def _print_fit(report: Report) -> None: p = report.placement w, d = report.footprint turn = ( @@ -424,27 +460,44 @@ def _print_report( f" fit footprint {w:.1f} x {d:.1f} mm in bed {report.bed.width:g} x {report.bed.depth:g} mm " f"(margin {report.margin:g}); {turn}moved by ({p.dx:+.1f}, {p.dy:+.1f}) mm; top z {report.zmax:.1f} mm" ) + + +def _print_group(origin: str, rows: list[tuple[str, object, object]], verbose: bool) -> None: + print(f" {origin}: {len(rows)}") + shown = rows if verbose or len(rows) <= _SHOWN_PER_GROUP else rows[:_SHOWN_PER_GROUP] + for key, old, new in shown: + print(f" {key}: {_fmt(old)} -> {_fmt(new)}") + if len(shown) < len(rows): + print( + f" ... {len(rows) - len(shown)} more (-v lists all): " + + ", ".join(k for k, _, _ in rows[_SHOWN_PER_GROUP : _SHOWN_PER_GROUP + 8]) + + (", ..." if len(rows) - len(shown) > 8 else "") + ) + + +def _print_changes(report: Report, verbose: bool) -> None: groups: dict[str, list[tuple[str, object, object]]] = {} for c in report.changes: groups.setdefault(c.origin, []).append((c.key, c.old, c.new)) print(f" changed {len(report.changes)} setting key(s)") for origin, rows in groups.items(): - print(f" {origin}: {len(rows)}") - shown = ( - rows - if opts.verbose or len(rows) <= _SHOWN_PER_GROUP - else rows[:_SHOWN_PER_GROUP] - ) - for key, old, new in shown: - print(f" {key}: {_fmt(old)} -> {_fmt(new)}") - if len(shown) < len(rows): - print( - f" ... {len(rows) - len(shown)} more (-v lists all): " - + ", ".join( - k for k, _, _ in rows[_SHOWN_PER_GROUP : _SHOWN_PER_GROUP + 8] - ) - + (", ..." if len(rows) - len(shown) > 8 else "") - ) + _print_group(origin, rows, verbose) + + +def _print_report( + report: Report, + refs: dict[str, PresetRef], + opts: _Options, + others: dict[str, list[str]], +) -> None: + print("=" * 72) + print(f"retarget: {report.source} -> {report.target}") + _print_presets(refs, others) + for label in report.objects: + state = "KEPT" if int(label.split(":")[0]) in report.kept else "dropped" + print(f" object {label} -> {state}") + _print_fit(report) + _print_changes(report, opts.verbose) for note in report.notes: print(f" note {note}") print("-" * 72) diff --git a/lib/commands/slice.py b/lib/commands/slice.py index 9d10d8c0..d24631a9 100644 --- a/lib/commands/slice.py +++ b/lib/commands/slice.py @@ -29,6 +29,7 @@ import re import shutil import subprocess +import zipfile import sys import tempfile from dataclasses import dataclass @@ -120,11 +121,51 @@ def _slice_prusa(binary: str, work_input: str, out: str, profile: str, printer: args += ["--load", p] if printer: args += ["--load", printer] # best-effort; slicer-specific preset naming varies. + args += _prusa_thumbnail_args() # layer (a) args.append(work_input) with open(log, "w") as lf: return subprocess.run(args, stdout=lf, stderr=subprocess.STDOUT).returncode +def _prusa_thumbnail_args() -> list[str]: + from slicing.thumbnails import PRUSA_ARGS + + return list(PRUSA_ARGS) + + +def _with_thumbnail_spec(work_input: str, tmpdir: str, slicer_kind: str) -> str: + """Layer (a) for a 3MF project given to an Orca-family slicer: a copy of it that also asks for the + thumbnail sizes (the user's file stays as it is). Other inputs are sliced as given.""" + if slicer_kind == "prusa" or not work_input.lower().endswith(".3mf") or not zipfile.is_zipfile(work_input): + return work_input # (a file that is no zip is the slicer's to reject, with its own message) + from pathlib import Path + + from slicing.thumbnails import with_project_spec + + copy = Path(tmpdir) / os.path.basename(work_input) + with_project_spec(Path(work_input), copy) + return str(copy) + + +def _finalize_thumbnails(produced: str) -> None: + """Layer (b): the slicer's output carries every required thumbnail before it is kept. A `.gcode.3mf` + gets its plate images instead. A failure is a failed slice: the message names the repair command.""" + from pathlib import Path + + from slicing import thumbnails + + out = Path(produced) + try: + if out.name.lower().endswith(".3mf"): + thumbnails.finalize_embedded(out) + else: + thumbnails.finalize_slice(out) + except GateFailure as exc: + print(f"STATUS: FAIL - sliced, but the thumbnails could not be added: {exc.message}") + raise + print(f" thumbnails: present ({os.path.basename(produced)})") + + def _slice_orca(binary: str, work_input: str, outdir: str, profile: str, printer: str, log: str) -> int: args = [binary, "--slice", "0", "--outputdir", outdir] loads = profile @@ -137,7 +178,8 @@ def _slice_orca(binary: str, work_input: str, outdir: str, profile: str, printer return subprocess.run(args, stdout=lf, stderr=subprocess.STDOUT).returncode -def run(argv: list[str]) -> int: +def _early_exit(argv: list[str]) -> int | None: + """Exit code for the argv shapes that never slice (no args, help, profile listing); None otherwise.""" if not argv: print(USAGE) return 1 @@ -147,12 +189,11 @@ def run(argv: list[str]) -> int: if argv == ["--list-profiles"]: _print_profiles() return 0 + return None - opts = _parse(argv) - _validate_input(opts.inp) - if opts.profile: - _validate_profiles(opts.profile) +def _require_slicer() -> tuple[str, str]: + """(kind, binary) of the slicer to use, or MissingDependency when none is installed.""" sl = _find_slicer() if sl is None: raise MissingDependency( @@ -161,56 +202,82 @@ def run(argv: list[str]) -> int: degrades="3d slice cannot run, including --dry-run sliceability verification", command="slice", ) - slicer_kind, slicer_bin = sl + return sl + + +def _print_banner(opts: _Options, profile: str, slicer_kind: str, slicer_bin: str, final_out: str) -> None: + print("================================================================") + print(f"slice: {os.path.basename(opts.inp)} via {slicer_kind} ({slicer_bin})") + note = " (--dry-run: verify only, G-code discarded)" if opts.dry_run else "" + extra = (f" printer={opts.printer}" if opts.printer else "") + ( + f" material={opts.material}" if opts.material else "" + ) + ( + f" profile={profile}" if profile else "" + ) + print(f" -> {final_out}{note}{extra}") + print("================================================================") + + +def _report_success(produced: str, final_out: str, dry_run: bool, log_text: str, tmpdir: str) -> None: + """Keep (or, for --dry-run, discard) the produced G-code and print the PASS block.""" + size = os.path.getsize(produced) + if dry_run: + print(f"STATUS: PASS - sliceable ({size} bytes produced, discarded; --dry-run gate)") + else: + _finalize_thumbnails(produced) + os.makedirs(os.path.dirname(final_out) or ".", exist_ok=True) + shutil.move(produced, final_out) + print(f"STATUS: PASS - sliced OK -> {final_out} ({size} bytes)") + _record_warnings(final_out, log_text, tmpdir) + est_time = _grep1(log_text, r"estimated printing time[^0-9]*[0-9hms: ]+") + est_fil = _grep1(log_text, r"filament used[^0-9]*[0-9.]+ ?[gm]+") + if est_time: + print(f" {est_time}") + if est_fil: + print(f" {est_fil}") + print("================================================================") + + +def _report_failure(rc: int) -> None: + print(f"STATUS: FAIL - slicer did not produce G-code (rc={rc})") + print(" Check --profile/--printer. Many slicers require machine/process/filament profiles.") + print(f" {PROFILE_EXPORT_STEPS}") + print("================================================================") + + +def run(argv: list[str]) -> int: + early = _early_exit(argv) + if early is not None: + return early + + opts = _parse(argv) + _validate_input(opts.inp) + if opts.profile: + _validate_profiles(opts.profile) + + slicer_kind, slicer_bin = _require_slicer() profile = opts.profile or _resolve_profile(opts) _validate_profiles(profile) tmpdir = tempfile.mkdtemp(prefix="3dslice.") log = tempfile.mktemp(prefix="3dslicelog.") try: - work_input = _prepare_input(opts.inp, opts.defs, tmpdir) + work_input = _with_thumbnail_spec(_prepare_input(opts.inp, opts.defs, tmpdir), tmpdir, slicer_kind) final_out = opts.out or (opts.inp.rsplit(".", 1)[0] + ".gcode") prusa_out = os.path.join(tmpdir, "out.gcode") - print("================================================================") - print(f"slice: {os.path.basename(opts.inp)} via {slicer_kind} ({slicer_bin})") - note = " (--dry-run: verify only, G-code discarded)" if opts.dry_run else "" - extra = (f" printer={opts.printer}" if opts.printer else "") + ( - f" material={opts.material}" if opts.material else "" - ) + ( - f" profile={profile}" if profile else "" - ) - print(f" -> {final_out}{note}{extra}") - print("================================================================") + _print_banner(opts, profile, slicer_kind, slicer_bin, final_out) rc = _run_slicer(slicer_kind, slicer_bin, work_input, tmpdir, prusa_out, opts, profile, log) produced = _find_produced_gcode(tmpdir, prusa_out) if rc == 0 else "" log_text = _read_log(log) _print_log_tail(log_text) - est_time = _grep1(log_text, r"estimated printing time[^0-9]*[0-9hms: ]+") - est_fil = _grep1(log_text, r"filament used[^0-9]*[0-9.]+ ?[gm]+") - if rc == 0 and produced and os.path.getsize(produced) > 0: - size = os.path.getsize(produced) - if opts.dry_run: - print(f"STATUS: PASS - sliceable ({size} bytes produced, discarded; --dry-run gate)") - else: - os.makedirs(os.path.dirname(final_out) or ".", exist_ok=True) - shutil.move(produced, final_out) - print(f"STATUS: PASS - sliced OK -> {final_out} ({size} bytes)") - _record_warnings(final_out, log_text, tmpdir) - if est_time: - print(f" {est_time}") - if est_fil: - print(f" {est_fil}") - print("================================================================") + _report_success(produced, final_out, opts.dry_run, log_text, tmpdir) return 0 - print(f"STATUS: FAIL - slicer did not produce G-code (rc={rc})") - print(" Check --profile/--printer. Many slicers require machine/process/filament profiles.") - print(f" {PROFILE_EXPORT_STEPS}") - print("================================================================") + _report_failure(rc) return 1 finally: with contextlib.suppress(OSError): @@ -412,39 +479,51 @@ def _profile_score( material_terms: tuple[str, ...], ) -> int: haystack = _profile_haystack(path) - if any(term in haystack for term in excluded_terms): + if _has_any(haystack, excluded_terms): return 0 - score = 0 if role == "machine": - if not all(term in haystack for term in printer_terms): - return 0 - if any(term in haystack for term in ("filament", "material", "process")): - return 0 - score += 30 - score += 10 if any(term in haystack for term in ("machine", "printer", "nozzle")) else 0 - elif role == "process": - if not _matches_process_printer(haystack, printer_terms): - return 0 - if any(term not in material_terms and term in haystack for term in AUTO_PROFILE_ALL_MATERIAL_TERMS): - return 0 - if any(term in haystack for term in ("filament", "material", "machine", "printer", "nozzle")): - return 0 - score += 30 - score += 15 if any(term in haystack for term in ("process", "print", "quality", "standard", "layer")) else 0 - score += 8 if any(term in haystack for term in material_terms) else 0 - score += 5 if "0-2" in haystack or "0-20" in haystack or "0-20mm" in haystack else 0 - elif role == "filament": - if not any(term in haystack for term in material_terms): - return 0 - if any(term in haystack for term in ("machine", "printer", "process")): - return 0 - score += 25 - score += 10 if any(term in haystack for term in ("filament", "material")) else 0 - else: + return _machine_score(haystack, printer_terms) + if role == "process": + return _process_score(haystack, printer_terms, material_terms) + if role == "filament": + return _filament_score(haystack, material_terms) + return 0 + + +def _has_any(haystack: str, terms: tuple[str, ...]) -> bool: + return any(term in haystack for term in terms) + + +def _machine_score(haystack: str, printer_terms: tuple[str, ...]) -> int: + if not all(term in haystack for term in printer_terms): + return 0 + if _has_any(haystack, ("filament", "material", "process")): + return 0 + return 30 + (10 if _has_any(haystack, ("machine", "printer", "nozzle")) else 0) + + +def _process_score(haystack: str, printer_terms: tuple[str, ...], material_terms: tuple[str, ...]) -> int: + if not _matches_process_printer(haystack, printer_terms): + return 0 + if any(term not in material_terms and term in haystack for term in AUTO_PROFILE_ALL_MATERIAL_TERMS): return 0 + if _has_any(haystack, ("filament", "material", "machine", "printer", "nozzle")): + return 0 + score = 30 + score += 15 if _has_any(haystack, ("process", "print", "quality", "standard", "layer")) else 0 + score += 8 if _has_any(haystack, material_terms) else 0 + score += 5 if _has_any(haystack, ("0-2", "0-20", "0-20mm")) else 0 return score +def _filament_score(haystack: str, material_terms: tuple[str, ...]) -> int: + if not _has_any(haystack, material_terms): + return 0 + if _has_any(haystack, ("machine", "printer", "process")): + return 0 + return 25 + (10 if _has_any(haystack, ("filament", "material")) else 0) + + def _profile_haystack(path: str) -> str: parts = _profile_haystack_parts(path) if parts: @@ -638,20 +717,24 @@ def _profile_roots() -> list[str]: return roots +def _prune_profile_dirs(cur: str, dirs: list[str], root: str) -> None: + """In-place os.walk pruning: stop below depth 4 and skip hidden/cache/log directories.""" + rel = os.path.relpath(cur, root) + depth = 0 if rel == "." else rel.count(os.sep) + 1 + if depth >= 4: + dirs[:] = [] + dirs[:] = [d for d in dirs if not d.startswith(".") and d not in ("cache", "logs", "__pycache__")] + + def _walk_profile_files(root: str) -> list[str]: out: list[str] = [] - max_depth = 4 for cur, dirs, files in os.walk(root): - rel = os.path.relpath(cur, root) - depth = 0 if rel == "." else rel.count(os.sep) + 1 - if depth >= max_depth: - dirs[:] = [] - dirs[:] = [d for d in dirs if not d.startswith(".") and d not in ("cache", "logs", "__pycache__")] - for fn in files: - if os.path.splitext(fn)[1].lower() in (".ini", ".json"): - out.append(os.path.join(cur, fn)) - if len(out) >= 200: - return out + _prune_profile_dirs(cur, dirs, root) + profile_files = [fn for fn in files if os.path.splitext(fn)[1].lower() in (".ini", ".json")] + for fn in profile_files: + out.append(os.path.join(cur, fn)) + if len(out) >= 200: + return out return out diff --git a/lib/gcode_thumbnails.py b/lib/gcode_thumbnails.py index c0b9f563..01c7e42a 100644 --- a/lib/gcode_thumbnails.py +++ b/lib/gcode_thumbnails.py @@ -12,7 +12,7 @@ `check()` compares the blocks present in the first MiB (Moonraker only reads that much of the head) with the sizes the slicer profile asks for (`; thumbnails = 48x48/PNG, - 300x300/PNG` in the config block). `ensure()` adds the missing blocks right after + 300x300/PNG` in the config block; DEFAULT_SIZES when it names none). `ensure()` adds the missing blocks right after `; HEADER_BLOCK_END` — where Orca's GUI writes them, before the first move — byte for byte in Orca's own block format. @@ -47,7 +47,7 @@ from typing import TYPE_CHECKING, BinaryIO, NamedTuple from collections.abc import Callable -from errors import InputNotFound +from errors import InputNotFound, ThreeDError if TYPE_CHECKING: import numpy as np @@ -100,6 +100,23 @@ class ThumbnailError(ValueError): """A G-code or option this module cannot handle (unsupported format, no size spec).""" +# What every G-code must carry when its slicer profile names no `; thumbnails =` of its own: the +# 48x48 icon, the 96x96 list view (Snapmaker U1 file list) and the 300x300 job picture +# (Klipper/Moonraker, Creality). The slicing wrappers also write this set into the slicer's +# `thumbnails` setting (`merge_spec`), so the config block of a freshly sliced G-code names all three. +DEFAULT_SIZES = (Thumb(48, 48, "PNG"), Thumb(96, 96, "PNG"), Thumb(300, 300, "PNG")) + + +def merge_spec(existing: str | list[str] | None, default_fmt: str = "PNG") -> str: + """The slicer `thumbnails` value that asks for everything `existing` asks for plus DEFAULT_SIZES + (Orca's `48x48/PNG, 300x300/PNG` form; existing sizes keep their order and format, a bare `WxH` + takes `default_fmt` — the preset's `thumbnails_format`). `existing` is the preset's string or list.""" + text = ", ".join(existing) if isinstance(existing, list) else existing or "" + sizes = parse_spec(text, default_fmt) + sizes += [t for t in DEFAULT_SIZES if t not in sizes] + return ", ".join(str(t) for t in sizes) + + # ---------------------------------------------------------------- reading @@ -144,7 +161,7 @@ def parse_spec(spec: str, default_fmt: str = "PNG") -> list[Thumb]: def required(path: str) -> list[Thumb]: - """Thumbnail sizes the slicer profile asks for (`; thumbnails = …`); [] when the G-code has no spec.""" + """Thumbnail sizes the G-code must carry: its profile's `; thumbnails = …`, else DEFAULT_SIZES.""" head, tail = _head_tail(path) return _required(head, tail) @@ -152,7 +169,7 @@ def required(path: str) -> list[Thumb]: def _required(head: str, tail: str) -> list[Thumb]: spec = _config_value("thumbnails", head, tail) if not spec: - return [] + return list(DEFAULT_SIZES) return parse_spec(spec, (_config_value("thumbnails_format", head, tail) or "PNG").upper()) @@ -188,19 +205,15 @@ def present(path: str) -> list[Thumb]: def check(path: str) -> ThumbCheck: - """ok = every profile-required size is present; without a spec, ok = at least one thumbnail.""" + """ok = every required size is present (the profile's `; thumbnails =`, else DEFAULT_SIZES).""" if not os.path.isfile(path): raise InputNotFound(path, command="gcode-thumbnails") head, tail = _head_tail(path) req, pres = _required(head, tail), _present(head) have = set(pres) missing = [t for t in req if t not in have] - if req: - ok = not missing - reason = "all required thumbnails present" if ok else "missing " + ", ".join(map(str, missing)) - else: - ok = bool(pres) - reason = "no thumbnails spec; thumbnail present" if ok else "no thumbnails (and no `; thumbnails =` spec)" + ok = not missing + reason = "all required thumbnails present" if ok else "missing " + ", ".join(map(str, missing)) return ThumbCheck(path, ok, req, pres, missing, reason) @@ -399,141 +412,232 @@ def _hex_rgb(c: str) -> tuple[int, int, int]: _NUM = re.compile(rb"([XYZEIJF])(-?\d*\.?\d+)") +_TOOL = re.compile(rb"T(\d+)") +_FEATURE_COMMENTS = (b";TYPE:", b"; FEATURE:") + + +def _words(body: bytes) -> dict[str, float]: + """`X10.5 E-0.2` → {"X": 10.5, "E": -0.2} (only the letters the preview needs).""" + return {k.decode(): float(v) for k, v in _NUM.findall(body)} + + +def _relative(p: dict[str, float], x: float, y: float, z: float) -> tuple[float, float, float]: + """The target of a G91 move: each axis word is an offset, a missing one stays put.""" + return (x + p["X"] if "X" in p else x, y + p["Y"] if "Y" in p else y, z + p["Z"] if "Z" in p else z) + + +def _arc_segments(g: bytes, p: dict[str, float], start: tuple[float, float], end: tuple[float, float], + z: float, tool: int) -> list[float]: + """G2 (clockwise) / G3 (counter-clockwise) arc about the centre start+(I, J), flattened to ≤0.5 mm chords.""" + (x, y), (nx, ny) = start, end + cx, cy = x + p.get("I", 0.0), y + p.get("J", 0.0) + a0, a1 = math.atan2(y - cy, x - cx), math.atan2(ny - cy, nx - cx) + sweep = a1 - a0 + if g == b"2" and sweep >= 0: + sweep -= 2 * math.pi + if g == b"3" and sweep <= 0: + sweep += 2 * math.pi + r = math.hypot(x - cx, y - cy) + n = max(2, min(64, int(abs(sweep) * r / 0.5) + 1)) + out: list[float] = [] + px_, py_ = x, y + for k in range(1, n + 1): + a = a0 + sweep * k / n + qx, qy = cx + r * math.cos(a), cy + r * math.sin(a) + out.extend((px_, py_, qx, qy, z, tool)) + px_, py_ = qx, qy + return out + + +class _Cursor: + """Interpreter state of toolpaths(): position, extrusion/positioning modes, tool, feature skip. + A class with __slots__ (not a dict of locals) so the per-line helpers share it without + allocating anything per line.""" + + __slots__ = ("seg", "x", "y", "z", "e", "abs_xyz", "abs_e", "tool", "skip") + + def __init__(self) -> None: + self.seg = array("d") + self.x = self.y = self.z = self.e = 0.0 + self.abs_xyz, self.abs_e, self.tool, self.skip = True, False, 0, False + + def set_feature(self, line: bytes) -> None: + """`;TYPE:Outer wall` / `; FEATURE: Outer wall` → whether the following extrusion is drawn.""" + feat = line.split(b":", 1)[1].strip().decode("ascii", "ignore").lower() + self.skip = feat in SKIP_FEATURES + + def gcode(self, line: bytes) -> None: + """G0–G3 moves, G90/G91 positioning and G92 extruder reset; any other G-word is ignored.""" + code = line[1:3] + if code[:1] in b"0123" and (len(code) == 1 or not code[1:2].isdigit()): + self.move(line, code[:1]) + elif line.startswith(b"G90"): + self.abs_xyz = True + elif line.startswith(b"G91"): + self.abs_xyz = False + elif line.startswith(b"G92"): + self.e = _words(line.split(b";", 1)[0][3:]).get("E", self.e) + + def mcode(self, line: bytes) -> None: + """M82/M83: absolute / relative extruder.""" + if line.startswith(b"M82"): + self.abs_e = True + elif line.startswith(b"M83"): + self.abs_e = False + + def move(self, line: bytes, g: bytes) -> None: + """One G0–G3 line: advance the position and record the segment when it extrudes visibly.""" + p = _words(line.split(b";", 1)[0][2:]) + x, y, z = self.x, self.y, self.z + nx, ny, nz = (p.get("X", x), p.get("Y", y), p.get("Z", z)) if self.abs_xyz else _relative(p, x, y, z) + pe = p.get("E") + de = 0.0 + if pe is not None: + de = pe - self.e if self.abs_e else pe + self.e = pe if self.abs_e else self.e + pe + if de > 0 and not self.skip and (nx != x or ny != y): + if g in b"23" and ("I" in p or "J" in p): + self.seg.extend(_arc_segments(g, p, (x, y), (nx, ny), nz, self.tool)) + else: + self.seg.extend((x, y, nx, ny, nz, self.tool)) + self.x, self.y, self.z = nx, ny, nz + + def toolpaths(f: BinaryIO) -> array[float]: """Visible extrusion segments as a flat array [x0, y0, x1, y1, z, tool, …] (6 doubles per segment). - Tracks G90/G91, M82/M83, G92, T, ;TYPE:/; FEATURE:, G2/G3 arcs (I/J).""" - seg = array("d") - x = y = z = e = 0.0 - abs_xyz, abs_e, tool, skip = True, False, 0, False + Tracks G90/G91, M82/M83, G92, T, ;TYPE:/; FEATURE:, G2/G3 arcs (I/J). + Reads 100+ MB G-codes line by line: the loop only dispatches on the first byte.""" + cur = _Cursor() + gcode, mcode = cur.gcode, cur.mcode for line in f: c = line[:1] if c == b";": - if line.startswith(b";TYPE:") or line.startswith(b"; FEATURE:"): - feat = line.split(b":", 1)[1].strip().decode("ascii", "ignore").lower() - skip = feat in SKIP_FEATURES - continue - if c == b"G": - code = line[1:3] - if code[:1] in b"0123" and (len(code) == 1 or not code[1:2].isdigit()): - g = code[:1] - body = line.split(b";", 1)[0] - p = {k.decode(): float(v) for k, v in _NUM.findall(body[2:])} - nx = (p["X"] if abs_xyz else x + p["X"]) if "X" in p else x - ny = (p["Y"] if abs_xyz else y + p["Y"]) if "Y" in p else y - nz = (p["Z"] if abs_xyz else z + p["Z"]) if "Z" in p else z - de = 0.0 - if "E" in p: - de = p["E"] - e if abs_e else p["E"] - e = p["E"] if abs_e else e + p["E"] - if de > 0 and not skip and (nx != x or ny != y): - if g in b"23" and ("I" in p or "J" in p): - cx, cy = x + p.get("I", 0.0), y + p.get("J", 0.0) - a0, a1 = math.atan2(y - cy, x - cx), math.atan2(ny - cy, nx - cx) - sweep = a1 - a0 - if g == b"2" and sweep >= 0: - sweep -= 2 * math.pi - if g == b"3" and sweep <= 0: - sweep += 2 * math.pi - r = math.hypot(x - cx, y - cy) - n = max(2, min(64, int(abs(sweep) * r / 0.5) + 1)) - px_, py_ = x, y - for k in range(1, n + 1): - a = a0 + sweep * k / n - qx, qy = cx + r * math.cos(a), cy + r * math.sin(a) - seg.extend((px_, py_, qx, qy, nz, tool)) - px_, py_ = qx, qy - else: - seg.extend((x, y, nx, ny, nz, tool)) - x, y, z = nx, ny, nz - elif line.startswith(b"G90"): - abs_xyz = True - elif line.startswith(b"G91"): - abs_xyz = False - elif line.startswith(b"G92"): - p = {k.decode(): float(v) for k, v in _NUM.findall(line.split(b";", 1)[0][3:])} - e = p.get("E", e) - continue - if c == b"M": - if line.startswith(b"M82"): - abs_e = True - elif line.startswith(b"M83"): - abs_e = False - continue - if c == b"T" and line[1:2].isdigit(): - tool = int(re.match(rb"T(\d+)", line).group(1)) # type: ignore[union-attr] - return seg + if line.startswith(_FEATURE_COMMENTS): + cur.set_feature(line) + elif c == b"G": + gcode(line) + elif c == b"M": + mcode(line) + elif c == b"T" and line[1:2].isdigit(): + cur.tool = int(_TOOL.match(line).group(1)) # type: ignore[union-attr] + return cur.seg -def render_gcode(path: str, size: int = 600) -> Image: - """Isometric z-buffered preview (front-right, from above) of the G-code's visible extrusion, - coloured by each tool's filament_colour and lit from screen-space normals. RGBA, transparent bg.""" +_AZIMUTH, _ELEVATION = math.radians(-45.0), math.radians(35.264) # camera at front-right, true isometric elevation +_CA, _SA, _CE, _SE = math.cos(_AZIMUTH), math.sin(_AZIMUTH), math.cos(_ELEVATION), math.sin(_ELEVATION) + + +def _project(px: np.ndarray, py: np.ndarray, pz: np.ndarray) -> tuple[np.ndarray, np.ndarray, np.ndarray]: + rx = px * _CA - py * _SA + ry = px * _SA + py * _CA # +ry = away from the camera on the bed + return rx, -(pz * _CE + ry * _SE), ry * _CE - pz * _SE # screen u, screen v (down), depth (far = big) + + +def _palette(head: str, tail: str) -> np.ndarray: + """Per-tool RGB from the G-code config's filament/extruder colours (grey when it names none).""" import numpy as np - head, tail = _head_tail(path) colours = (_config_value("filament_colour", head, tail) or _config_value("extruder_colour", head, tail) or "") palette = np.array([_hex_rgb(c) for c in re.split(r"[;,]", colours) if c.strip()] or [(200, 200, 200)], np.float64) - palette = 45.0 + palette * (210.0 / 255.0) # lift near-black filament so shading still shows the form - width = float(_config_value("outer_wall_line_width", head, tail) or _config_value("line_width", head, tail) or 0.42) - with open(path, "rb") as f: - seg = toolpaths(f) - n = len(seg) // 6 - if n == 0: - raise ThumbnailError(f"{path}: no visible extrusion moves to draw a preview from") - s = np.frombuffer(seg, np.float64).reshape(n, 6) - x0, y0, x1, y1, z, tool = s.T + return 45.0 + palette * (210.0 / 255.0) # lift near-black filament so shading still shows the form - az, el = math.radians(-45.0), math.radians(35.264) # camera at front-right, true isometric elevation - ca, sa, ce, se = math.cos(az), math.sin(az), math.cos(el), math.sin(el) - def project(px: np.ndarray, py: np.ndarray, pz: np.ndarray) -> tuple[np.ndarray, np.ndarray, np.ndarray]: - rx = px * ca - py * sa - ry = px * sa + py * ca # +ry = away from the camera on the bed - return rx, -(pz * ce + ry * se), ry * ce - pz * se # screen u, screen v (down), depth (far = big) +class _View(NamedTuple): + """Screen-space framing of the toolpaths: bounds (u/v), the square `extent` they fit in, its + `margin`, the pixel size `res` (mm per pixel) and the picture's `side` in pixels.""" + umin: float + umax: float + vmin: float + vmax: float + extent: float + margin: float + res: float + side: int - u0, v0, _ = project(np.concatenate([x0, x1]), np.concatenate([y0, y1]), np.concatenate([z, z])) + +def _frame(s: np.ndarray, size: int, width: float) -> _View: + """Fit the projected toolpaths (n×6 rows from toolpaths()) into about size×size pixels.""" + import numpy as np + + x0, y0, x1, y1, z, _ = s.T + u0, v0, _ = _project(np.concatenate([x0, x1]), np.concatenate([y0, y1]), np.concatenate([z, z])) umin, umax, vmin, vmax = u0.min(), u0.max(), v0.min(), v0.max() extent = max(umax - umin, vmax - vmin, 1.0) margin = 0.03 * extent # a pixel no finer than ~0.4 line widths: neighbouring lines then touch and surfaces close up res = max((extent + 2 * margin) / size, 0.4 * width) side = int(math.ceil((extent + 2 * margin) / res)) + return _View(umin, umax, vmin, vmax, extent, margin, res, side) + - # sample every segment at half-pixel steps; float32 / int32 per point keeps a 115 MB G-code under ~1 GB +def _sample_pixels(s: np.ndarray, view: _View, n_tools: int) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]: + """Sample every segment at half-pixel steps → per-sample pixel column, pixel row, depth, tool index. + float32 / int32 per point keeps a 115 MB G-code under ~1 GB.""" + import numpy as np + + x0, y0, x1, y1, z, tool = s.T seg_len = np.hypot(x1 - x0, y1 - y0) - steps = np.maximum(1, np.ceil(seg_len / (0.5 * res)).astype(np.int64)) - idx = np.repeat(np.arange(n, dtype=np.int32), steps + 1) + steps = np.maximum(1, np.ceil(seg_len / (0.5 * view.res)).astype(np.int64)) + idx = np.repeat(np.arange(len(s), dtype=np.int32), steps + 1) t = (np.arange(len(idx)) - np.repeat(np.cumsum(steps + 1) - (steps + 1), steps + 1)).astype(np.float32) t /= steps[idx].astype(np.float32) px = (x0[idx] + (x1 - x0)[idx] * t).astype(np.float32) py = (y0[idx] + (y1 - y0)[idx] * t).astype(np.float32) del t - u, v, d = project(px, py, z[idx].astype(np.float32)) + u, v, d = _project(px, py, z[idx].astype(np.float32)) del px, py - ti = np.minimum(tool[idx], len(palette) - 1).astype(np.int16) + ti = np.minimum(tool[idx], n_tools - 1).astype(np.int16) del idx - iu0 = np.floor((u - umin + margin + (extent - (umax - umin)) / 2) / res).astype(np.int32) - iv0 = np.floor((v - vmin + margin + (extent - (vmax - vmin)) / 2) / res).astype(np.int32) + iu0 = np.floor((u - view.umin + view.margin + (view.extent - (view.umax - view.umin)) / 2) / view.res).astype(np.int32) + iv0 = np.floor((v - view.vmin + view.margin + (view.extent - (view.vmax - view.vmin)) / 2) / view.res).astype(np.int32) del u, v - d = d.astype(np.float32) + return iu0, iv0, d.astype(np.float32), ti + + +def _window_pixels(iu0: np.ndarray, iv0: np.ndarray, dx: int, dy: int, side: int) -> tuple[np.ndarray, np.ndarray]: + """Flat pixel index of every sample shifted by (dx, dy), and the mask of those that land inside the picture.""" + iu, iv = iu0 + dx, iv0 + dy + ok = (iu >= 0) & (iu < side) & (iv >= 0) & (iv < side) + return iv[ok] * side + iu[ok], ok + + +def _depth_buffers(samples: tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray], view: _View, + width: float) -> tuple[np.ndarray, np.ndarray]: + """Z-buffer (side², nearest depth, inf where empty) and tool index per pixel; each sample is + stamped over a disc as wide as the extrusion line.""" + import numpy as np + iu0, iv0, d, ti = samples + side = view.side zbuf = np.full(side * side, np.inf, np.float32) tbuf = np.zeros(side * side, np.int16) - r_px = max(0.0, 0.5 * width / res - 0.5) + r_px = max(0.0, 0.5 * width / view.res - 0.5) offs = [(dx, dy) for dx in range(-2, 3) for dy in range(-2, 3) if dx * dx + dy * dy <= r_px * r_px + 0.25] - - def pixels(dx: int, dy: int) -> tuple[np.ndarray, np.ndarray]: - iu, iv = iu0 + dx, iv0 + dy - ok = (iu >= 0) & (iu < side) & (iv >= 0) & (iv < side) - return iv[ok] * side + iu[ok], ok - for dx, dy in offs: - pix, ok = pixels(dx, dy) + pix, ok = _window_pixels(iu0, iv0, dx, dy, side) np.minimum.at(zbuf, pix, d[ok]) for dx, dy in offs: # the nearest sample of each pixel gives its tool colour - pix, ok = pixels(dx, dy) + pix, ok = _window_pixels(iu0, iv0, dx, dy, side) win = d[ok] <= zbuf[pix] tbuf[pix[win]] = ti[ok][win] - rgb = palette[tbuf] + return zbuf, tbuf + + +def _depth_edges(zf: np.ndarray, filled: np.ndarray, res: float) -> np.ndarray: + """Silhouette / step edges: a filled pixel whose neighbour is empty or a depth jump away.""" + import numpy as np + + edge = np.zeros_like(filled) + for ax in (0, 1): + for sh in (1, -1): + nb = np.roll(zf, sh, axis=ax) + edge |= filled & (~np.isfinite(nb) | (np.abs(nb - zf) > 3 * res)) + return edge + + +def _shade(zbuf: np.ndarray, rgb: np.ndarray, side: int, res: float) -> Image: + """RGBA picture: tool colours lit by the screen-space normals of the depth buffer, edges darkened.""" + import numpy as np zb = zbuf.reshape(side, side) filled = np.isfinite(zb) @@ -545,19 +649,32 @@ def pixels(dx: int, dy: int) -> tuple[np.ndarray, np.ndarray]: light /= np.linalg.norm(light) lambert = np.clip((nrm * light).sum(axis=2), 0.0, 1.0) shade = 0.45 + 0.55 * lambert - # silhouette / step edges: a depth jump to a neighbour darkens the pixel - edge = np.zeros_like(filled) - for ax in (0, 1): - for sh in (1, -1): - nb = np.roll(zf, sh, axis=ax) - edge |= filled & (~np.isfinite(nb) | (np.abs(nb - zf) > 3 * res)) - shade = np.where(edge, shade * 0.7, shade) + shade = np.where(_depth_edges(zf, filled, res), shade * 0.7, shade) img = np.zeros((side, side, 4), np.uint8) img[..., :3] = np.clip(rgb.reshape(side, side, 3) * shade[..., None], 0, 255).round().astype(np.uint8) img[..., 3] = np.where(filled, 255, 0).astype(np.uint8) return img +def render_gcode(path: str, size: int = 600) -> Image: + """Isometric z-buffered preview (front-right, from above) of the G-code's visible extrusion, + coloured by each tool's filament_colour and lit from screen-space normals. RGBA, transparent bg.""" + import numpy as np + + head, tail = _head_tail(path) + palette = _palette(head, tail) + width = float(_config_value("outer_wall_line_width", head, tail) or _config_value("line_width", head, tail) or 0.42) + with open(path, "rb") as f: + seg = toolpaths(f) + n = len(seg) // 6 + if n == 0: + raise ThumbnailError(f"{path}: no visible extrusion moves to draw a preview from") + s = np.frombuffer(seg, np.float64).reshape(n, 6) + view = _frame(s, size, width) + zbuf, tbuf = _depth_buffers(_sample_pixels(s, view, len(palette)), view, width) + return _shade(zbuf, palette[tbuf], view.side, view.res) + + # ---------------------------------------------------------------- ensure @@ -566,28 +683,38 @@ def render_side(sizes: list[Thumb]) -> int: return min(2 * max([max(t.width, t.height) for t in sizes] or [300]), RENDER_MAX_SIDE) +def _missing_sizes(before: ThumbCheck, sizes: list[Thumb] | None, replace: bool) -> list[Thumb]: + """The sizes ensure() must write: required + requested ones not yet present (all of them with `replace`, + which also redraws the sizes the file has but nobody asked for).""" + want = before.required + [t for t in sizes or [] if t not in before.required] + if replace: + want += [t for t in before.present if t not in want] + have = set() if replace else set(before.present) + return [t for t in want if t not in have] + + +def _require_writable(todo: list[Thumb]) -> None: + bad = [t for t in todo if t.fmt not in WRITABLE] + if bad: + raise ThumbnailError(f"cannot write {', '.join(map(str, bad))}: supported formats are {', '.join(WRITABLE)}") + + def ensure(path: str, image: str | None = None, sizes: list[Thumb] | None = None, out: str | None = None, source: Callable[[str], Image] | None = None, replace: bool = False) -> ThumbCheck: - """Add every missing required thumbnail (or `sizes`, when the G-code declares none). - No-op (file untouched) when check() already passes. `replace` redraws every size, + """Add every missing required thumbnail (the profile's sizes, else DEFAULT_SIZES) and every + missing one of `sizes`. No-op (file untouched) when check() already passes. `replace` redraws every size, dropping the existing blocks. Returns the check after writing, with `.added` listing what was inserted.""" before = check(path) seen = os.stat(path) - want = before.required or list(sizes or []) or (list(before.present) if replace else []) - if not want and not before.present: - raise ThumbnailError(f"{path}: no `; thumbnails =` spec in the G-code; pass sizes (e.g. --size 300x300/PNG)") - have = set() if replace else set(before.present) - todo = [t for t in want if t not in have] + todo = _missing_sizes(before, sizes, replace) if not todo: if out and out != path: import shutil shutil.copy2(path, out) # keep mode + mtime like splice() does return before - bad = [t for t in todo if t.fmt not in WRITABLE] - if bad: - raise ThumbnailError(f"cannot write {', '.join(map(str, bad))}: supported formats are {', '.join(WRITABLE)}") + _require_writable(todo) pic = load_image(image) if image else (source or (lambda p: render_gcode(p, size=render_side(todo))))(path) text = "".join(block(t, encode(fit(pic, t.width, t.height), t.fmt)) for t in todo) splice(path, text, out, expect=seen, replace=replace) @@ -599,6 +726,48 @@ def ensure(path: str, image: str | None = None, sizes: list[Thumb] | None = None # ---------------------------------------------------------------- CLI (lib tool entry: `3d gcode-thumbnails`) +def _check_any(path: str) -> ThumbCheck: + if path.lower().endswith(".3mf"): + from pathlib import Path + + from slicing.thumbnails import check_plate_images + + if not os.path.isfile(path): + raise InputNotFound(path, command="gcode-thumbnails") + return check_plate_images(Path(path)) + return check(path) + + +def _image_source(image: str | None, gcode: str) -> Callable[[str], Image]: + from pathlib import Path + + from slicing.thumbnails import Picture + + return (lambda _p: load_image(image)) if image else Picture(Path(gcode)) + + +def _add_3mf(path: str, image: str | None) -> ThumbCheck: + """`add X.3mf`: the plate images drawn from the embedded plate G-codes (or from --image).""" + from pathlib import Path + + from slicing.thumbnails import finalize_embedded + + if not os.path.isfile(path): + raise InputNotFound(path, command="gcode-thumbnails") + return finalize_embedded(Path(path), (lambda _p: load_image(image)) if image else None) + + +def _add_plate_images(three_mf: str, plate: int, gcode: str, image: str | None) -> None: + from pathlib import Path + + from slicing.thumbnails import finalize_plate_images + + if not os.path.isfile(three_mf): + raise InputNotFound(three_mf, command="gcode-thumbnails") + for member in finalize_plate_images(Path(three_mf), plate, _image_source(image, gcode)): + print(f"added {member} to {three_mf}", file=sys.stderr) + + def _describe(c: ThumbCheck) -> str: pres = ", ".join(map(str, c.present)) or "none" req = ", ".join(map(str, c.required)) or "no spec" @@ -610,7 +779,7 @@ def _describe(c: ThumbCheck) -> str: return line -def main(argv: list[str] | None = None) -> int: +def _build_parser() -> argparse.ArgumentParser: ap = argparse.ArgumentParser(prog="3d gcode-thumbnails", description="check/add G-code thumbnails") sub = ap.add_subparsers(dest="cmd", required=True) pc = sub.add_parser("check") @@ -623,41 +792,69 @@ def main(argv: list[str] | None = None) -> int: pa.add_argument("--out") pa.add_argument("--replace", action="store_true", help="redraw all sizes, dropping existing thumbnails") pa.add_argument("--preview", help="also save the source picture (PNG) here") + pa.add_argument("--3mf", dest="three_mf", help="also add the missing Metadata/plate_N.png to this 3MF project") + pa.add_argument("--plate", type=int, default=1, help="plate number for --3mf (default 1)") pa.add_argument("--json", action="store_true") - a = ap.parse_args(argv) - results: list[ThumbCheck] = [] + return ap + + +def _preview_saver(keep: str, sizes: list[Thumb]) -> Callable[[str], Image]: + """Image source for `--preview`: render the G-code as ensure() would, and save that picture to `keep`.""" + def src(gp: str) -> Image: + want = check(gp).required + sizes + pic = render_gcode(gp, size=render_side(want)) + with open(keep, "wb") as fh: + fh.write(encode_png(pic)) + return pic + return src + + +def _add_gcode(path: str, a: argparse.Namespace, sizes: list[Thumb]) -> ThumbCheck: + """`add X.gcode`: ensure() the thumbnails, then the plate images of `--3mf` when asked.""" + src = _preview_saver(a.preview, sizes) if a.preview else None + result = ensure(path, image=a.image, sizes=sizes, out=a.out, source=src, replace=a.replace) + if a.three_mf: + _add_plate_images(a.three_mf, a.plate, a.out or path, a.image) + return result + + +def _run_check(a: argparse.Namespace) -> list[ThumbCheck]: + return [_check_any(p) for p in a.files] + + +def _run_add(a: argparse.Namespace) -> list[ThumbCheck]: + if a.out and len(a.files) != 1: + raise ThumbnailError("--out takes exactly one input file") + sizes = [t for s in a.size for t in parse_spec(s)] + return [_add_3mf(p, a.image) if p.lower().endswith(".3mf") else _add_gcode(p, a, sizes) for p in a.files] + + +def _print_results(results: list[ThumbCheck], as_json: bool) -> None: + if not as_json: + for c in results: + print(_describe(c)) + return + import json + + print(json.dumps([{"path": c.path, "ok": c.ok, "reason": c.reason, "required": [str(t) for t in c.required], + "present": [str(t) for t in c.present], "missing": [str(t) for t in c.missing], + "added": [str(t) for t in c.added]} for c in results], indent=2)) + + +def main(argv: list[str] | None = None) -> int: + a = _build_parser().parse_args(argv) try: - if a.cmd == "check": - results = [check(p) for p in a.files] - else: - if a.out and len(a.files) != 1: - raise ThumbnailError("--out takes exactly one input file") - sizes = [t for s in a.size for t in parse_spec(s)] - for p in a.files: - src: Callable[[str], Image] | None = None - if a.preview: - def src(gp: str, keep: str = a.preview) -> Image: - want = check(gp).required or sizes - pic = render_gcode(gp, size=render_side(want)) - with open(keep, "wb") as fh: - fh.write(encode_png(pic)) - return pic - results.append(ensure(p, image=a.image, sizes=sizes, out=a.out, source=src, replace=a.replace)) + results = _run_check(a) if a.cmd == "check" else _run_add(a) except (ThumbnailError, InputNotFound) as e: print(f"gcode-thumbnails: {e}", file=sys.stderr) return 2 + except ThreeDError as e: # a slicing.thumbnails GateFailure: the message names the fix + print(e.render(), file=sys.stderr) + return e.exit_code except ImportError as e: # Pillow for --image / JPG output, numpy for rendering print(f"gcode-thumbnails: {e}; install it (e.g. `uv sync --extra preprocess` for Pillow)", file=sys.stderr) return 127 - if a.json: - import json - - print(json.dumps([{"path": c.path, "ok": c.ok, "reason": c.reason, "required": [str(t) for t in c.required], - "present": [str(t) for t in c.present], "missing": [str(t) for t in c.missing], - "added": [str(t) for t in c.added]} for c in results], indent=2)) - else: - for c in results: - print(_describe(c)) + _print_results(results, a.json) return 0 if all(c.ok for c in results) else 1 diff --git a/lib/slicing/orca_cli.py b/lib/slicing/orca_cli.py index f85095bc..4b4a89f5 100644 --- a/lib/slicing/orca_cli.py +++ b/lib/slicing/orca_cli.py @@ -40,11 +40,11 @@ from collections.abc import Iterator, Mapping, Sequence from dataclasses import dataclass, field from pathlib import Path -from typing import Any, Literal +from typing import Any, Literal, NoReturn -from errors import MissingDependency, ThreeDError +from errors import GateFailure, MissingDependency, ThreeDError from prep.contracts import Verdict -from slicing import slice_warnings +from slicing import slice_warnings, thumbnails CLI_FALLBACK_VERSION = "01.10.01.50" INSTALL_HINT = ( @@ -182,7 +182,9 @@ class Quirk: "no-thumbnails", "The sliced G-code has no thumbnail, so the printer screen shows no preview.", "The CLI's export_gcode gets a null thumbnail callback.", - "Add thumbnails afterwards with `3d gcode-thumbnails add FILE`.", + "Nothing to do here: every slice through this module asks for 48x48/96x96/300x300 in the settings and " + "draws the missing ones from the toolpaths afterwards (`slicing.thumbnails`); by hand: " + "`3d gcode-thumbnails add FILE`.", "babushkin-dom gen/verify_slices.py (comment before add_thumbnails)", ), Quirk( @@ -346,7 +348,13 @@ def slicing_copy( # --------------------------------------------------------------------------- failures FailureClass = Literal[ - "segfault", "timeout", "lock_busy", "exclude_triangles", "return_code", "no_gcode" + "segfault", + "timeout", + "lock_busy", + "exclude_triangles", + "return_code", + "no_gcode", + "thumbnails", ] @@ -358,6 +366,36 @@ class Failure: quirk_ids: tuple[str, ...] = () +def _lock_was_busy( + heavy: bool, lock_busy_code: int | None, returncode: int, res: Mapping[str, Any] +) -> bool: + """True when the heavy lock wrapper gave up (its busy exit code, and the CLI never ran to write a result).""" + return ( + heavy + and lock_busy_code is not None + and returncode == lock_busy_code + and not res + ) + + +def _return_code_failure(code: Any, log: str, res: Mapping[str, Any]) -> Failure: + """The `return_code` failure: `code` is result.json's non-zero return_code, else the process exit code.""" + detail = str(res.get("error_string") or "").strip() + tail = "\n".join(log.strip().splitlines()[-5:]) + return Failure( + "return_code", + f"the slicer reported failure (return code {code})" + + (f": {detail}" if detail else (f"; log tail: {tail}" if tail else "")), + ( + "Read slice.log in the work dir; the last [error] line names the cause.", + "Error -101 is the plate arrangement: keep `--arrange 0`.", + "Error -24 'Unsupported 3MF version': the project's Application tag is newer than the CLI's " + "build number; stamp it with `orca_version`.", + ), + ("arrange-moves-objects", "application-tag-newer"), + ) + + def classify_failure( returncode: int, log: str, @@ -394,12 +432,7 @@ def classify_failure( "Raise timeout_s, or slice a smaller model; check the slice.log tail for the stage it stuck in.", ), ) - if ( - heavy - and lock_busy_code is not None - and returncode == lock_busy_code - and not res - ): + if _lock_was_busy(heavy, lock_busy_code, returncode, res): return Failure( "lock_busy", "the machine-wide heavy lock stayed busy for the whole bounded wait", @@ -417,21 +450,7 @@ def classify_failure( ) bad_rc = res_rc not in (0, None) if bad_rc or returncode != 0: - code = res_rc if bad_rc else returncode - detail = str(res.get("error_string") or "").strip() - tail = "\n".join(log.strip().splitlines()[-5:]) - return Failure( - "return_code", - f"the slicer reported failure (return code {code})" - + (f": {detail}" if detail else (f"; log tail: {tail}" if tail else "")), - ( - "Read slice.log in the work dir; the last [error] line names the cause.", - "Error -101 is the plate arrangement: keep `--arrange 0`.", - "Error -24 'Unsupported 3MF version': the project's Application tag is newer than the CLI's " - "build number; stamp it with `orca_version`.", - ), - ("arrange-moves-objects", "application-tag-newer"), - ) + return _return_code_failure(res_rc if bad_rc else returncode, log, res) if not has_gcode: return Failure( "no_gcode", @@ -632,21 +651,32 @@ def _prepare_workdir(work: Path) -> Path: return work +def _heavy_guard(heavy: bool) -> tuple[list[str], int | None, int]: + """(command prefix, the lock wrapper's busy exit code, seconds the lock wait may add) for `heavy`.""" + if not heavy: + return [], None, 0 + from cli.heavy import DEFAULT_WAIT_S, EXIT_BUSY, heavy_prefix + + prefix = heavy_prefix() + return prefix, EXIT_BUSY, DEFAULT_WAIT_S if prefix else 0 + + +def _finalize_thumbnails(work: Path, sliced: Path) -> Failure | None: + """Layer (b): the CLI never writes thumbnails; draw them before anyone hashes or copies the G-code.""" + try: + thumbnails.finalize_workdir(work, sliced) + except GateFailure as exc: + return Failure("thumbnails", exc.message, tuple(exc.remediation)) + return None + + def _run_slicer( cli: OrcaCli, args: list[str], work: Path, *, heavy: bool, timeout_s: int ) -> SliceOutcome: """Run `CLI --debug 2 --datadir WORK/dd ARGS` in `work` and collect the outcome. `heavy=True` puts the call under the machine-wide heavy lock/cap (`cli.heavy.heavy_prefix`, `[]` when the caller already holds it). Writes WORK/slice.log.""" - prefix: list[str] = [] - lock_busy_code: int | None = None - lock_wait_s = 0 - if heavy: - from cli.heavy import DEFAULT_WAIT_S, EXIT_BUSY, heavy_prefix - - prefix = heavy_prefix() - lock_busy_code = EXIT_BUSY - lock_wait_s = DEFAULT_WAIT_S if prefix else 0 + prefix, lock_busy_code, lock_wait_s = _heavy_guard(heavy) timeout_bin = _gnu_timeout() # --foreground: no setpgid, so `timeout` and the slicer stay in the group `_run` kills. Without it # (behind `flock`, which is no session leader) they move to a group of their own and escape the kill. @@ -694,9 +724,11 @@ def _run_slicer( lock_busy_code=lock_busy_code, timed_out=timed_out, ) + sliced = work / "sliced.3mf" + if failure is None and gcode is not None: + failure = _finalize_thumbnails(work, sliced) warnings = collect_warnings(work, log) verdict: Verdict = "FAIL" if failure else ("WARN" if warnings else "PASS") - sliced = work / "sliced.3mf" return SliceOutcome( verdict=verdict, workdir=work, @@ -736,7 +768,12 @@ def slice_project( ) work = _prepare_workdir(Path(workdir)) copy = work / "in.3mf" - slicing_copy(project, copy, set_cfg=set_cfg, drop_layer_profile=drop_layer_profile) + slicing_copy( + project, + copy, + set_cfg={**thumbnails.thumbnail_cfg(Path(project)), **(set_cfg or {})}, # layer (a) + drop_layer_profile=drop_layer_profile, + ) args = [ "--arrange", "0", @@ -771,9 +808,11 @@ def slice_stl_with_profiles( work = _prepare_workdir(Path(workdir)) model = work / Path(stl).name shutil.copyfile(stl, model) + machine_copy = work / "machine.json" # layer (a): a copy of the machine preset that also asks for thumbnails + thumbnails.with_machine_spec(Path(machine), machine_copy) args = [ "--load-settings", - f"{Path(machine).resolve()};{Path(process).resolve()}", + f"{machine_copy};{Path(process).resolve()}", "--load-filaments", str(Path(filament).resolve()), "--arrange", @@ -928,6 +967,48 @@ def _write_probe_stl(path: Path, cx: float, cy: float) -> None: path.write_text("\n".join(rows) + "\n", encoding="ascii") +def _write_flat_presets( + presets: SystemPresets, wanted: Sequence[tuple[str, str]], tmp: Path +) -> dict[tuple[str, str], Path]: + """Each distinct (kind, name) in `wanted` flattened into a JSON file under `tmp`. + Raises `MissingDependency` when a named preset is not in `presets`.""" + files: dict[tuple[str, str], Path] = {} + for kind, name in wanted: + if (kind, name) in files: + continue + try: + flat = presets.flat(kind, name) + except KeyError as exc: + raise MissingDependency( + f"system {kind} preset {name!r} (missing: {exc.args[0]!r})", + install=f"install Snapmaker Orca and expose its presets at {_RESOURCES_HINT}", + degrades="project_settings.config cannot be generated", + command="prep", + ) from exc + flat.update({"name": name, "from": "system", "instantiation": "true"}) + f = tmp / f"{kind}_{len(files)}.json" # by index: names hold dots, `;` and `/` + f.write_text(json.dumps(flat, indent=1), encoding="utf-8") + files[kind, name] = f + return files + + +def _raise_no_reference_project(returncode: int, log: str, timed_out: bool) -> NoReturn: + """The probe export left no project: name the missing OpenGL runtime if that is why, else the log tail.""" + why = "timed out" if timed_out else f"exit {returncode}" + if "libOpenGL" in log or "libGLU" in log: + raise MissingDependency( + "host OpenGL runtime libraries (libOpenGL.so.0, libGLU)", + install="sudo apt-get install libopengl0 libglu1-mesa; on a box without sudo point ORCA at a wrapper that sets LD_LIBRARY_PATH (e.g. ORCA=~/.local/bin/snapmaker-orca)", + degrades="headless slicing and project_settings generation", + command="prep", + ) + raise ThreeDError( + f"the Orca CLI wrote no reference project ({why})", + command="prep", + remediation=[log[-1500:].strip() or "no output"], + ) + + def reference_project_settings( cli: OrcaCli, presets: SystemPresets, @@ -953,25 +1034,7 @@ def reference_project_settings( ] with tempfile.TemporaryDirectory(prefix="orca-ref-") as tmp_s: tmp = Path(tmp_s) - files: dict[tuple[str, str], Path] = {} - for kind, name in wanted: - if (kind, name) in files: - continue - try: - flat = presets.flat(kind, name) - except KeyError as exc: - raise MissingDependency( - f"system {kind} preset {name!r} (missing: {exc.args[0]!r})", - install=f"install Snapmaker Orca and expose its presets at {_RESOURCES_HINT}", - degrades="project_settings.config cannot be generated", - command="prep", - ) from exc - flat.update({"name": name, "from": "system", "instantiation": "true"}) - f = ( - tmp / f"{kind}_{len(files)}.json" - ) # by index: names hold dots, `;` and `/` - f.write_text(json.dumps(flat, indent=1), encoding="utf-8") - files[kind, name] = f + files = _write_flat_presets(presets, wanted, tmp) probe = tmp / "probe.stl" _write_probe_stl(probe, *bed_centre) prefix: list[str] = [] @@ -999,19 +1062,7 @@ def reference_project_settings( returncode, log, timed_out = _run(command, timeout_s) out = tmp / "probe.3mf" if not out.exists(): - why = "timed out" if timed_out else f"exit {returncode}" - if "libOpenGL" in log or "libGLU" in log: - raise MissingDependency( - "host OpenGL runtime libraries (libOpenGL.so.0, libGLU)", - install="sudo apt-get install libopengl0 libglu1-mesa; on a box without sudo point ORCA at a wrapper that sets LD_LIBRARY_PATH (e.g. ORCA=~/.local/bin/snapmaker-orca)", - degrades="headless slicing and project_settings generation", - command="prep", - ) - raise ThreeDError( - f"the Orca CLI wrote no reference project ({why})", - command="prep", - remediation=[log[-1500:].strip() or "no output"], - ) + _raise_no_reference_project(returncode, log, timed_out) with zipfile.ZipFile(out) as z: cfg = json.loads(z.read("Metadata/project_settings.config")) if not isinstance(cfg, dict): diff --git a/lib/slicing/thumbnails.py b/lib/slicing/thumbnails.py new file mode 100644 index 00000000..8cebff94 --- /dev/null +++ b/lib/slicing/thumbnails.py @@ -0,0 +1,341 @@ +"""slicing/thumbnails.py — the layers that make a thumbnail impossible to miss in a sliced G-code. + +WHAT: the shared glue between the slicing wrappers / the printer commands and lib/gcode_thumbnails.py. + Layer (a) `spec_for` / `with_project_spec` / `with_machine_spec` / `PRUSA_ARGS`: ask the slicer for + 48x48, 96x96 and 300x300 PNG in its own settings, so a slicer that writes thumbnails (Orca's GUI, + PrusaSlicer, Bambu Studio) does, and the config block of every other G-code names the sizes the + checks require. + Layer (b) `finalize_slice` / `finalize_gcode` / `finalize_plate_images`: after the slice, draw the + missing sizes from the toolpaths into the G-code (and the missing Metadata/plate_N.png into a 3MF + project). Idempotent: complete output stays byte-identical. + Layer (c) `fix_in_place`: what `3d printer upload|start` call before sending a G-code. + +WHY: the Orca-family CLI exports G-code with a null thumbnail callback, so a headless slice never has + a preview (docs/commands/gcode-thumbnails.md). Every path that produces a G-code goes through + one of these functions; tests/test_thumbnail_guard.py fails when a module that runs a slicer + does not. + +INVARIANTS: stdlib at import (numpy/Pillow come in with gcode_thumbnails, lazily). A failure raises + GateFailure naming the exact `3d gcode-thumbnails add` command: a slice whose preview cannot be + written is not reported as done. +""" + +from __future__ import annotations + +import json +import os +import re +import shlex +import tempfile +import zipfile +from collections.abc import Callable, Mapping +from pathlib import Path +from typing import TYPE_CHECKING + +from errors import GateFailure + +if TYPE_CHECKING: + from gcode_thumbnails import Image, ThumbCheck + +PLATE_SIDE = 512 # Metadata/plate_N.png, as the Orca GUI writes it +PLATE_SMALL_SIDE = 128 # Metadata/plate_N_small.png +RENDER_SIDE = ( + 1024 # one toolpath render feeds every size (2x supersampled up to 512 px) +) +SETTINGS_MEMBER = "Metadata/project_settings.config" +# PrusaSlicer takes every config option as a CLI flag; it writes the blocks itself when given sizes. +PRUSA_ARGS = ["--thumbnails", "48x48,96x96,300x300", "--thumbnails-format", "PNG"] +_THUMB_REL = ( + "http://schemas.openxmlformats.org/package/2006/relationships/metadata/thumbnail" +) +_PLATE_GCODE = re.compile(r"plate_(\d+)\.gcode$", re.I) + + +def fix_command(path: str | Path) -> str: + """The command that repairs a G-code without its thumbnails (what every message prints).""" + return f"3d gcode-thumbnails add {shlex.quote(str(path))}" + + +# ------------------------------------------------------------ (a) ask the slicer for them + + +def spec_for(cfg: Mapping[str, object]) -> str | list[str]: + """The `thumbnails` value of settings `cfg` (a project's config or a machine preset) extended by DEFAULT_SIZES, in the + shape the preset uses: Orca presets hold either `"48x48/PNG, 300x300/PNG"` or a list of sizes.""" + from gcode_thumbnails import ThumbnailError, merge_spec + + value = cfg.get("thumbnails") + fmt = cfg.get("thumbnails_format") + existing = value if isinstance(value, (str, list)) else None + try: + merged = merge_spec(existing, fmt.upper() if isinstance(fmt, str) and fmt else "PNG") + except ThumbnailError: # a size the slicer itself would reject: ask for the defaults only + merged = merge_spec(None) + return merged.split(", ") if isinstance(value, list) else merged + + +def _rewrite_zip( + src: Path, + dst: Path, + edit: Callable[[str, bytes], bytes | None], + extra: Mapping[str, bytes] | None = None, +) -> None: + """Copy zip `src` to `dst` (atomically; `dst` may be `src`) member by member with the zip metadata kept; + `edit(name, data)` returns the new bytes of a member or None to keep it. `extra` members are appended. + Mode and mtime of `src` carry over: a project rewritten for its thumbnails must not look newer than the + G-code sliced from it (the staleness gate).""" + st = src.stat() + with tempfile.NamedTemporaryFile( + dir=dst.parent, prefix=f".{dst.name}.", suffix=".partial", delete=False + ) as h: + tmp = Path(h.name) + try: + with ( + zipfile.ZipFile(src) as zi, + zipfile.ZipFile(tmp, "w", zipfile.ZIP_DEFLATED) as zo, + ): + for item in zi.infolist(): + data = zi.read(item.filename) + new = edit(item.filename, data) + zo.writestr(item, data if new is None else new) + for name, data in (extra or {}).items(): + zo.writestr(name, data) + os.chmod(tmp, st.st_mode & 0o7777) + os.utime(tmp, ns=(st.st_atime_ns, st.st_mtime_ns)) + tmp.replace(dst) + finally: + tmp.unlink(missing_ok=True) + + +def with_project_spec(src: Path, dst: Path) -> None: + """Copy of 3MF project `src` whose `thumbnails` setting also asks for DEFAULT_SIZES. Everything else + keeps its bytes. A zip without Metadata/project_settings.config is copied as it is.""" + + def edit(name: str, data: bytes) -> bytes | None: + if name != SETTINGS_MEMBER: + return None + cfg = json.loads(data) + cfg["thumbnails"] = spec_for(cfg) + return json.dumps(cfg, indent=4, ensure_ascii=False).encode() + + _rewrite_zip(src, dst, edit) + + +def thumbnail_cfg(project: Path) -> dict[str, str | list[str]]: + """`set_cfg` entry that makes 3MF `project` ask for DEFAULT_SIZES on top of its own `thumbnails`; + {} for a zip without Metadata/project_settings.config (nothing to override).""" + with zipfile.ZipFile(project) as z: + if SETTINGS_MEMBER not in z.namelist(): + return {} + cfg = json.loads(z.read(SETTINGS_MEMBER)) + return {"thumbnails": spec_for(cfg)} + + +def with_machine_spec(src: Path, dst: Path) -> None: + """Copy of a flat Orca machine preset (JSON) that also asks for DEFAULT_SIZES.""" + cfg = json.loads(src.read_text(encoding="utf-8")) + cfg["thumbnails"] = spec_for(cfg) + dst.write_text(json.dumps(cfg, indent=1), encoding="utf-8") + + +# ------------------------------------------------------------ (b) draw what the slicer did not + + +def _fail(path: Path | str, why: str) -> GateFailure: + return GateFailure( + f"{path}: thumbnails could not be added ({why})", + command="gcode-thumbnails", + remediation=[ + f"Add them by hand: {fix_command(path)}", + "numpy and Pillow are core dependencies: `uv sync` repairs a broken install.", + ], + ) + + +def placeholder(side: int) -> Image: + """A flat grey tile with a darker rim: the picture of a G-code that extrudes nothing visible, so that + even a macro-only file carries every thumbnail its checks require.""" + import numpy as np + + img = np.zeros((side, side, 4), np.uint8) + margin, rim = side // 8, max(side // 32, 1) + img[margin : side - margin, margin : side - margin] = (70, 70, 70, 255) + img[margin + rim : side - margin - rim, margin + rim : side - margin - rim] = (130, 130, 130, 255) + return img + + +class Picture: + """One lazy toolpath render shared by the G-code blocks and the plate images of the same slice.""" + + def __init__(self, gcode: Path) -> None: + self.gcode = gcode + self._image: Image | None = None + + def __call__(self, _path: str = "") -> Image: + if self._image is None: + from gcode_thumbnails import ThumbnailError, render_gcode + + try: + self._image = render_gcode(str(self.gcode), size=RENDER_SIDE) + except ThumbnailError: # nothing visible to draw (console macros, travel-only): a neutral tile + self._image = placeholder(RENDER_SIDE) + return self._image + + +def finalize_gcode( + gcode: Path, picture: Callable[[str], Image] | None = None +) -> ThumbCheck: + """Layer (b): every required size is in `gcode` afterwards (added in place when missing), else GateFailure. + A complete G-code is not touched.""" + import gcode_thumbnails as gt + + try: + result = gt.ensure(str(gcode), source=picture or Picture(gcode)) + except (gt.ThumbnailError, OSError, ImportError) as exc: + raise _fail(gcode, str(exc)) from exc + if not result.ok: + raise _fail(gcode, result.reason) + return result + + +def plate_members(plate: int) -> tuple[str, str]: + return f"Metadata/plate_{plate}.png", f"Metadata/plate_{plate}_small.png" + + +def missing_plate_images(three_mf: Path, plate: int = 1) -> list[str]: + """The Metadata/plate_N PNGs `three_mf` lacks (a member that is not a PNG counts as missing).""" + with zipfile.ZipFile(three_mf) as z: + names = set(z.namelist()) + return [ + m + for m in plate_members(plate) + if m not in names or not z.read(m).startswith(b"\x89PNG\r\n\x1a\n") + ] + + +def finalize_plate_images( + three_mf: Path, plate: int, picture: Callable[[str], Image] +) -> list[str]: + """Add the missing Metadata/plate_N.png / plate_N_small.png (drawn from `picture`) to `three_mf` in place and + return the members written; a 3MF that has both is left byte-identical. Plate 1 also gets the package + thumbnail relationship when the package names none.""" + missing = missing_plate_images(three_mf, plate) + if not missing: + return [] + from gcode_thumbnails import encode_png, fit + + pic = picture("") + side = { + plate_members(plate)[0]: PLATE_SIDE, + plate_members(plate)[1]: PLATE_SMALL_SIDE, + } + images = {m: encode_png(fit(pic, side[m], side[m])) for m in missing} + + def edit(name: str, data: bytes) -> bytes | None: + if name in images: # a corrupt member is replaced, not duplicated + return images.pop(name) + if name == "[Content_Types].xml" and b'Extension="png"' not in data: + return data.replace( + b"", + b' \n', + ) + if name == "_rels/.rels" and plate == 1 and _THUMB_REL.encode() not in data: + rel = f'' + return data.replace(b"", rel.encode() + b"") + return None + + with zipfile.ZipFile(three_mf) as z: + present = set(z.namelist()) + new_members = {m: images[m] for m in missing if m not in present} + _rewrite_zip(three_mf, three_mf, edit, extra=new_members) + return missing + + +def plates_of(three_mf: Path) -> list[int]: + """Plate numbers a 3MF carries a G-code for (`Metadata/plate_N.gcode`); [1] when it has none.""" + with zipfile.ZipFile(three_mf) as z: + found = sorted({int(m.group(1)) for n in z.namelist() if (m := _PLATE_GCODE.search(n))}) + return found or [1] + + +def check_plate_images(three_mf: Path) -> ThumbCheck: + """`3d gcode-thumbnails check X.3mf`: every plate has plate_N.png and plate_N_small.png (a 3MF is not a + printer input, so a missing one is reported like a missing thumbnail but never blocks a print).""" + from gcode_thumbnails import Thumb, ThumbCheck + + big, small = Thumb(PLATE_SIDE, PLATE_SIDE, "PNG"), Thumb(PLATE_SMALL_SIDE, PLATE_SMALL_SIDE, "PNG") + required: list[Thumb] = [] + missing: list[Thumb] = [] + for plate in plates_of(three_mf): + lacking = missing_plate_images(three_mf, plate) + for member, size in zip(plate_members(plate), (big, small), strict=True): + required.append(size) + if member in lacking: + missing.append(size) + ok = not missing + reason = "every plate has its images" if ok else "missing " + ", ".join( + m for p in plates_of(three_mf) for m in missing_plate_images(three_mf, p)) + return ThumbCheck(str(three_mf), ok, required, [t for t in required if t not in missing], missing, reason) + + +def finalize_embedded(three_mf: Path, picture: Callable[[str], Image] | None = None) -> ThumbCheck: + """Add the missing plate images to a `.gcode.3mf`/sliced project, drawn from `picture` or from each plate's + embedded `Metadata/plate_N.gcode`. A 3MF without an embedded G-code needs `picture`.""" + try: + for plate in plates_of(three_mf): + if not missing_plate_images(three_mf, plate): + continue + if picture is not None: + finalize_plate_images(three_mf, plate, picture) + continue + with tempfile.TemporaryDirectory(prefix="3d-plate-") as tmp, zipfile.ZipFile(three_mf) as z: + member = f"Metadata/plate_{plate}.gcode" + if member not in z.namelist(): + raise ValueError(f"{three_mf} has no {member} to draw the image from; pass --image PNG") + gcode = Path(tmp) / f"plate_{plate}.gcode" + gcode.write_bytes(z.read(member)) + finalize_plate_images(three_mf, plate, Picture(gcode)) + except (OSError, ImportError, zipfile.BadZipFile, ValueError) as exc: + raise _fail(three_mf, str(exc)) from exc + return check_plate_images(three_mf) + + +def finalize_slice( + gcode: Path, three_mf: Path | None = None, plate: int | None = None +) -> ThumbCheck: + """Layer (b) for one slice: the G-code, and the project/gcode.3mf next to it when given, from one render. + `plate` defaults to the number in `plate_N.gcode`, else 1.""" + picture = Picture(gcode) + result = finalize_gcode(gcode, picture) + if three_mf is not None and three_mf.is_file(): + match = _PLATE_GCODE.search(gcode.name) + number = plate if plate is not None else int(match.group(1)) if match else 1 + try: + finalize_plate_images(three_mf, number, picture) + except (OSError, ImportError, zipfile.BadZipFile, ValueError) as exc: + raise _fail(three_mf, str(exc)) from exc + return result + + +def finalize_workdir(workdir: Path, three_mf: Path | None = None) -> list[Path]: + """Layer (b) over a slicer output directory: every non-empty `*.gcode` in it. Returns the G-codes.""" + gcodes = [g for g in sorted(workdir.glob("*.gcode")) if g.stat().st_size > 0] + for g in gcodes: + finalize_slice( + g, three_mf if len(gcodes) == 1 or _PLATE_GCODE.search(g.name) else None + ) + return gcodes + + +# ------------------------------------------------------------ (c) before a printer gets the file + + +def fix_in_place(gcode: Path) -> ThumbCheck | None: + """`3d printer upload|start`: add what `gcode` lacks, in place (atomically, mode/mtime kept, the slice-warnings + record follows the new hash). None when the G-code was already complete. The remote name is + content-addressed, so the fixed file gets its own name and the next upload/start refer to the same bytes.""" + from gcode_thumbnails import check + + if check(str(gcode)).ok: + return None + return finalize_gcode(gcode) diff --git a/tests/test_gcode_thumbnails.py b/tests/test_gcode_thumbnails.py index 4136dff4..521f3ccf 100644 --- a/tests/test_gcode_thumbnails.py +++ b/tests/test_gcode_thumbnails.py @@ -145,13 +145,12 @@ def test_ensure_adds_only_the_missing_size_after_existing_blocks(tmp_path: Path) assert [(w, h) for w, h, _ in _moonraker_blocks(g.read_text(encoding="ascii"))] == [(48, 48), (96, 96)] -def test_no_spec_needs_sizes_and_passes_with_any_thumbnail(tmp_path: Path) -> None: +def test_no_spec_requires_the_default_sizes_and_extra_sizes_are_added_on_top(tmp_path: Path) -> None: g = make_gcode(tmp_path / "a.gcode", spec=None) c = gt.check(str(g)) - assert not c.ok and c.required == [] - with pytest.raises(gt.ThumbnailError): - gt.ensure(str(g)) - gt.ensure(str(g), sizes=[gt.Thumb(32, 32, "PNG")]) + assert not c.ok and c.required == list(gt.DEFAULT_SIZES) + c = gt.ensure(str(g), sizes=[gt.Thumb(32, 32, "PNG")]) + assert c.ok and c.added == [*gt.DEFAULT_SIZES, gt.Thumb(32, 32, "PNG")] assert gt.check(str(g)).ok diff --git a/tests/test_thumbnail_guard.py b/tests/test_thumbnail_guard.py new file mode 100644 index 00000000..422cf154 --- /dev/null +++ b/tests/test_thumbnail_guard.py @@ -0,0 +1,74 @@ +"""Gate: no module of lib/ may run a slicer that writes a G-code without handing that G-code to a thumbnail layer. + +The Orca CLI writes no thumbnails (docs/commands/gcode-thumbnails.md). A new slicing entry point that forgets the +layer would ship G-code that `3d print-preflight` refuses; this test fails the commit that adds it. A module +that runs a slicer for something other than a kept G-code must say so in EXEMPT, with the reason.""" + +from __future__ import annotations + +import re +from pathlib import Path + +LIB = Path(__file__).resolve().parents[1] / "lib" + +# a slicer invocation that writes files: the Orca-family `--slice` / `--outputdir` run, or PrusaSlicer's `-g --output` +RUNS_SLICER = re.compile(r'"--slice"|"--outputdir"|"-g",\s*"--output"') +# the layer: slicing.thumbnails finalize_* (or gcode_thumbnails.ensure) applied to what the slicer wrote +HAS_LAYER = re.compile(r"\b(?:finalize_(?:gcode|slice|workdir)|ensure)\(") + +EXEMPT = { + "commands/slice_check.py": "a verification gate: the G-code it slices is counted and deleted, never kept", + "slicing/mix_tester.py": "slices a 10 mm probe only to read the project settings back; no G-code is kept or printed", + "slicing/calibration_plates/u1_underlay.py": ( + "KNOWN GAP, issue #724: the module is over the 2400-line lint gate and cannot be committed until it is split; " + "its plate G-code still gets thumbnails from `3d printer upload|start` and `print-preflight --fix`" + ), +} + + +def _slicer_modules() -> list[str]: + return [ + path.relative_to(LIB).as_posix() + for path in sorted(LIB.rglob("*.py")) + if RUNS_SLICER.search(path.read_text(encoding="utf-8")) + ] + + +def test_every_module_that_slices_to_gcode_applies_a_thumbnail_layer() -> None: + uncovered = [ + rel + for rel in _slicer_modules() + if rel not in EXEMPT + and not HAS_LAYER.search((LIB / rel).read_text(encoding="utf-8")) + ] + assert not uncovered, ( + f"{uncovered} run a slicer but never call slicing.thumbnails.finalize_gcode/finalize_slice/finalize_workdir " + "(or gcode_thumbnails.ensure) on its output: the Orca CLI writes no thumbnails, so the G-code would fail " + "`3d print-preflight`. Call the layer before the G-code is hashed or moved, or add the module to EXEMPT " + "with the reason." + ) + + +def test_exemptions_are_still_slicer_runners() -> None: + """A stale exemption hides a module that was rewritten into a real slicing path.""" + runners = set(_slicer_modules()) + assert set(EXEMPT) <= runners, ( + f"EXEMPT lists modules that no longer run a slicer: {set(EXEMPT) - runners}" + ) + + +def test_the_guard_sees_the_known_entry_points() -> None: + """If the patterns stop matching, the gate above would pass for the wrong reason.""" + assert { + "commands/slice.py", + "commands/retarget.py", + "slicing/orca_cli.py", + "slicing/calibration_gen.py", + "slicing/calibration_plates/u1_underlay.py", + } <= set(_slicer_modules()) + + +def test_gcode_resume_gives_its_derived_gcode_the_thumbnails_too() -> None: + """`3d gcode-resume` writes a new G-code from an old one's head: when that had no thumbnails the resume must get them.""" + source = (LIB / "commands" / "gcode_resume.py").read_text(encoding="utf-8") + assert "finalize_gcode(dest)" in source diff --git a/tests/test_thumbnail_layers.py b/tests/test_thumbnail_layers.py new file mode 100644 index 00000000..658e8e52 --- /dev/null +++ b/tests/test_thumbnail_layers.py @@ -0,0 +1,320 @@ +"""The thumbnail layers one by one (lib/slicing/thumbnails.py): each one alone gets a G-code from +"no thumbnails" / "some" to "all", and none of them touches a G-code that already has them.""" + +from __future__ import annotations + +import json +import os +import stat +import sys +import zipfile +from pathlib import Path + +import pytest +from thumbnail_fixtures import ( + ALL_SIZES, + MACROS_ONLY, + bare, + block_sizes, + complete, + partial, + plate_project, +) + +import gcode_thumbnails as gt +from errors import GateFailure +from slicing import orca_cli, slice_warnings, thumbnails + +FIXTURES = {"bare": bare, "partial": partial, "complete": complete} + + +# ------------------------------------------------------------------ (a) ask the slicer + + +def test_spec_for_adds_the_default_sizes_in_the_shape_the_preset_uses() -> None: + assert thumbnails.spec_for({}) == "48x48/PNG, 96x96/PNG, 300x300/PNG" + assert ( + thumbnails.spec_for({"thumbnails": "300x300/PNG"}) + == "300x300/PNG, 48x48/PNG, 96x96/PNG" + ) + assert thumbnails.spec_for( + {"thumbnails": ["230x110"], "thumbnails_format": "JPG"} + ) == ["230x110/JPG", "48x48/PNG", "96x96/PNG", "300x300/PNG"] + assert thumbnails.spec_for({"thumbnails": "48x48/PNG, 96x96/PNG, 300x300/PNG"}) == ( + "48x48/PNG, 96x96/PNG, 300x300/PNG" + ) + assert ( + thumbnails.spec_for({"thumbnails": "nonsense"}) + == "48x48/PNG, 96x96/PNG, 300x300/PNG" + ) + + +def test_project_copy_changes_only_the_thumbnails_setting(tmp_path: Path) -> None: + src = plate_project(tmp_path / "in.3mf") + dst = tmp_path / "out.3mf" + thumbnails.with_project_spec(src, dst) + with zipfile.ZipFile(src) as a, zipfile.ZipFile(dst) as b: + assert a.namelist() == b.namelist() + for name in a.namelist(): + if name != "Metadata/project_settings.config": + assert a.read(name) == b.read(name) + assert json.loads(b.read("Metadata/project_settings.config")) == { + "layer_height": "0.2", + "thumbnails": "48x48/PNG, 96x96/PNG, 300x300/PNG", + } + assert thumbnails.thumbnail_cfg(src) == { + "thumbnails": "48x48/PNG, 96x96/PNG, 300x300/PNG" + } + + +def test_project_without_settings_gets_no_override(tmp_path: Path) -> None: + path = tmp_path / "bare.3mf" + with zipfile.ZipFile(path, "w") as z: + z.writestr("3D/3dmodel.model", b"") + assert thumbnails.thumbnail_cfg(path) == {} + + +def test_machine_preset_copy_asks_for_the_default_sizes(tmp_path: Path) -> None: + src = tmp_path / "machine.json" + src.write_text(json.dumps({"name": "M", "thumbnails": "48x48/PNG"})) + dst = tmp_path / "machine.thumbnails.json" + thumbnails.with_machine_spec(src, dst) + assert json.loads(dst.read_text()) == { + "name": "M", + "thumbnails": "48x48/PNG, 96x96/PNG, 300x300/PNG", + } + assert ( + json.loads(src.read_text())["thumbnails"] == "48x48/PNG" + ) # the user's preset stays as it is + + +def test_prusa_gets_the_sizes_as_cli_flags() -> None: + assert thumbnails.PRUSA_ARGS == [ + "--thumbnails", + "48x48,96x96,300x300", + "--thumbnails-format", + "PNG", + ] + + +# ------------------------------------------------------------------ (b) draw what is missing + + +@pytest.mark.parametrize("kind", FIXTURES) +def test_finalize_gcode_gives_every_kind_of_input_all_the_sizes_once( + tmp_path: Path, kind: str +) -> None: + path = FIXTURES[kind](tmp_path / f"{kind}.gcode") + before = path.read_bytes() + pre_existing = block_sizes(path) + + result = thumbnails.finalize_gcode(path) + + assert result.ok and gt.check(str(path)).ok + assert sorted(block_sizes(path)) == sorted(ALL_SIZES) # all three, none twice + after = path.read_bytes() + assert after.endswith( + before[before.index(b"G90") :] + ) # the toolpath and config are untouched + if kind == "complete": + assert after == before and result.added == [] + if kind == "partial": + assert pre_existing == [(48, 48)] + first = before[ + before.index(b"; THUMBNAIL_BLOCK_START") : before.index( + b"; THUMBNAIL_BLOCK_END" + ) + ] + assert first in after # the 48x48 block that was there is byte-identical + assert [(t.width, t.height) for t in result.added] == [(96, 96), (300, 300)] + + +def test_finalize_gcode_twice_changes_nothing_the_second_time(tmp_path: Path) -> None: + path = bare(tmp_path / "a.gcode") + thumbnails.finalize_gcode(path) + once, mtime = path.read_bytes(), path.stat().st_mtime_ns + thumbnails.finalize_gcode(path) + assert path.read_bytes() == once and path.stat().st_mtime_ns == mtime + + +def test_finalize_gcode_without_a_spec_adds_the_default_sizes(tmp_path: Path) -> None: + path = bare(tmp_path / "a.gcode", spec=None) + thumbnails.finalize_gcode(path) + assert sorted(block_sizes(path)) == sorted(ALL_SIZES) + + +def test_a_macro_only_gcode_still_gets_its_thumbnails(tmp_path: Path) -> None: + """Nothing is extruded, so there is nothing to draw: a neutral tile stands in, the checks still pass.""" + path = bare(tmp_path / "shaper.gcode", body=MACROS_ONLY) + thumbnails.finalize_gcode(path) + assert gt.check(str(path)).ok and sorted(block_sizes(path)) == sorted(ALL_SIZES) + + +def test_finalize_gcode_failure_names_the_repair_command( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + path = bare(tmp_path / "a b.gcode") + + def boom(*_a: object, **_k: object) -> None: + raise gt.ThumbnailError("disk full") + + monkeypatch.setattr(gt, "ensure", boom) + with pytest.raises(GateFailure) as err: + thumbnails.finalize_gcode(path) + assert "disk full" in err.value.message + assert f"3d gcode-thumbnails add '{path}'" in err.value.remediation[0] + + +def test_plate_images_are_added_to_a_project_without_them_once(tmp_path: Path) -> None: + gcode = bare(tmp_path / "plate_1.gcode") + project = plate_project(tmp_path / "sliced.3mf") + stamp = (1_700_000_000, 1_700_000_000) + os.utime(project, stamp) + + thumbnails.finalize_slice(gcode, project) + + with zipfile.ZipFile(project) as z: + names = z.namelist() + assert ( + names.count("Metadata/plate_1.png") == 1 + and names.count("Metadata/plate_1_small.png") == 1 + ) + assert z.read("Metadata/plate_1.png")[:8] == b"\x89PNG\r\n\x1a\n" + assert b'Extension="png"' in z.read("[Content_Types].xml") + assert b"/Metadata/plate_1.png" in z.read("_rels/.rels") + assert z.read("3D/3dmodel.model") == b"" + assert ( + project.stat().st_mtime == stamp[1] + ) # the project must not look newer than the G-code sliced from it + assert thumbnails.missing_plate_images(project) == [] + + snapshot = project.read_bytes() + thumbnails.finalize_slice(gcode, project) + assert project.read_bytes() == snapshot # idempotent + + +def test_a_project_that_already_has_its_plate_pictures_is_not_rewritten( + tmp_path: Path, +) -> None: + gcode = complete(tmp_path / "plate_2.gcode") + png = b"\x89PNG\r\n\x1a\nORIGINAL" + project = plate_project( + tmp_path / "p.3mf", + members={"Metadata/plate_2.png": png, "Metadata/plate_2_small.png": png}, + ) + before = project.read_bytes() + thumbnails.finalize_slice(gcode, project) # plate number comes from the file name + assert project.read_bytes() == before + + +def test_a_corrupt_plate_picture_is_replaced_not_duplicated(tmp_path: Path) -> None: + gcode = bare(tmp_path / "plate_1.gcode") + project = plate_project( + tmp_path / "p.3mf", members={"Metadata/plate_1.png": b"not a png"} + ) + thumbnails.finalize_slice(gcode, project) + with zipfile.ZipFile(project) as z: + assert z.namelist().count("Metadata/plate_1.png") == 1 + assert z.read("Metadata/plate_1.png")[:4] == b"\x89PNG" + + +def test_embedded_plate_gcode_feeds_the_pictures_of_a_gcode_3mf(tmp_path: Path) -> None: + gcode = bare(tmp_path / "g.gcode") + project = plate_project( + tmp_path / "x.gcode.3mf", members={"Metadata/plate_1.gcode": gcode.read_bytes()} + ) + assert not thumbnails.check_plate_images(project).ok + assert thumbnails.finalize_embedded(project).ok + assert thumbnails.check_plate_images(project).ok + + +# ------------------------------------------------------------------ the slicer wrapper (orca_cli) + +FAKE_ORCA = """#!__PYTHON__ +import json, os, sys, zipfile +a = sys.argv[1:] +out = a[a.index("--outputdir") + 1] +with zipfile.ZipFile(a[-1]) as z: + cfg = json.loads(z.read("Metadata/project_settings.config")) +open(os.path.join(out, "seen-thumbnails.json"), "w").write(json.dumps(cfg.get("thumbnails"))) +open(os.path.join(out, "plate_1.gcode"), "w").write(__GCODE__) +with zipfile.ZipFile(os.path.join(out, "sliced.3mf"), "w") as z: + z.writestr("Metadata/slice_info.config", "") + z.writestr("[Content_Types].xml", "") + z.writestr("_rels/.rels", "") +print("[warning] cli mode") +""" + + +@pytest.fixture +def fake_orca(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> orca_cli.OrcaCli: + monkeypatch.setenv( + "THREED_HEAVY_HELD", "1" + ) # as inside the machine lock: no flock prefix + text = bare(tmp_path / "template.gcode").read_text(encoding="ascii") + exe = tmp_path / "fake-orca" + exe.write_text(FAKE_ORCA.replace("__PYTHON__", sys.executable).replace("__GCODE__", repr(text))) + exe.chmod(exe.stat().st_mode | stat.S_IXUSR) + return orca_cli.OrcaCli(exe, "test") + + +def test_slice_project_asks_for_the_sizes_and_returns_a_gcode_that_has_them( + tmp_path: Path, fake_orca: orca_cli.OrcaCli +) -> None: + project = plate_project(tmp_path / "p.3mf") + out = orca_cli.slice_project(project, tmp_path / "work", orca=fake_orca) + + assert out.ok and out.gcode is not None + assert ( + json.loads((tmp_path / "work" / "seen-thumbnails.json").read_text()) + == "48x48/PNG, 96x96/PNG, 300x300/PNG" + ) + assert gt.check(str(out.gcode)).ok and sorted(block_sizes(out.gcode)) == sorted( + ALL_SIZES + ) + assert ( + out.sliced_3mf is not None + and thumbnails.missing_plate_images(out.sliced_3mf) == [] + ) + + +def test_slice_project_fails_loudly_when_the_thumbnails_cannot_be_added( + tmp_path: Path, fake_orca: orca_cli.OrcaCli, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr( + gt, + "ensure", + lambda *_a, **_k: (_ for _ in ()).throw(gt.ThumbnailError("no space")), + ) + out = orca_cli.slice_project( + plate_project(tmp_path / "p.3mf"), tmp_path / "work", orca=fake_orca + ) + assert not out.ok and out.failure is not None and out.failure.kind == "thumbnails" + assert "3d gcode-thumbnails add" in out.failure.remediation[0] + + +# ------------------------------------------------------------------ (c) before a printer gets the file + + +def test_fix_in_place_adds_the_sizes_and_the_slice_warnings_record_follows( + tmp_path: Path, +) -> None: + path = bare(tmp_path / "part.gcode") + slice_warnings.record(path, logs=[("slicer log", "[warning] thin wall")]) + old_sha = slice_warnings.load(slice_warnings.sidecar_path(path))["gcode_sha256"] + + fixed = thumbnails.fix_in_place(path) + + assert fixed is not None and len(fixed.added) == 3 + record = slice_warnings.load(slice_warnings.sidecar_path(path)) + assert record["gcode_sha256"] != old_sha + steps = record["post_processed"] + assert isinstance(steps, list) and steps[-1]["from_sha256"] == old_sha + assert thumbnails.fix_in_place(path) is None # nothing left to do + + +def test_fix_in_place_leaves_a_complete_gcode_alone(tmp_path: Path) -> None: + path = complete(tmp_path / "part.gcode") + before = path.read_bytes() + assert thumbnails.fix_in_place(path) is None + assert path.read_bytes() == before diff --git a/tests/thumbnail_fixtures.py b/tests/thumbnail_fixtures.py new file mode 100644 index 00000000..e9cb4a65 --- /dev/null +++ b/tests/thumbnail_fixtures.py @@ -0,0 +1,75 @@ +"""Fixtures for the thumbnail layers: a G-code with no thumbnails, with some, and with all of them. + +The G-code is what the Orca CLI writes: a header, a few extruding layers, a config block that names +`; thumbnails = 48x48/PNG, 96x96/PNG, 300x300/PNG`. `bare` has no block, `partial` only the 48x48 one, +`complete` all three.""" + +from __future__ import annotations + +import re +import zipfile +from pathlib import Path + +import numpy as np + +import gcode_thumbnails as gt + +SPEC = "48x48/PNG, 96x96/PNG, 300x300/PNG" +ALL_SIZES = [(48, 48), (96, 96), (300, 300)] +HEADER = ( + "; HEADER_BLOCK_START\n; generated by Snapmaker Orca 2.4.0\n; HEADER_BLOCK_END\n\n" +) +BODY = "G90\nM83\nT0\n;TYPE:Outer wall\n" + "".join( + f"G1 Z{z}\nG1 X10 Y10\nG1 X40 Y10 E1\nG1 X40 Y25 E1\nG1 X10 Y25 E1\nG1 X10 Y10 E1\n" + for z in (0.2, 0.4, 0.6) +) +CONFIG = "; CONFIG_BLOCK_START\n; filament_colour = #C12E1F\n; thumbnails = {spec}\n; CONFIG_BLOCK_END\n" +MACROS_ONLY = "G28\nM104 S0\nTEMPERATURE_WAIT SENSOR=extruder MAXIMUM=38\n" + + +def bare(path: Path, spec: str | None = SPEC, body: str = BODY) -> Path: + config = ( + CONFIG.format(spec=spec) + if spec + else CONFIG.replace("; thumbnails = {spec}\n", "") + ) + path.write_text(HEADER + body + config, encoding="ascii") + return path + + +def partial(path: Path) -> Path: + """Only the 48x48 block, drawn from a flat colour so its bytes differ from what ensure() would draw.""" + bare(path) + tile = np.full((48, 48, 4), 200, np.uint8) + gt.splice(str(path), gt.block(gt.Thumb(48, 48, "PNG"), gt.encode_png(tile))) + return path + + +def complete(path: Path) -> Path: + bare(path) + gt.ensure(str(path)) + return path + + +def block_sizes(path: Path) -> list[tuple[int, int]]: + """(w, h) of every thumbnail block in the file, in order (duplicates included).""" + return [ + (int(w), int(h)) + for w, h in re.findall( + r"; thumbnail begin (\d+)x(\d+) \d+", path.read_text(encoding="ascii") + ) + ] + + +def plate_project(path: Path, *, members: dict[str, bytes] | None = None) -> Path: + """A minimal Orca project 3MF with settings but no plate pictures (what the headless CLI exports).""" + base = { + "[Content_Types].xml": b'', + "_rels/.rels": b'', + "3D/3dmodel.model": b"", + "Metadata/project_settings.config": b'{"layer_height": "0.2"}', + } + with zipfile.ZipFile(path, "w", zipfile.ZIP_DEFLATED) as z: + for name, data in {**base, **(members or {})}.items(): + z.writestr(name, data) + return path From 9e543ad71b712fc835f9873d0a04a3fcfac55927 Mon Sep 17 00:00:00 2001 From: alex-mextner <111590326+alex-mextner@users.noreply.github.com> Date: Wed, 4 Nov 2026 23:25:45 +0100 Subject: [PATCH 2/8] feat(printer): upload and start add missing thumbnails; print-preflight names the fix and has --fix (Refs #722) 3d printer upload and start add the thumbnails FILE lacks in place before the snapshot is hashed (atomic, mtime kept, the slice-warnings record follows the new SHA-256, old and new hash printed) instead of sending a G-code that preflight refuses. print-preflight's thumbnails FAIL names the exact 3d gcode-thumbnails add command for the original file; --fix runs it. End-to-end tests run bin/3d against a fake slicer and a fake Moonraker server (no real printer). The legacy over-limit functions of printer.py, print_preflight.py and printer_ops/preflight.py were split into helpers (behavior-preserving) so the whole-file lint gate passes (#707). --- docs/commands/print-preflight.md | 6 +- docs/commands/printer.md | 4 +- lib/commands/print_preflight.py | 219 +++++++--- lib/commands/printer.py | 340 ++++++++++----- lib/printer_ops/preflight.py | 419 ++++++++++--------- tests/e2e/test_printer_workflows.py | 3 +- tests/e2e/test_thumbnail_layers_workflows.py | 233 +++++++++++ tests/test_printer_ops.py | 11 +- tests/test_thumbnail_preflight.py | 35 ++ 9 files changed, 899 insertions(+), 371 deletions(-) create mode 100644 tests/e2e/test_thumbnail_layers_workflows.py create mode 100644 tests/test_thumbnail_preflight.py diff --git a/docs/commands/print-preflight.md b/docs/commands/print-preflight.md index 85ab46d9..ce9fb648 100644 --- a/docs/commands/print-preflight.md +++ b/docs/commands/print-preflight.md @@ -10,12 +10,14 @@ input is missing is SKIP and its line says what to pass — it never passes sile ``` 3d print-preflight FILE.gcode [--printer NAME] [--axis-limits X0:X1,Y0:Y1|NAME] [--source-3mf FILE.3mf] [--visible [OBJ:]SPEC]... [--bridge-max MM] [--printops-api URL] [--offline] - [--printops-snapshot FILE] [--accept preset_match|mix_calibration --reason TEXT] [--json] [--secs S] [--no-bed-move] + [--printops-snapshot FILE] [--accept preset_match|mix_calibration --reason TEXT] [--json] [--secs S] [--no-bed-move] [--fix] ``` +`--fix` adds the thumbnails the G-code lacks, in place (header only; mtime kept, a current slice-warnings record follows the new SHA-256), then runs the checks. It is the only option that writes. + | Check | When | FAIL means | |---|---|---| -| `thumbnails` | always | The G-code lacks preview images its `; thumbnails =` spec asks for (fix: `3d gcode-thumbnails add FILE`). | +| `thumbnails` | always | The G-code lacks preview images its `; thumbnails =` spec asks for (48x48, 96x96 and 300x300 PNG when it names none). The line names the exact fix, `3d gcode-thumbnails add 'FILE'`; `--fix` runs it. [Layers](gcode-thumbnails.md#layers-a-thumbnail-cannot-be-missing) add thumbnails earlier, so this FAIL means the G-code came from outside them. | | `printer` | `--printer` | The printer is printing, paused, busy, or its state cannot be read. | | `bed` | `--printer` with `watch.bed.enabled` (SKIP otherwise) | The camera sees objects on the bed or the build plate missing against the empty-bed reference; WARN when it cannot tell (no reference at this Z, plate out of view, camera down). See [Bed state](printer.md#bed-state). On an idle printer with `watch.bed.auto_view` (the U1: its camera sees 15 % of the plate at the Z 200 rest) the bed is moved in Z only to where the whole plate is in view, judged there and put back, and the line names the Z used; never while printing/paused, never homing; `--no-bed-move` judges at the current Z. | | `slot Tn` | `--printer` on a printer with per-head data (Snapmaker U1) | Tool *n* is used by the G-code but head T*n+1* is empty, or its colour/material differs from `; filament_colour` / `; filament_type`. | diff --git a/docs/commands/printer.md b/docs/commands/printer.md index 4ed17330..fca03efd 100644 --- a/docs/commands/printer.md +++ b/docs/commands/printer.md @@ -45,8 +45,8 @@ Epic: [#90](https://github.com/alex-mextner/3d-cli/issues/90). |---|---| | `list` | Every fleet and configured printer: owner, access, model, address, heads (an unusable head with its reason), and where the device settings came from when a user/project file overrides the fleet. | | `status NAME` | State, current file and progress, temperatures, and (U1) what each head holds. | -| `upload NAME FILE` | Store the file on the printer and verify its size there. Never starts a print. | -| `start NAME FILE --yes` | Start a file that was uploaded before. Runs [`3d print-preflight`](print-preflight.md) first. | +| `upload NAME FILE` | Store the file on the printer and verify its size there. Never starts a print. A FILE without its preview thumbnails gets them first, in place (old and new SHA-256 printed; see [`3d gcode-thumbnails`](gcode-thumbnails.md#layers-a-thumbnail-cannot-be-missing)). | +| `start NAME FILE --yes` | Start a file that was uploaded before. Runs [`3d print-preflight`](print-preflight.md) first. Adds missing thumbnails to FILE in place like `upload`; the fixed bytes have another content-addressed name, so upload again. | | `cancel NAME --yes` | Cancel the running or paused job. | | `evidence NAME --out DIR` | After EVERY print (done, cancelled, failed): save the Moonraker history job and the klippy.log lines around its pauses and errors as `DIR/-.md` + `.json`, with the layer of each stop when `--gcode` is given; GET only. See [printer-evidence.md](printer-evidence.md). | | `guard NAME --job-id ID --yes` | Authorize one automatic native PAUSE of this exact live Moonraker job on confirmed camera failure; [policy and readiness](printer-vision.md#one-job-automatic-pause). | diff --git a/lib/commands/print_preflight.py b/lib/commands/print_preflight.py index c96d6c50..6fc92aad 100644 --- a/lib/commands/print_preflight.py +++ b/lib/commands/print_preflight.py @@ -19,21 +19,36 @@ https://github.com/alex-mextner/3d-cli/issues/96); running it alone answers "is this file ready to go on that printer?" without touching anything. It only READS from the printer. -INVARIANTS: stdlib-only at import time; printer_ops imported lazily inside run(). +INVARIANTS: stdlib-only at import time; printer_ops imported lazily inside the helpers and run(). """ from __future__ import annotations +import json +import sys +from dataclasses import dataclass +from pathlib import Path +from typing import TYPE_CHECKING + from cli.registry import Command from errors import GateFailure, InputNotFound, UsageError +if TYPE_CHECKING: # typing-only: printer_ops stays a lazy import inside the helpers + from printer_ops.base import PrinterStatus + from printer_ops.devices import Device + from printer_ops.move_bounds import Limits + from printer_ops.preflight import Check + from printer_ops.printops_checks import PrintopsContext + USAGE = """3d print-preflight FILE.gcode [--printer NAME] [--axis-limits X0:X1,Y0:Y1|NAME] [--source-3mf FILE.3mf] [--visible [OBJ:]SPEC]... [--bridge-max MM] [--printops-api URL] [--offline] [--printops-snapshot FILE] [--accept preset_match|mix_calibration --reason TEXT] [--json] [--secs S] - [--no-bed-move] - Read-only checks before a print. `3d printer start` runs the same checks and refuses on FAIL. + [--no-bed-move] [--fix] + Read-only checks before a print (the one write is opt-in: --fix). `3d printer start` runs the same checks and refuses on FAIL. Every G-code gate runs; one whose input is missing is SKIP and says what to pass. - thumbnails preview images required by the G-code's `; thumbnails =` spec are present + thumbnails preview images required by the G-code's `; thumbnails =` spec (48x48, 96x96 and + 300x300 PNG when it names none) are present; a FAIL names the exact + `3d gcode-thumbnails add FILE` command, `--fix` runs it printer (--printer) the printer is not printing / paused / busy / unreadable bed (--printer with watch.bed.enabled) the camera sees the bed clear and the build plate on: objects or no plate FAIL, cannot tell WARN (`3d printer bed --help`). On @@ -118,6 +133,9 @@ carries --reason; only those two; needs --reason TEXT --reason TEXT why the accepted mismatch is printed --no-bed-move the bed line judges at the current Z: the printer's bed is not moved + --fix add the thumbnails the G-code lacks, in place (header only: the toolpath is + unchanged, mtime kept, a current slice-warnings record follows the new + SHA-256), then run the checks. The only option that writes. --json JSON report on stdout --secs S Creality: seconds to listen for pushed state (default 3) @@ -137,40 +155,57 @@ 3d print-preflight out/plate.gcode --printer k1se --accept preset_match --reason 'stock flow on purpose'""" -def run(argv: list[str]) -> int: - if not argv: - print(USAGE) - return 1 - if argv[0] in ("-h", "--help"): - print(USAGE) - return 0 - import json - from pathlib import Path +_VALUE_OPTIONS = ("--printer", "--source-3mf", "--secs", "--axis-limits", "--visible", "--bridge-max", + "--printops-api", "--printops-snapshot", "--accept", "--reason") +_FLAG_OPTIONS = ("--json", "--offline", "--no-bed-move", "--fix") + + +@dataclass +class _Target: + """What the checks run against: the printer's device/status/limits, or an offline stand-in.""" + + device: Device | None + status: PrinterStatus | None + limits: Limits | None + limits_source: str + trusted: frozenset[str] + + +def _record_flag_or_positional(a: str, pos: list[str], opts: dict[str, str]) -> None: + """One argv token that takes no value: a flag, the G-code path, or an unknown option.""" + if a in _FLAG_OPTIONS: + opts[a] = "1" + elif a.startswith("--"): + raise UsageError(f"unknown option {a}", command="print-preflight", + remediation=["See `3d print-preflight --help`."]) + else: + pos.append(a) + +def _parse_args(argv: list[str]) -> tuple[list[str], dict[str, str], list[str]]: + """argv -> (positionals, options by flag, repeatable --visible values).""" pos: list[str] = [] opts: dict[str, str] = {} visible: list[str] = [] i = 0 while i < len(argv): a = argv[i] - if a in ("--printer", "--source-3mf", "--secs", "--axis-limits", "--visible", "--bridge-max", - "--printops-api", "--printops-snapshot", "--accept", "--reason"): - if i + 1 >= len(argv): - raise UsageError(f"{a} needs a value", command="print-preflight") - if a == "--visible": - visible.append(argv[i + 1]) - else: - opts[a] = argv[i + 1] - i += 2 + if a not in _VALUE_OPTIONS: + _record_flag_or_positional(a, pos, opts) + i += 1 continue - if a in ("--json", "--offline", "--no-bed-move"): - opts[a] = "1" - elif a.startswith("--"): - raise UsageError(f"unknown option {a}", command="print-preflight", - remediation=["See `3d print-preflight --help`."]) + if i + 1 >= len(argv): + raise UsageError(f"{a} needs a value", command="print-preflight") + if a == "--visible": + visible.append(argv[i + 1]) else: - pos.append(a) - i += 1 + opts[a] = argv[i + 1] + i += 2 + return pos, opts, visible + + +def _resolve_inputs(pos: list[str], opts: dict[str, str]) -> tuple[Path, Path | None]: + """The G-code and the optional source 3MF, both required to exist.""" if len(pos) != 1: raise UsageError(f"expected one G-code file, got {len(pos)}", command="print-preflight") gcode = Path(pos[0]) @@ -179,52 +214,108 @@ def run(argv: list[str]) -> int: source = Path(opts["--source-3mf"]) if "--source-3mf" in opts else None if source is not None and not source.is_file(): raise InputNotFound(str(source), command="print-preflight") + return gcode, source - from printer_ops.preflight import FAIL, run_preflight - from printer_ops.printops_checks import context_from_options - from printer_ops import adapter_by_name, bridge_max_for, device_for, limits_from_option, trusted_macros_for +def _apply_fix(gcode: Path) -> None: + """--fix: add the missing thumbnails in place before the checks run.""" + from slicing.thumbnails import fix_in_place + + fixed = fix_in_place(gcode) + if fixed is not None: + print(f"--fix: added {', '.join(map(str, fixed.added))} to {gcode}", file=sys.stderr) + + +def _offline_profile(opts: dict[str, str], printer: str | None, offline: bool) -> Device | None: + """The static profile standing in for --printer when it is offline or --offline is given. + + Such a printer is never contacted: its profile replaces --printer, and an explicit + --axis-limits replaces only its limits. Mutates `opts` accordingly.""" from printer_ops.devices import get_device + profile = get_device(printer, offline_ok=True) if printer is not None else None + if profile is None or not (offline or profile.access == "offline"): + return None + del opts["--printer"] + opts.setdefault("--axis-limits", profile.name) + return profile + + +def _static_device(opts: dict[str, str], profile: Device | None) -> tuple[Device | None, frozenset[str]]: + """Device and trusted macros known without contacting a printer.""" + from printer_ops import device_for, trusted_macros_for + + if profile is not None: + return profile, profile.trusted_macros + if "--printer" in opts: + return None, frozenset() + spec = opts.get("--axis-limits") + return device_for(spec), trusted_macros_for(spec) + + +def _connect(opts: dict[str, str], profile: Device | None) -> _Target: + """Device, live status, axis limits and trusted macros for the run; reads the printer if --printer.""" + from printer_ops import adapter_by_name, limits_from_option + + device, trusted = _static_device(opts, profile) + limits: Limits | None = None + limits_source = "no --printer or --axis-limits given" + if "--axis-limits" in opts: + limits, limits_source = limits_from_option(opts["--axis-limits"], command="print-preflight") + if "--printer" not in opts: + return _Target(device, None, limits, limits_source, trusted) + adapter = adapter_by_name(opts["--printer"], secs=opts.get("--secs"), command="print-preflight") + status = adapter.status() + if "--axis-limits" not in opts: + limits, limits_source = adapter.axis_limits() + # this printer's macros only, whatever --axis-limits names + return _Target(adapter.device, status, limits, limits_source, adapter.trusted_macros) + + +def _setup_printer(opts: dict[str, str]) -> tuple[PrintopsContext, _Target]: + """PrintOps gate context plus the printer target, from --printer / --axis-limits / --offline.""" + from printer_ops.printops_checks import context_from_options + printer = opts.get("--printer") offline = "--offline" in opts - profile = get_device(printer, offline_ok=True) if printer is not None else None - if profile is not None and (offline or profile.access == "offline"): - # an offline fleet printer, or --offline: never contacted, its static profile stands in for - # --printer, an explicit --axis-limits replaces only its limits - del opts["--printer"] - opts.setdefault("--axis-limits", profile.name) - else: - profile = None + profile = _offline_profile(opts, printer, offline) printops = context_from_options(printer, opts.get("--axis-limits"), api=opts.get("--printops-api"), offline=offline, snapshot=opts.get("--printops-snapshot"), command="print-preflight", accept=opts.get("--accept"), reason=opts.get("--reason")) + return printops, _connect(opts, profile) - status = None - device = profile or (None if "--printer" in opts else device_for(opts.get("--axis-limits"))) - limits, limits_source = None, "no --printer or --axis-limits given" - trusted = profile.trusted_macros if profile else \ - frozenset() if "--printer" in opts else trusted_macros_for(opts.get("--axis-limits")) - if "--axis-limits" in opts: - limits, limits_source = limits_from_option(opts["--axis-limits"], command="print-preflight") - if "--printer" in opts: - adapter = adapter_by_name(opts["--printer"], secs=opts.get("--secs"), command="print-preflight") - trusted = adapter.trusted_macros # this printer's macros only, whatever --axis-limits names - device = adapter.device - status = adapter.status() - if "--axis-limits" not in opts: - limits, limits_source = adapter.axis_limits() - - bed = None - if "--printer" in opts and device is not None: - from printer_ops.preflight import check_bed - - bed = check_bed(device, move="--no-bed-move" not in opts) - bridge, bridge_source = bridge_max_for(opts.get("--bridge-max"), device, command="print-preflight") - report = run_preflight(gcode, status=status, source_3mf=source, axis_limits=limits, limits_source=limits_source, - trusted_macros=trusted, visible=visible, bridge_max=bridge, bridge_source=bridge_source, - bed=bed, printops=printops) + +def _bed_line(opts: dict[str, str], device: Device | None) -> Check | None: + """The camera bed line, only for a real --printer whose device is known.""" + if "--printer" not in opts or device is None: + return None + from printer_ops.preflight import check_bed + + return check_bed(device, move="--no-bed-move" not in opts) + + +def run(argv: list[str]) -> int: + if not argv: + print(USAGE) + return 1 + if argv[0] in ("-h", "--help"): + print(USAGE) + return 0 + pos, opts, visible = _parse_args(argv) + gcode, source = _resolve_inputs(pos, opts) + + from printer_ops import bridge_max_for + from printer_ops.preflight import FAIL, run_preflight + + if "--fix" in opts: + _apply_fix(gcode) + printops, target = _setup_printer(opts) + bed = _bed_line(opts, target.device) + bridge, bridge_source = bridge_max_for(opts.get("--bridge-max"), target.device, command="print-preflight") + report = run_preflight(gcode, status=target.status, source_3mf=source, axis_limits=target.limits, + limits_source=target.limits_source, trusted_macros=target.trusted, visible=visible, + bridge_max=bridge, bridge_source=bridge_source, bed=bed, printops=printops) print(json.dumps(report.to_json(), indent=1) if "--json" in opts else report.render()) if report.overall == FAIL: raise GateFailure("preflight FAIL", command="print-preflight", silent=True) diff --git a/lib/commands/printer.py b/lib/commands/printer.py index 55563bd2..570aa93a 100644 --- a/lib/commands/printer.py +++ b/lib/commands/printer.py @@ -15,6 +15,9 @@ re-reads state immediately before the write, and runs `3d print-preflight` plus the camera `bed` check (printer_ops/bed.py; a FAIL blocks; --force needs the owner's Telegram hatch approval, printer_ops/force_hatch.py); + - upload and start first add the thumbnails FILE lacks (48x48/96x96/300x300 PNG per its + `; thumbnails =` spec) in place (lib/slicing/thumbnails.py `fix_in_place`): a G-code + never reaches a printer screen without its preview; - a failed/timed-out start is reported as UNKNOWN and never resent automatically. INVARIANTS: stdlib-only at import time (registry contract); printer_ops is imported lazily. @@ -27,6 +30,7 @@ from errors import InputNotFound, UsageError if TYPE_CHECKING: + from collections.abc import Callable from pathlib import Path from printer_ops.base import Adapter, PrinterStatus @@ -45,10 +49,13 @@ status NAME [--json] [--secs S] state, job, progress, temperatures, loaded heads (everything about one printer — web UIs, queue, job history, settings: `3d printers show NAME`) - upload NAME FILE.gcode [--as REMOTE] store the file on the printer; never starts a print + upload NAME FILE.gcode [--as REMOTE] store the file on the printer; never starts a print. + A FILE without its preview thumbnails gets them first (in + place, sha256 printed: `3d gcode-thumbnails check FILE`) start NAME FILE.gcode --yes [--as REMOTE] [--source-3mf X.3mf] [--bridge-max MM] [--force] [--secs S] [--no-bed-move] - start a file that was uploaded before; runs + start a file that was uploaded before (also adds missing + thumbnails to FILE in place: then upload FILE again); runs `3d print-preflight` first, plus the camera bed check (objects on the bed / plate missing FAIL) when the printer has watch.bed.enabled (an idle printer with @@ -196,32 +203,81 @@ def _adapter(name: str, opts: dict[str, str], *, write: bool = False) -> Adapter return adapter_by_name(name, secs=opts.get("--secs")) -def _list_row(name: str, printer: FleetPrinter | None, device: Device | None) -> dict[str, Any]: - """One `printer list` entry: fleet facts (owner, topology) + the device that would be contacted.""" +def _fleet_facts(printer: FleetPrinter | None) -> dict[str, Any]: + """The fleet-declared columns of a `printer list` row (empty facts when the printer is not in the fleet).""" from dataclasses import asdict + if printer is None: + return {"owner": None, "fleet": None, "heads": [], "ams": None, "enclosure": None, + "chamber_heater": None, "plates": None} + return { + "owner": printer.owner, + "fleet": str(printer.source), + "heads": [asdict(h) for h in printer.heads], + "ams": [asdict(u) for u in printer.ams] if printer.ams is not None else None, + "enclosure": printer.enclosure, + "chamber_heater": printer.chamber_heater, + "plates": list(printer.plates) if printer.plates is not None else None, + } + + +def _device_facts(device: Device | None) -> dict[str, Any]: + """The device-derived columns of a `printer list` row (the device that would be contacted).""" + if device is None: + return {"adapter": None, "address": None, "settings": None} + return {"adapter": device.adapter, "address": f"{device.host}:{device.port}", "settings": str(device.source)} + + +def _list_row(name: str, printer: FleetPrinter | None, device: Device | None) -> dict[str, Any]: + """One `printer list` entry: fleet facts (owner, topology) + the device that would be contacted.""" + fleet = _fleet_facts(printer) + dev = _device_facts(device) return { "name": name, "model": (device.model if device else None) or (printer.model if printer else None), - "owner": printer.owner if printer else None, + "owner": fleet["owner"], "access": device.access if device else printer.access if printer else None, - "adapter": device.adapter if device else None, - "address": f"{device.host}:{device.port}" if device else None, - "settings": str(device.source) if device else None, - "fleet": str(printer.source) if printer else None, - "heads": [asdict(h) for h in printer.heads] if printer else [], - "ams": [asdict(u) for u in printer.ams] if printer and printer.ams is not None else None, - "enclosure": printer.enclosure if printer else None, - "chamber_heater": printer.chamber_heater if printer else None, - "plates": list(printer.plates) if printer and printer.plates is not None else None, + "adapter": dev["adapter"], + "address": dev["address"], + "settings": dev["settings"], + "fleet": fleet["fleet"], + "heads": fleet["heads"], + "ams": fleet["ams"], + "enclosure": fleet["enclosure"], + "chamber_heater": fleet["chamber_heater"], + "plates": fleet["plates"], } +def _print_no_printers() -> None: + """The `printer list` hint when nothing is configured: where we looked and an example.""" + from printer_ops.devices import EXAMPLE, search_paths + from printops.fleet import fleet_path + + print("No printers configured. Looked in:") + for p in [fleet_path(), *search_paths()]: + print(f" {p}") + print("\nCreate one of them, e.g.:\n" + EXAMPLE) + + +def _print_list_row(r: dict[str, Any]) -> None: + """One human-readable `printer list` line plus its unusable-head and settings notes.""" + address = f"{r['adapter']} {r['address']}" if r["adapter"] else "- (never contacted)" + heads = " | ".join(h["label"] + ("" if h["usable"] else " UNUSABLE") for h in r["heads"]) or "-" + print(f" {r['name']:<13} {r['owner'] or '-':<12} {r['access']:<9} {r['model'] or '-':<20} " + f"{address:<31} {heads}") + for h in r["heads"]: + if not h["usable"]: + print(f" {h['label']}: {h['reason']}") + if r["settings"] and r["settings"] != r["fleet"]: + print(f" device settings: {r['settings']}") + + def _cmd_list(argv: list[str]) -> int: import json - from printer_ops.devices import EXAMPLE, load_devices, search_paths - from printops.fleet import fleet_path, load_fleet + from printer_ops.devices import load_devices + from printops.fleet import load_fleet _, opts = _parse(argv, {"--json"}, 0, "list") fleet = load_fleet() @@ -232,22 +288,11 @@ def _cmd_list(argv: list[str]) -> int: print(json.dumps(rows, indent=1, ensure_ascii=False)) return 0 if not rows: - print("No printers configured. Looked in:") - for p in [fleet_path(), *search_paths()]: - print(f" {p}") - print("\nCreate one of them, e.g.:\n" + EXAMPLE) + _print_no_printers() return 0 print(f" {'NAME':<13} {'OWNER':<12} {'ACCESS':<9} {'MODEL':<20} {'ADDRESS':<31} HEADS") for r in rows: - address = f"{r['adapter']} {r['address']}" if r["adapter"] else "- (never contacted)" - heads = " | ".join(h["label"] + ("" if h["usable"] else " UNUSABLE") for h in r["heads"]) or "-" - print(f" {r['name']:<13} {r['owner'] or '-':<12} {r['access']:<9} {r['model'] or '-':<20} " - f"{address:<31} {heads}") - for h in r["heads"]: - if not h["usable"]: - print(f" {h['label']}: {h['reason']}") - if r["settings"] and r["settings"] != r["fleet"]: - print(f" device settings: {r['settings']}") + _print_list_row(r) if fleet is not None: print(f"\nfleet: {fleet.path}") return 0 @@ -277,6 +322,20 @@ def _local(path: str) -> Path: return p +def _ensure_thumbnails(original: Path) -> None: + """A G-code reaches a printer with its preview or not at all: add the missing thumbnails to FILE in place + (atomic, mtime kept, the slice-warnings record follows) BEFORE the snapshot is hashed. The remote name is + content-addressed, so the fixed bytes get a name of their own and `start` of the same FILE refers to them.""" + from printer_ops.base import file_sha256 + from slicing.thumbnails import fix_in_place + + old = file_sha256(original) + res = fix_in_place(original) + if res is not None: + print(f"thumbnails: {original} lacked {', '.join(map(str, res.added))}; added in place " + f"(sha256 {old[:12]}… -> {file_sha256(original)[:12]}…)") + + def _cmd_upload(argv: list[str]) -> int: import shutil import tempfile @@ -284,6 +343,7 @@ def _cmd_upload(argv: list[str]) -> int: (name, file), opts = _parse(argv, {"--as", "--secs"}, 2, "upload") original = _local(file) + _ensure_thumbnails(original) # The bytes named by the hash are the bytes sent: hash and upload one snapshot, so a # slicer rewriting FILE meanwhile cannot store other content under this name. with tempfile.TemporaryDirectory(prefix="3d-upload-") as tmp: @@ -343,6 +403,7 @@ def _cmd_start(argv: list[str]) -> int: source = Path(opts["--source-3mf"]) if "--source-3mf" in opts else None if source is not None and not source.is_file(): raise InputNotFound(str(source), command="printer") + _ensure_thumbnails(original) # One immutable snapshot: the name hash, the read-back comparison and the preflight all see # the same bytes even if a slicer rewrites FILE meanwhile. with tempfile.TemporaryDirectory(prefix="3d-start-") as tmp: @@ -351,20 +412,11 @@ def _cmd_start(argv: list[str]) -> int: return _start_snapshot(name, file, original, local, source, opts) -def _start_snapshot(name: str, file: str, original: Path, local: Path, source: Path | None, - opts: dict[str, str]) -> int: - from printer_ops.base import UNKNOWN, PrinterCommError, PrinterRefused, file_sha256, safe_remote_name - from printer_ops.render import render_status - from printer_ops import bridge_max_for - from printer_ops.preflight import FAIL, run_preflight - from printer_ops.printops_checks import context_from_options - - remote = safe_remote_name(local, opts.get("--as")) # content-addressed: -.gcode - size = local.stat().st_size - adapter = _adapter(name, opts, write=True) +def _require_stored_file(adapter: Adapter, name: str, file: str, remote: str, size: int, + opts: dict[str, str]) -> None: + """Refuse unless the printer holds `remote` with exactly the local size.""" + from printer_ops.base import PrinterRefused - st = adapter.status() - _refuse_if_busy(name, st, "before preflight") stored = adapter.list_files(expect=(remote, size)).get(remote) if stored is None or stored.size != size: got = "absent" if stored is None else f"{stored.size} B" @@ -373,6 +425,12 @@ def _start_snapshot(name: str, file: str, original: Path, local: Path, source: P command="printer", remediation=[f"Upload it first: 3d printer upload {name} {file}" + (f" --as {opts['--as']}" if "--as" in opts else "")], ) + + +def _verify_remote_identity(adapter: Adapter, name: str, file: str, remote: str, local: Path) -> str: + """Check the stored file is the local one; returns the local sha256.""" + from printer_ops.base import PrinterRefused, file_sha256 + # Identity of what will be printed with what is checked below: the read-back hash where # the firmware can return the file, else the hash in the name plus the size. remote_hash = adapter.remote_sha256(remote) @@ -385,11 +443,19 @@ def _start_snapshot(name: str, file: str, original: Path, local: Path, source: P ) identity = "read-back sha256 matches" if remote_hash else "content hash in the file name + size" print(f"remote file {remote}: {identity}") + return local_hash + + +def _preflight_report(name: str, adapter: Adapter, st: PrinterStatus, original: Path, local: Path, + source: Path | None, opts: dict[str, str]) -> Any: + """Run and print the preflight on the snapshot; the report is relabelled with the original path.""" + from printer_ops import bridge_max_for + from printer_ops.preflight import check_bed, run_preflight + from printer_ops.printops_checks import context_from_options limits, limits_source = adapter.axis_limits() bridge, bridge_source = bridge_max_for(opts.get("--bridge-max"), adapter.device, command="printer") # the snapshot lives in a temp dir: NAME.3mf and NAME.slice-warnings.json sit next to the original - from printer_ops.preflight import check_bed # plus the PrintOps material gates of `3d print-preflight --printer NAME` (live 3d-api state # when configured, else fleet facts only) @@ -402,18 +468,34 @@ def _start_snapshot(name: str, file: str, original: Path, local: Path, source: P else None) report.file = f"{original} (snapshot)" print(report.render()) - if report.overall == FAIL: - if "--force" not in opts: - raise PrinterRefused("preflight FAIL; start refused", command="printer", - remediation=["Fix the cause of each FAIL line above (a gate's false positive is a bug: " - "file an issue and fix it).", - "Forcing needs a one-time Telegram approval, not a flag: see --force in " - "`3d printer --help`."]) - from printer_ops.force_hatch import require_force_approval - - forced = ", ".join(c.name for c in report.checks if c.status == FAIL) - approval = require_force_approval(printer=name, file=str(original), sha256=local_hash, checks=report.checks) - print(f"FORCED: starting despite preflight FAIL ({forced}); owner approved on Telegram ({approval})") + return report + + +def _require_preflight_pass(report: Any, name: str, original: Path, local_hash: str, + opts: dict[str, str]) -> None: + """Refuse on a preflight FAIL unless --force is given and the owner approves it.""" + from printer_ops.base import PrinterRefused + from printer_ops.preflight import FAIL + + if report.overall != FAIL: + return + if "--force" not in opts: + raise PrinterRefused("preflight FAIL; start refused", command="printer", + remediation=["Fix the cause of each FAIL line above (a gate's false positive is a bug: " + "file an issue and fix it).", + "Forcing needs a one-time Telegram approval, not a flag: see --force in " + "`3d printer --help`."]) + from printer_ops.force_hatch import require_force_approval + + forced = ", ".join(c.name for c in report.checks if c.status == FAIL) + approval = require_force_approval(printer=name, file=str(original), sha256=local_hash, checks=report.checks) + print(f"FORCED: starting despite preflight FAIL ({forced}); owner approved on Telegram ({approval})") + + +def _start_and_report(adapter: Adapter, name: str, remote: str) -> None: + """Send the start and print the state after it; any unreadable outcome is reported as UNKNOWN.""" + from printer_ops.base import UNKNOWN, PrinterCommError + from printer_ops.render import render_status _refuse_if_busy(name, adapter.status(), "state changed during preflight") dont_resend = [f"Do NOT resend blindly. Check: 3d printer status {name}"] @@ -440,6 +522,23 @@ def _start_snapshot(name: str, file: str, original: Path, local: Path, source: P command="printer", remediation=dont_resend, ) print(render_status(name, adapter, after)) + + +def _start_snapshot(name: str, file: str, original: Path, local: Path, source: Path | None, + opts: dict[str, str]) -> int: + from printer_ops.base import safe_remote_name + + remote = safe_remote_name(local, opts.get("--as")) # content-addressed: -.gcode + size = local.stat().st_size + adapter = _adapter(name, opts, write=True) + + st = adapter.status() + _refuse_if_busy(name, st, "before preflight") + _require_stored_file(adapter, name, file, remote, size, opts) + local_hash = _verify_remote_identity(adapter, name, file, remote, local) + report = _preflight_report(name, adapter, st, original, local, source, opts) + _require_preflight_pass(report, name, original, local_hash, opts) + _start_and_report(adapter, name, remote) return 0 @@ -487,6 +586,80 @@ def _cmd_bed(argv: list[str]) -> int: return exec_tool(BED_DEPS, BED_TOOL, argv) +def _run_evidence(rest: list[str], name: str | None) -> int: + from printer_ops.evidence import run as evidence + + return evidence(rest) + + +def _run_watch(rest: list[str], name: str | None) -> int: + from printer_ops.watch_run import run as watch + + return watch(rest) + + +def _run_supervise(rest: list[str], name: str | None) -> int: + from printer_ops.supervise import run as supervise + + return supervise(rest) + + +def _run_guard(rest: list[str], name: str | None) -> int: + from printer_ops.guard import run as guard + + return guard(rest, vision_deps=VISION_DEPS) + + +def _run_agent(rest: list[str], name: str | None) -> int: + from printer_ops.printer_agent import run as agent + + return agent(rest) + + +def _run_doctor(rest: list[str], name: str | None) -> int: + from printers_cli import dispatch + + return dispatch(["doctor", *rest]) + + +def _run_todo(rest: list[str], name: str | None) -> int: + from printops.todo_cli import run as todo + + return todo(rest, printer=name) + + +def _run_subscriptions(sub: str) -> Callable[[list[str], str | None], int]: + """Handler for subscribe/unsubscribe/subscriptions: the module takes the subcommand word too.""" + def handler(rest: list[str], name: str | None) -> int: + from printer_ops.subscriptions import run as subscriptions + + return subscriptions(sub, rest) + return handler + + +# subcommand -> handler(rest, -n/--name value). Add new subcommands here. +_SUBCOMMANDS: dict[str, Callable[[list[str], str | None], int]] = { + "list": lambda rest, name: _cmd_list(rest), + "status": lambda rest, name: _cmd_status(rest), + "upload": lambda rest, name: _cmd_upload(rest), + "start": lambda rest, name: _cmd_start(rest), + "cancel": lambda rest, name: _cmd_cancel(rest), + "evidence": _run_evidence, + "watch": _run_watch, + "supervise": _run_supervise, + "vision": lambda rest, name: _cmd_vision(rest), + "guard": _run_guard, + "bed": lambda rest, name: _cmd_bed(rest), + "subscribe": _run_subscriptions("subscribe"), + "unsubscribe": _run_subscriptions("unsubscribe"), + "subscriptions": _run_subscriptions("subscriptions"), + "agent": _run_agent, + "doctor": _run_doctor, + "todo": _run_todo, + "issues": _run_todo, +} + + def run(argv: list[str]) -> int: if not argv: print(USAGE) @@ -502,57 +675,12 @@ def run(argv: list[str]) -> int: if name is not None and sub not in ("todo", "issues"): raise UsageError(f"-n/--name selects the printer for `todo`/`issues`; `{sub}` takes the printer as NAME", command="printer", remediation=[f"3d printer {sub} {name} …"]) - if sub == "list": - return _cmd_list(rest) - if sub == "status": - return _cmd_status(rest) - if sub == "upload": - return _cmd_upload(rest) - if sub == "start": - return _cmd_start(rest) - if sub == "cancel": - return _cmd_cancel(rest) - if sub == "evidence": - from printer_ops.evidence import run as evidence - - return evidence(rest) - if sub == "watch": - from printer_ops.watch_run import run as watch - - return watch(rest) - - if sub == "supervise": - from printer_ops.supervise import run as supervise - - return supervise(rest) - - if sub == "vision": - return _cmd_vision(rest) - if sub == "guard": - from printer_ops.guard import run as guard - - return guard(rest, vision_deps=VISION_DEPS) - if sub == "bed": - return _cmd_bed(rest) - if sub in ("subscribe", "unsubscribe", "subscriptions"): - from printer_ops.subscriptions import run as subscriptions - - return subscriptions(sub, rest) - if sub == "agent": - from printer_ops.printer_agent import run as agent - - return agent(rest) - if sub == "doctor": - from printers_cli import dispatch - - return dispatch(["doctor", *rest]) - if sub in ("todo", "issues"): - from printops.todo_cli import run as todo - - return todo(rest, printer=name) - raise UsageError(f"unknown subcommand '{sub}'", command="printer", - remediation=["Use list, status, upload, start, cancel, evidence, watch, supervise, vision, guard, bed, subscribe, unsubscribe, " - "subscriptions, agent, doctor or todo (issues) — see `3d printer --help`."]) + handler = _SUBCOMMANDS.get(sub) + if handler is None: + raise UsageError(f"unknown subcommand '{sub}'", command="printer", + remediation=["Use list, status, upload, start, cancel, evidence, watch, supervise, vision, guard, bed, subscribe, unsubscribe, " + "subscriptions, agent, doctor or todo (issues) — see `3d printer --help`."]) + return handler(rest, name) def _printer_name(argv: list[str]) -> tuple[str | None, list[str]]: diff --git a/lib/printer_ops/preflight.py b/lib/printer_ops/preflight.py index 3e5eef47..94556737 100644 --- a/lib/printer_ops/preflight.py +++ b/lib/printer_ops/preflight.py @@ -57,7 +57,7 @@ from pathlib import Path from typing import TYPE_CHECKING, Any -from printer_ops.base import PrinterStatus +from printer_ops.base import PrinterStatus, Slot from printer_ops.move_bounds import ( BoundsReport, Limits, @@ -151,45 +151,57 @@ def _hex(value: str | None) -> str | None: ) +def _scan_gcode_line(line: str, config: dict[str, str], tool_lines: set[int]) -> None: + """Fold one G-code line into the slicer config comments / the `Tn` tool selections seen.""" + if line.startswith(";"): + m = _CONFIG_LINE.match(line.rstrip("\n")) + if m: + config.setdefault(m.group(1).strip(), m.group(2).strip()) + elif line[:1] == "T": + t = _TOOL_LINE.match(line.strip()) + if t: + tool_lines.add(int(t.group(1))) + + +def _filament_amount(value: str) -> float: + """One `filament used [mm]` entry; an unparsable amount counts as unused, keep reading.""" + try: + return float(value) + except ValueError: + return 0.0 + + +def _used_tools(config: dict[str, str], tool_lines: set[int]) -> list[int]: + """Tools that print: those with filament used, else those selected by `Tn`, else tool 0.""" + used_mm = config.get("filament used [mm]") + if used_mm: + amounts = [_filament_amount(v) for v in used_mm.split(",")] + return [i for i, a in enumerate(amounts) if a > 0] + if tool_lines: + return sorted(tool_lines) + return [0] + + def read_gcode_meta(path: Path) -> GcodeMeta: """Filament colours/types and the tools actually used, from slicer comments + Tn lines.""" config: dict[str, str] = {} tool_lines: set[int] = set() with open(path, encoding="utf-8", errors="replace") as fh: for line in fh: - if line.startswith(";"): - m = _CONFIG_LINE.match(line.rstrip("\n")) - if m: - config.setdefault(m.group(1).strip(), m.group(2).strip()) - elif line[:1] == "T": - t = _TOOL_LINE.match(line.strip()) - if t: - tool_lines.add(int(t.group(1))) - used: list[int] - used_mm = config.get("filament used [mm]") - if used_mm: - amounts = [] - for v in used_mm.split(","): - try: - amounts.append(float(v)) - except ValueError: # noqa: PERF203 — an unparsable amount counts as unused, keep reading - amounts.append(0.0) - used = [i for i, a in enumerate(amounts) if a > 0] - elif tool_lines: - used = sorted(tool_lines) - else: - used = [0] + _scan_gcode_line(line, config, tool_lines) return GcodeMeta( colours=[_hex(c) for c in _split(config.get("filament_colour", ""))], types=[(t or None) for t in _split(config.get("filament_type", ""))], - used_tools=used, + used_tools=_used_tools(config, tool_lines), ) -def check_thumbnails(path: Path) -> Check: +def check_thumbnails(path: Path, shown: Path | None = None) -> Check: + """`shown` is the file the fix command names (the original when `path` is a snapshot copy).""" from gcode_thumbnails import ( check as thumb_check, ) # lazy: keeps `3d help` import-light + from slicing.thumbnails import fix_command r = thumb_check(str(path)) if r.ok: @@ -199,7 +211,8 @@ def check_thumbnails(path: Path) -> Check: reason = r.reason or "missing " + ( ", ".join(str(t) for t in r.missing) or "thumbnails" ) - return Check("thumbnails", FAIL, f"{reason}; fix: 3d gcode-thumbnails add FILE") + fix = fix_command(shown or path) + return Check("thumbnails", FAIL, f"{reason}; fix: {fix} (or print-preflight --fix; upload/start add them themselves)") def check_printer_free(status: PrinterStatus) -> Check: @@ -211,58 +224,55 @@ def check_printer_free(status: PrinterStatus) -> Check: return Check("printer", PASS, f"{status.state} [{status.raw_state}]") +def _slot_problems(slot: Slot, want_c: str | None, want_m: str | None) -> list[str]: + """How the loaded head differs from the colour/material the G-code expects.""" + problems = [] + if want_c and slot.color and want_c != slot.color: + problems.append(f"colour: G-code {want_c}, loaded {slot.color}") + if want_m and slot.material and want_m.upper() != slot.material.upper(): + problems.append(f"material: G-code {want_m}, loaded {slot.material}") + return problems + + +def _check_slot(meta: GcodeMeta, status: PrinterStatus, t: int) -> Check: + """The `slot Tn` line: is the head the G-code uses present and loaded as expected.""" + name = f"slot T{t}" + if t >= len(status.slots): + return Check( + name, + FAIL, + f"G-code uses tool {t} but the printer has {len(status.slots)} heads", + ) + slot = status.slots[t] + if not slot.present: + return Check( + name, + FAIL, + f"head {slot.label} is used by the G-code but has no filament / is disabled", + ) + want_c = meta.colours[t] if t < len(meta.colours) else None + want_m = meta.types[t] if t < len(meta.types) else None + problems = _slot_problems(slot, want_c, want_m) + if problems: + return Check(name, FAIL, f"head {slot.label}: " + "; ".join(problems)) + if not want_c: + return Check( + name, + WARN, + f"head {slot.label}: G-code has no filament_colour for tool {t}; " + f"loaded {slot.color} {slot.material}", + ) + return Check( + name, + PASS, + f"head {slot.label}: {slot.color} {slot.material or ''}".rstrip(), + ) + + def check_slots(meta: GcodeMeta, status: PrinterStatus) -> list[Check]: if not status.slots: return [Check("slots", SKIP, "the printer reports no per-head filament data")] - out = [] - for t in meta.used_tools: - name = f"slot T{t}" - if t >= len(status.slots): - out.append( - Check( - name, - FAIL, - f"G-code uses tool {t} but the printer has {len(status.slots)} heads", - ) - ) - continue - slot = status.slots[t] - if not slot.present: - out.append( - Check( - name, - FAIL, - f"head {slot.label} is used by the G-code but has no filament / is disabled", - ) - ) - continue - want_c = meta.colours[t] if t < len(meta.colours) else None - want_m = meta.types[t] if t < len(meta.types) else None - problems = [] - if want_c and slot.color and want_c != slot.color: - problems.append(f"colour: G-code {want_c}, loaded {slot.color}") - if want_m and slot.material and want_m.upper() != slot.material.upper(): - problems.append(f"material: G-code {want_m}, loaded {slot.material}") - if problems: - out.append(Check(name, FAIL, f"head {slot.label}: " + "; ".join(problems))) - elif not want_c: - out.append( - Check( - name, - WARN, - f"head {slot.label}: G-code has no filament_colour for tool {t}; " - f"loaded {slot.color} {slot.material}", - ) - ) - else: - out.append( - Check( - name, - PASS, - f"head {slot.label}: {slot.color} {slot.material or ''}".rstrip(), - ) - ) - return out + return [_check_slot(meta, status, t) for t in meta.used_tools] def run_halves_check(source: Path) -> tuple[int, str, str]: @@ -361,22 +371,8 @@ def find_project_3mf(gcode: Path, source: Path | None) -> tuple[Path | None, str return None, "" -def check_head_sequence( - gcode: Path, project: Path | None, project_source: str -) -> Check: - """Issue #103: every object prints each layer on the heads its filaments call for.""" - if project is None: - return Check( - "head seq", - SKIP, - "no project 3MF: pass --source-3mf FILE.3mf or keep NAME.3mf next to NAME.gcode", - ) - from slicing.head_sequence import ProjectError, preflight_check - - try: - result = preflight_check(str(gcode), str(project)) - except ProjectError as exc: - return Check("head seq", SKIP, f"{project_source}: {exc}") +def _head_sequence_verdict(result: dict[str, Any], project_source: str) -> Check: + """The `head seq` line for a successful `preflight_check` result.""" objects = result.get("objects") objs = ( [o for o in objects if isinstance(o, dict)] if isinstance(objects, list) else [] @@ -409,6 +405,25 @@ def check_head_sequence( ) +def check_head_sequence( + gcode: Path, project: Path | None, project_source: str +) -> Check: + """Issue #103: every object prints each layer on the heads its filaments call for.""" + if project is None: + return Check( + "head seq", + SKIP, + "no project 3MF: pass --source-3mf FILE.3mf or keep NAME.3mf next to NAME.gcode", + ) + from slicing.head_sequence import ProjectError, preflight_check + + try: + result = preflight_check(str(gcode), str(project)) + except ProjectError as exc: + return Check("head seq", SKIP, f"{project_source}: {exc}") + return _head_sequence_verdict(result, project_source) + + def check_mix_faces(project: Path | None, project_source: str, gcode: Path) -> Check: """Issues #411 / #419: a part in a mixed filament shows its colour on walls, not on a flat top. By the project 3MF when there is one (exact: it knows which parts are mixed, FAIL); else by the @@ -531,101 +546,108 @@ def fmt(names: list[str]) -> str: ] -def check_moves( - rep: BoundsReport, - axis_limits: Limits | None, - limits_source: str, - trusted_macros: frozenset[str] = frozenset(), -) -> list[Check]: - out: list[Check] = [] +def _axis_travel_check( + rep: BoundsReport, axis_limits: Limits | None, limits_source: str +) -> Check: + """The `axis travel` line: every move inside the printer's axis limits.""" if axis_limits is None: why = f"{limits_source}; " if limits_source else "" - out.append( - Check( - "axis travel", - SKIP, - f"printer axis limits unknown ({why}pass --axis-limits " - f"X0:X1,Y0:Y1 or set axis_limits in {PROFILE_FILES})", - ) + return Check( + "axis travel", + SKIP, + f"printer axis limits unknown ({why}pass --axis-limits " + f"X0:X1,Y0:Y1 or set axis_limits in {PROFILE_FILES})", ) - elif rep.axis_out_count: - out.append( - Check( - "axis travel", - FAIL, - f"{fmt_limits(axis_limits)} ({limits_source}): " - + _first(rep.axis_out, rep.axis_out_count), - ) + if rep.axis_out_count: + return Check( + "axis travel", + FAIL, + f"{fmt_limits(axis_limits)} ({limits_source}): " + + _first(rep.axis_out, rep.axis_out_count), ) - else: - out.append( - Check( - "axis travel", - PASS, - f"all moves {fmt_extent(rep.all_extent)} within " - f"{fmt_limits(axis_limits)} ({limits_source})", - ) + return Check( + "axis travel", + PASS, + f"all moves {fmt_extent(rep.all_extent)} within " + f"{fmt_limits(axis_limits)} ({limits_source})", + ) + + +def _print_area_violations(rep: BoundsReport) -> list[str]: + """Plate moves outside printable_area or inside bed_exclude_area, one phrase each. + + bed_exclude_area is evaluated on its own: a missing printable_area must not hide it.""" + parts = [] + if rep.area_out_count and rep.printable_area: + parts.append( + f"printable_area {fmt_area(rep.printable_area)}: " + + _first(rep.area_out, rep.area_out_count) + ) + if rep.excluded_in_count and rep.exclude_area: + parts.append( + f"bed_exclude_area {fmt_area(rep.exclude_area)}: " + + _first(rep.excluded_in, rep.excluded_in_count, "inside") ) + return parts + +def _print_area_check(rep: BoundsReport) -> Check: + """The `print area` line: the plate section stays inside the printable area.""" if not rep.plate_found: - out.append( - Check( - "print area", - WARN, - "UNVERIFIED: no ;LAYER_CHANGE / ; CHANGE_LAYER markers, " - "the plate section cannot be told from start/end G-code", - ) + return Check( + "print area", + WARN, + "UNVERIFIED: no ;LAYER_CHANGE / ; CHANGE_LAYER markers, " + "the plate section cannot be told from start/end G-code", ) - else: - # bed_exclude_area is evaluated on its own: a missing printable_area must not hide it - parts = [] - if rep.area_out_count and rep.printable_area: - parts.append( - f"printable_area {fmt_area(rep.printable_area)}: " - + _first(rep.area_out, rep.area_out_count) - ) - if rep.excluded_in_count and rep.exclude_area: - parts.append( - f"bed_exclude_area {fmt_area(rep.exclude_area)}: " - + _first(rep.excluded_in, rep.excluded_in_count, "inside") - ) - if parts: - out.append( - Check( - "print area", - FAIL, - f"plate moves {fmt_extent(rep.plate_extent)}; " + "; ".join(parts), - ) - ) - elif rep.printable_area is None: - out.append( - Check( - "print area", - WARN, - "UNVERIFIED: the G-code has no `; printable_area`", - ) - ) - else: - out.append( - Check( - "print area", - PASS, - f"plate moves {fmt_extent(rep.plate_extent)} within " - f"{fmt_area(rep.printable_area)}", - ) - ) - - if rep.unverified_count: - more = ", ..." if rep.unverified_count > len(rep.unverified) else "" - out.append( - Check( - "unverified", - WARN, - f"{rep.unverified_count} move(s) only partly checked: " - + ", ".join(rep.unverified) - + more, - ) + parts = _print_area_violations(rep) + if parts: + return Check( + "print area", + FAIL, + f"plate moves {fmt_extent(rep.plate_extent)}; " + "; ".join(parts), + ) + if rep.printable_area is None: + return Check( + "print area", + WARN, + "UNVERIFIED: the G-code has no `; printable_area`", ) + return Check( + "print area", + PASS, + f"plate moves {fmt_extent(rep.plate_extent)} within " + f"{fmt_area(rep.printable_area)}", + ) + + +def _unverified_check(rep: BoundsReport) -> Check | None: + """The `unverified` WARN for moves the bounds check could only partly judge, if any.""" + if not rep.unverified_count: + return None + more = ", ..." if rep.unverified_count > len(rep.unverified) else "" + return Check( + "unverified", + WARN, + f"{rep.unverified_count} move(s) only partly checked: " + + ", ".join(rep.unverified) + + more, + ) + + +def check_moves( + rep: BoundsReport, + axis_limits: Limits | None, + limits_source: str, + trusted_macros: frozenset[str] = frozenset(), +) -> list[Check]: + out = [ + _axis_travel_check(rep, axis_limits, limits_source), + _print_area_check(rep), + ] + unverified = _unverified_check(rep) + if unverified is not None: + out.append(unverified) out.extend(check_macros(rep, trusted_macros)) return out @@ -645,31 +667,26 @@ def bounds_report( ) -def check_bed_verdict(v: dict[str, Any]) -> Check: - """`bed_state.py status --json` (issue #303) -> the `bed` line. A verdict judged after the auto view - moved the bed names the Z it was judged at; a bed left away from where it was found is a WARN.""" - bed, plate = v.get("bed", "unknown"), v.get("plate", "unknown") +def _bed_when(v: dict[str, Any], view: dict[str, Any]) -> str: + """Where the verdict came from, how old it is, and the Z it was judged at if the bed was moved.""" age = v.get("age_s") when = ( f"{v.get('source', '?')}, {age:.0f} s ago" if isinstance(age, (int, float)) else str(v.get("source", "?")) ) - raw_view = v.get("view") - view: dict[str, Any] = raw_view if isinstance(raw_view, dict) else {} if view.get("action") == "moved": when += ( f", judged at Z {view.get('z_used', v.get('z'))} after moving the bed from Z " f"{view.get('moved_from')}" + (" and back" if view.get("restored") else "") ) - frame = [f"frame: {v['frame']}"] if v.get("frame") else [] - why = str(v.get("reason") or "") - if view.get("action") == "moved" and not view.get("restored"): - frame.append( - f"BED NOT PUT BACK to Z {view.get('moved_from')}: {view.get('restore_error')}" - ) - if plate != "missing" and bed != "occupied": - return Check("bed", WARN, f"bed state {bed} ({when}): {why}", frame) + return when + + +def _bed_judgement( + bed: Any, plate: Any, when: str, why: str, frame: list[str] +) -> Check: + """The bed/plate state pair -> the `bed` line.""" if plate == "missing": return Check("bed", FAIL, f"build plate missing ({when}): {why}", frame) if bed == "occupied": @@ -681,6 +698,24 @@ def check_bed_verdict(v: dict[str, Any]) -> Check: return Check("bed", WARN, f"bed state {bed} ({when}): {why}", frame) +def check_bed_verdict(v: dict[str, Any]) -> Check: + """`bed_state.py status --json` (issue #303) -> the `bed` line. A verdict judged after the auto view + moved the bed names the Z it was judged at; a bed left away from where it was found is a WARN.""" + bed, plate = v.get("bed", "unknown"), v.get("plate", "unknown") + raw_view = v.get("view") + view: dict[str, Any] = raw_view if isinstance(raw_view, dict) else {} + when = _bed_when(v, view) + frame = [f"frame: {v['frame']}"] if v.get("frame") else [] + why = str(v.get("reason") or "") + if view.get("action") == "moved" and not view.get("restored"): + frame.append( + f"BED NOT PUT BACK to Z {view.get('moved_from')}: {view.get('restore_error')}" + ) + if plate != "missing" and bed != "occupied": + return Check("bed", WARN, f"bed state {bed} ({when}): {why}", frame) + return _bed_judgement(bed, plate, when, why, frame) + + def check_bed( device: Device, *, @@ -750,7 +785,7 @@ def run_preflight( sibling NAME.3mf and NAME.slice-warnings.json are looked up there. `bed` is the camera bed line (`check_bed` above), computed by the caller that knows the device.""" report = Report(str(gcode)) - report.checks.append(check_thumbnails(gcode)) + report.checks.append(check_thumbnails(gcode, origin)) if status is not None: report.checks.append(check_printer_free(status)) report.checks.extend(check_slots(read_gcode_meta(gcode), status)) diff --git a/tests/e2e/test_printer_workflows.py b/tests/e2e/test_printer_workflows.py index ab5ca326..ec5c3a8b 100644 --- a/tests/e2e/test_printer_workflows.py +++ b/tests/e2e/test_printer_workflows.py @@ -120,7 +120,8 @@ def test_printer_force_approved_request_lists_the_failing_checks(tmp_path: Path) assert isinstance(context, dict) assert request["hook"] == "3d-printer-start-force" and request["critical"] is True assert context["printer"] == "u1" and context["file"].endswith("part.gcode") - assert "- slicer warn:" in context["failing_checks"] and "- thumbnails:" in context["failing_checks"] + # the upload added the thumbnails this G-code lacked: only the slicer-warnings record is left to force + assert "- slicer warn:" in context["failing_checks"] and "- thumbnails:" not in context["failing_checks"] assert request["command"] == f"3d printer start u1 {context['file']} --yes --force" diff --git a/tests/e2e/test_thumbnail_layers_workflows.py b/tests/e2e/test_thumbnail_layers_workflows.py new file mode 100644 index 00000000..b25ffef6 --- /dev/null +++ b/tests/e2e/test_thumbnail_layers_workflows.py @@ -0,0 +1,233 @@ +"""Whatever way a G-code reaches the printer, it carries its thumbnails: slice, preflight, upload, start. + +Real `bin/3d` runs against a fake slicer binary and a fake Moonraker server; no real printer or slicer.""" + +from __future__ import annotations + +import hashlib +import json +import re +import stat +import sys +import zipfile +from pathlib import Path + +from printer_fakes import FakeMoonraker, write_devices +from thumbnail_fixtures import ALL_SIZES, bare, block_sizes, complete, plate_project + +from .workflow_helper import run_cli, run_cli_with_hatch + +FAKE_SLICER = """#!__PYTHON__ +import sys +from pathlib import Path +args = sys.argv[1:] +Path(__file__ + ".argv").write_text(" ".join(args)) +out = Path(args[args.index("--output") + 1]) if "--output" in args else Path(args[args.index("--outputdir") + 1]) / "plate_1.gcode" +out.write_text(__GCODE__) +print("estimated printing time: 1h 02m") +""" + + +def _fake_slicer(tmp_path: Path) -> Path: + exe = tmp_path / "fake-slicer" + exe.write_text( + FAKE_SLICER.replace("__PYTHON__", sys.executable).replace( + "__GCODE__", + repr(bare(tmp_path / "template.gcode").read_text(encoding="ascii")), + ), + encoding="utf-8", + ) + exe.chmod(exe.stat().st_mode | stat.S_IXUSR) + return exe + + +def test_slice_output_has_every_thumbnail_without_a_separate_step( + tmp_path: Path, +) -> None: + """A user slices with the slicer CLI that writes no preview and checks the file afterwards.""" + model = tmp_path / "part.stl" + model.write_text("solid x\nendsolid x\n") + out = tmp_path / "part.gcode" + + sliced = run_cli( + tmp_path, + "slice", + str(model), + "-o", + str(out), + env_extra={"SLICER": str(_fake_slicer(tmp_path))}, + ) + checked = run_cli(tmp_path, "gcode-thumbnails", "check", str(out)) + + assert sliced.returncode == 0, sliced.stdout + sliced.stderr + assert "thumbnails: present" in sliced.stdout + assert ( + "--thumbnails 48x48,96x96,300x300" + in (tmp_path / "fake-slicer.argv").read_text() + ) # layer (a), Prusa form + assert checked.returncode == 0, checked.stdout + assert sorted(block_sizes(out)) == sorted(ALL_SIZES) + + +def test_slice_dry_run_keeps_nothing_and_renders_nothing(tmp_path: Path) -> None: + model = tmp_path / "part.stl" + model.write_text("solid x\nendsolid x\n") + dry = run_cli( + tmp_path, + "slice", + str(model), + "--dry-run", + env_extra={"SLICER": str(_fake_slicer(tmp_path))}, + ) + assert dry.returncode == 0 and "thumbnails:" not in dry.stdout + assert not (tmp_path / "part.gcode").exists() + + +def test_slice_of_a_3mf_project_asks_the_slicer_for_the_sizes_and_leaves_the_input_alone( + tmp_path: Path, +) -> None: + project = plate_project(tmp_path / "p.3mf") + before = project.read_bytes() + slicer = tmp_path / "spy-slicer" + slicer.write_text( + f"#!{sys.executable}\nimport sys, zipfile, json\nfrom pathlib import Path\n" + "a = sys.argv[1:]\nz = zipfile.ZipFile(a[-1])\n" + "Path(a[a.index('--outputdir') + 1], 'seen.json').write_text(z.read('Metadata/project_settings.config').decode())\n" + f"Path(a[a.index('--outputdir') + 1], 'plate_1.gcode').write_text({bare(tmp_path / 't.gcode').read_text()!r})\n" + "if '--output' in a: raise SystemExit(2)\n", + encoding="utf-8", + ) + slicer.chmod(slicer.stat().st_mode | stat.S_IXUSR) + out = tmp_path / "p.gcode" + + sliced = run_cli( + tmp_path, + "slice", + str(project), + "-o", + str(out), + env_extra={"SLICER": str(slicer)}, + ) + + assert sliced.returncode == 0, sliced.stdout + sliced.stderr + assert project.read_bytes() == before # the user's project is never rewritten + assert sorted(block_sizes(out)) == sorted(ALL_SIZES) + + +def test_preflight_names_the_fix_and_fix_applies_it(tmp_path: Path) -> None: + """A user runs the preflight on a bare G-code, reads the command it names, then lets --fix do it.""" + gcode = bare(tmp_path / "plate one.gcode") + gate = run_cli(tmp_path, "print-preflight", str(gcode), "--offline", "--json") + lines = {c["name"]: c for c in json.loads(gate.stdout)["checks"]} + assert lines["thumbnails"]["status"] == "FAIL" + assert f"3d gcode-thumbnails add '{gcode}'" in lines["thumbnails"]["detail"] + assert block_sizes(gcode) == [] # the preflight itself never writes + + fixed = run_cli( + tmp_path, "print-preflight", str(gcode), "--offline", "--json", "--fix" + ) + lines = {c["name"]: c for c in json.loads(fixed.stdout)["checks"]} + assert lines["thumbnails"]["status"] == "PASS" + assert "--fix: added" in fixed.stderr + assert sorted(block_sizes(gcode)) == sorted(ALL_SIZES) + + +def test_upload_adds_the_missing_thumbnails_and_the_printer_receives_them( + tmp_path: Path, +) -> None: + """The G-code of a headless slice is uploaded: the bytes on the (fake) printer carry all three sizes.""" + gcode = bare(tmp_path / "part.gcode") + moon = FakeMoonraker() + with moon.serve(): + write_devices(tmp_path / "xdg-config", moon=moon) + up = run_cli(tmp_path, "printer", "upload", "u1", "part.gcode") + + assert up.returncode == 0, up.stdout + up.stderr + assert ( + "thumbnails:" in up.stdout + and "added in place" in up.stdout + and "sha256" in up.stdout + ) + ((remote, body),) = moon.contents.items() + assert re.fullmatch(r"part-[0-9a-f]{16}\.gcode", remote) + assert ( + body == gcode.read_bytes() + ) # what the printer got is the fixed file, byte for byte + received = tmp_path / "received.gcode" + received.write_bytes(body) + assert sorted(block_sizes(received)) == sorted(ALL_SIZES) + + +def test_upload_of_a_complete_gcode_is_untouched_and_silent(tmp_path: Path) -> None: + gcode = complete(tmp_path / "part.gcode") + before = gcode.read_bytes() + moon = FakeMoonraker() + with moon.serve(): + write_devices(tmp_path / "xdg-config", moon=moon) + up = run_cli(tmp_path, "printer", "upload", "u1", "part.gcode") + assert up.returncode == 0 and "thumbnails:" not in up.stdout + assert gcode.read_bytes() == before + assert list(moon.contents.values()) == [before] + + +def test_start_adds_thumbnails_too_and_asks_for_the_upload_of_the_new_bytes( + tmp_path: Path, +) -> None: + """A file uploaded before this feature has none: `start` fixes FILE, so the stored copy is stale + (content-addressed name) and the user is told to upload again. Then the whole chain passes.""" + gcode = bare(tmp_path / "part.gcode") + old_remote = f"part-{hashlib.sha256(gcode.read_bytes()).hexdigest()[:16]}.gcode" + moon = FakeMoonraker( + files={old_remote: gcode.stat().st_size}, + contents={old_remote: gcode.read_bytes()}, + ) + with moon.serve(): + write_devices(tmp_path / "xdg-config", moon=moon) + first = run_cli(tmp_path, "printer", "start", "u1", "part.gcode", "--yes") + assert not [r for r in moon.writes() if r.path == "/printer/print/start"] + up = run_cli(tmp_path, "printer", "upload", "u1", "part.gcode") + started = run_cli_with_hatch( + tmp_path, "printer", "start", "u1", "part.gcode", "--yes", "--force" + ) + + assert "added in place" in first.stdout and first.returncode != 0 + assert "Upload it first" in first.stderr + assert ( + up.returncode == 0 and "thumbnails:" not in up.stdout + ) # the file already carries them + assert started.returncode == 0, started.stdout + started.stderr + assert re.search(r"PASS\s+thumbnails", started.stdout) + assert sorted(block_sizes(gcode)) == sorted(ALL_SIZES) + + +def test_gcode_thumbnails_add_updates_the_plate_pictures_of_a_project( + tmp_path: Path, +) -> None: + """`add G --3mf P` draws the G-code thumbnails and the project's plate pictures from one render; + `check P` says which pictures a project lacks.""" + gcode = bare(tmp_path / "plate_1.gcode") + project = plate_project(tmp_path / "sliced.3mf") + + before = run_cli(tmp_path, "gcode-thumbnails", "check", str(project)) + added = run_cli( + tmp_path, "gcode-thumbnails", "add", str(gcode), "--3mf", str(project) + ) + after = run_cli(tmp_path, "gcode-thumbnails", "check", str(gcode), str(project)) + + assert before.returncode == 1 and "missing" in before.stdout + assert added.returncode == 0, added.stdout + added.stderr + assert "added Metadata/plate_1.png" in added.stderr + assert after.returncode == 0 and after.stdout.count("PASS") == 2 + with zipfile.ZipFile(project) as z: + assert {"Metadata/plate_1.png", "Metadata/plate_1_small.png"} <= set( + z.namelist() + ) + + +def test_help_documents_the_layers(tmp_path: Path) -> None: + thumbs = run_cli(tmp_path, "gcode-thumbnails", "--help") + flight = run_cli(tmp_path, "print-preflight", "--help") + printer = run_cli(tmp_path, "printer", "--help") + assert "--3mf" in thumbs.stdout and "96x96" in thumbs.stdout + assert "--fix" in flight.stdout and "gcode-thumbnails add" in flight.stdout + assert "thumbnails" in printer.stdout and "in place" in printer.stdout diff --git a/tests/test_printer_ops.py b/tests/test_printer_ops.py index 6b054d4f..b14efbec 100644 --- a/tests/test_printer_ops.py +++ b/tests/test_printer_ops.py @@ -84,8 +84,11 @@ def hatch(monkeypatch: pytest.MonkeyPatch) -> FakeHatch: @pytest.fixture() def gates_ok(monkeypatch: pytest.MonkeyPatch) -> None: """Command tests check printer logic, not the G-code gates with their own tests - (thumbnails, overhangs, slicer warnings): pin those to PASS.""" - monkeypatch.setattr(preflight, "check_thumbnails", lambda p: preflight.Check("thumbnails", "PASS", "stub")) + (thumbnails, overhangs, slicer warnings): pin those to PASS. `upload`/`start` add missing + thumbnails to the G-code in place (tests/test_thumbnail_preflight.py, the e2e thumbnail tests); + here the fixture's bytes must stay what the fake printer holds.""" + monkeypatch.setattr(preflight, "check_thumbnails", lambda p, shown=None: preflight.Check("thumbnails", "PASS", "stub")) + monkeypatch.setattr("commands.printer._ensure_thumbnails", lambda original: None) monkeypatch.setattr(preflight, "check_overhangs", lambda p, *_: preflight.Check("overhangs", "PASS", "stub")) monkeypatch.setattr( preflight, "check_slice_warnings", lambda o, g: preflight.Check("slicer warn", "PASS", "stub") @@ -644,7 +647,7 @@ def test_cmd_upload_refused_while_creality_busy( def test_cmd_upload_refused_over_the_running_file_on_moonraker( - moon: FakeMoonraker, env: Path, tmp_path: Path + moon: FakeMoonraker, env: Path, tmp_path: Path, gates_ok: None ) -> None: g = gcode(tmp_path) moon.status = u1_status("printing", safe_remote_name(g)) @@ -1838,7 +1841,7 @@ def test_ws_connection_reset_counts_as_closed() -> None: def test_cmd_upload_sends_the_bytes_it_named( - moon: FakeMoonraker, env: Path, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + moon: FakeMoonraker, env: Path, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, gates_ok: None ) -> None: """Review #132: a rewrite of FILE between naming and sending must not store other bytes under the first content's hash.""" diff --git a/tests/test_thumbnail_preflight.py b/tests/test_thumbnail_preflight.py new file mode 100644 index 00000000..69ef2d49 --- /dev/null +++ b/tests/test_thumbnail_preflight.py @@ -0,0 +1,35 @@ +"""Layers (c) and (d): what `3d print-preflight` says about a missing thumbnail and where `3d printer` +adds it (the end-to-end runs are in tests/e2e/test_thumbnail_layers_workflows.py).""" +from __future__ import annotations + +from pathlib import Path + +from thumbnail_fixtures import bare + +from printer_ops.preflight import FAIL, PASS, check_thumbnails +from slicing import thumbnails + +LIB = Path(__file__).resolve().parents[1] / "lib" + + +def test_preflight_names_the_exact_fix_command_for_the_original_file( + tmp_path: Path, +) -> None: + original = bare(tmp_path / "my part.gcode") + snapshot = tmp_path / "snapshot.gcode" + snapshot.write_bytes(original.read_bytes()) + check = check_thumbnails(snapshot, original) + assert check.status == FAIL + assert ( + f"3d gcode-thumbnails add '{original}'" in check.detail + and "--fix" in check.detail + ) + assert "48x48/PNG" in check.detail + + thumbnails.finalize_gcode(snapshot) + assert check_thumbnails(snapshot, original).status == PASS + + +def test_printer_upload_and_start_call_the_in_place_fix_before_the_snapshot() -> None: + source = (LIB / "commands" / "printer.py").read_text(encoding="utf-8") + assert source.count("_ensure_thumbnails(original)") == 2 # _cmd_upload and _cmd_start From 9bdeefb6728061022aa22b452bc6f82bb8bf3c99 Mon Sep 17 00:00:00 2001 From: alex-mextner <111590326+alex-mextner@users.noreply.github.com> Date: Thu, 5 Nov 2026 01:12:09 +0100 Subject: [PATCH 3/8] =?UTF-8?q?feat(print-preflight):=20slot=20colour=20by?= =?UTF-8?q?=20CIEDE2000=20=CE=94E,=20--fix=20rewrites=20the=20file=20to=20?= =?UTF-8?q?the=20loaded=20colour=20(Refs=20#728)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The slot Tn check compared colours as strings: the beige PETG sliced as #E8D8B0 and shown by the U1 as #FEE5A5 FAILed and only --force got past it. The colour is now within --colour-delta-e (CIEDE2000, default 10) and the line shows the distance (this pair: 6.4); a material mismatch stays a FAIL. --fix (needs a live --printer) writes the printer's colour into the used tools' filament_colour/extruder_colour in the G-code and its project 3MF, keeps mode and mtime, moves the slice-warnings sidecar to the new hash, updates the manifest and prints old/new sha256. A head further than --fix-max-delta-e is refused (another filament). --- docs/architecture/lib-map.md | 2 + docs/commands/print-preflight.md | 6 +- lib/commands/print_preflight.py | 238 +++++++--- lib/printer_ops/colour_fix.py | 438 ++++++++++++++++++ lib/printer_ops/preflight.py | 363 ++++++++------- tests/colour_fix_fixtures.py | 80 ++++ .../test_print_preflight_colour_workflows.py | 124 +++++ tests/test_colour_fix.py | 323 +++++++++++++ 8 files changed, 1349 insertions(+), 225 deletions(-) create mode 100644 lib/printer_ops/colour_fix.py create mode 100644 tests/colour_fix_fixtures.py create mode 100644 tests/e2e/test_print_preflight_colour_workflows.py create mode 100644 tests/test_colour_fix.py diff --git a/docs/architecture/lib-map.md b/docs/architecture/lib-map.md index 0b29ff75..395bd142 100644 --- a/docs/architecture/lib-map.md +++ b/docs/architecture/lib-map.md @@ -111,6 +111,8 @@ Target package: `lib/geometry/`, with subpackages only when a real boundary appe `dossier.py` / `dossier_render.py` / `webui.py` / `render.py` (`3d printers show NAME`: name resolution, the concurrent read-only live reads, the web UI probe, the text; `render.py` is the status text shared with `3d printer status`), `preflight.py` (pre-print checks), + `colour_fix.py` (the CIEDE2000 slot colour tolerance and the `--fix` / `--fix-colour` rewrite of a + G-code + project 3MF + sidecars to the loaded colour), `move_bounds.py` (G-code moves and arc extremes vs axis travel / printable area), `force_hatch.py` (the owner's Telegram hatch approval that `3d printer start --force` needs), `watch.py` / `watch_run.py` / `watch_sinks.py` (`3d printer watch`: detectors, streams, diff --git a/docs/commands/print-preflight.md b/docs/commands/print-preflight.md index 85ab46d9..01b4bfa2 100644 --- a/docs/commands/print-preflight.md +++ b/docs/commands/print-preflight.md @@ -11,6 +11,7 @@ input is missing is SKIP and its line says what to pass — it never passes sile 3d print-preflight FILE.gcode [--printer NAME] [--axis-limits X0:X1,Y0:Y1|NAME] [--source-3mf FILE.3mf] [--visible [OBJ:]SPEC]... [--bridge-max MM] [--printops-api URL] [--offline] [--printops-snapshot FILE] [--accept preset_match|mix_calibration --reason TEXT] [--json] [--secs S] [--no-bed-move] + [--colour-delta-e N] [--fix [--fix-max-delta-e N]] ``` | Check | When | FAIL means | @@ -18,7 +19,7 @@ input is missing is SKIP and its line says what to pass — it never passes sile | `thumbnails` | always | The G-code lacks preview images its `; thumbnails =` spec asks for (fix: `3d gcode-thumbnails add FILE`). | | `printer` | `--printer` | The printer is printing, paused, busy, or its state cannot be read. | | `bed` | `--printer` with `watch.bed.enabled` (SKIP otherwise) | The camera sees objects on the bed or the build plate missing against the empty-bed reference; WARN when it cannot tell (no reference at this Z, plate out of view, camera down). See [Bed state](printer.md#bed-state). On an idle printer with `watch.bed.auto_view` (the U1: its camera sees 15 % of the plate at the Z 200 rest) the bed is moved in Z only to where the whole plate is in view, judged there and put back, and the line names the Z used; never while printing/paused, never homing; `--no-bed-move` judges at the current Z. | -| `slot Tn` | `--printer` on a printer with per-head data (Snapmaker U1) | Tool *n* is used by the G-code but head T*n+1* is empty, or its colour/material differs from `; filament_colour` / `; filament_type`. | +| `slot Tn` | `--printer` on a printer with per-head data (Snapmaker U1) | Tool *n* is used by the G-code but head T*n+1* is empty, its material differs from `; filament_type`, or its colour is further than `--colour-delta-e` (CIEDE2000, default 10) from `; filament_colour`. The line shows the distance; `--fix` aligns the file with the printer, [`3d printer slot set`](printer.md#slot-colour) the printer with the file. | | `axis travel` | always (SKIP when the limits are unknown) | A move, or the bulge of an arc, leaves the printer's X/Y travel anywhere in the file. See [`3d gcode-bounds`](gcode-bounds.md). | | `print area` | always | A plate move leaves the G-code's `; printable_area`, or an extruding path crosses `; bed_exclude_area` (vendor blocks exempt: `;TYPE:Custom`; on Bambu Studio G-code everything outside its object blocks, wipe tower and features — see [`3d gcode-bounds`](gcode-bounds.md)). | | `macros` | when the G-code calls macros | A macro (PAUSE, a vendor toolchange, …) is not in the printer's `trusted_macros`: its moves cannot be checked. | @@ -133,6 +134,9 @@ Options: - `--bridge-max MM` — longest span the overhangs gate counts as a bridge; overrides the printer's `bridge_max_mm`: `3d print-preflight out/porch.gcode --axis-limits u1 --bridge-max 25` - `--json` — JSON report (`checks[]` with `name`, `status`, `detail`, `lines`): `3d print-preflight out/part.gcode --printer u1 --json | jq '.checks[] | select(.status=="FAIL")'` - `--no-bed-move` — the `bed` line judges at the current Z: the printer's bed is not moved to the best inspection Z (`watch.bed.auto_view`): `3d print-preflight out/part.gcode --printer u1 --no-bed-move` +- `--colour-delta-e N` — the `slot Tn` colour tolerance, CIEDE2000 (default 10, the `colour.warn_delta_e` of `fleet/policy.yaml`). The slot line shows the distance either way. The beige PETG of 2026-11-04 (sliced `#E8D8B0`, shown by the U1 as `#FEE5A5`) is ΔE 6.4 and passes; white against that beige is 20.0 and fails; red against blue is 52.9. A **material** mismatch is always a FAIL, whatever the colour: `3d print-preflight out/part.gcode --printer u1 --colour-delta-e 5` +- `--fix` — needs a live `--printer`. Before the checks, rewrite `; filament_colour` / `; extruder_colour` for every tool the G-code uses to the colour the printer reports, in the G-code **and** in its project 3MF (`--source-3mf`, else `NAME.3mf` next to the G-code; `Metadata/project_settings.config`, `Metadata/plate_N.json`). The toolpath bytes, mode and mtime stay; `NAME.slice-warnings.json` follows the new G-code SHA-256 and `NAME.manifest.json` gets the new slot colour; the old and new SHA-256 of every rewritten file go to stderr (the file's hash changed: upload it again). A head further than `--fix-max-delta-e` away is refused with nothing written (another filament, not the same one reported differently). It is the one option that writes: `3d print-preflight out/plate.gcode --printer u1 --fix` +- `--fix-max-delta-e N` — the largest ΔE `--fix` rewrites across (default 30): `3d print-preflight out/plate.gcode --printer u1 --fix --fix-max-delta-e 40` - `--secs S` — Creality: seconds to listen for pushed state: `3d print-preflight out/roof.gcode --printer k1se --secs 5` - `--printops-api URL` — 3d-api for the PrintOps gates (default `$PRINTOPS_API`, else `printops.yaml`): `3d print-preflight out/part.gcode --printer u1 --printops-api http://192.168.0.39:8765` - `--offline` — no network: no printer contact, PrintOps state from the snapshot (the slicer hook mode); together with `--printops-api` it is a usage error: `3d print-preflight out/part.gcode --offline` diff --git a/lib/commands/print_preflight.py b/lib/commands/print_preflight.py index c96d6c50..c7e0ae6b 100644 --- a/lib/commands/print_preflight.py +++ b/lib/commands/print_preflight.py @@ -19,18 +19,28 @@ https://github.com/alex-mextner/3d-cli/issues/96); running it alone answers "is this file ready to go on that printer?" without touching anything. It only READS from the printer. -INVARIANTS: stdlib-only at import time; printer_ops imported lazily inside run(). +INVARIANTS: stdlib-only at import time; printer_ops imported lazily inside the helpers and run(). """ from __future__ import annotations +from dataclasses import dataclass +from pathlib import Path +from typing import TYPE_CHECKING + from cli.registry import Command from errors import GateFailure, InputNotFound, UsageError +if TYPE_CHECKING: # typing-only: printer_ops stays a lazy import inside the helpers + from printer_ops.base import PrinterStatus + from printer_ops.devices import Device + from printer_ops.move_bounds import Limits + from printer_ops.preflight import Check + USAGE = """3d print-preflight FILE.gcode [--printer NAME] [--axis-limits X0:X1,Y0:Y1|NAME] [--source-3mf FILE.3mf] [--visible [OBJ:]SPEC]... [--bridge-max MM] [--printops-api URL] [--offline] [--printops-snapshot FILE] [--accept preset_match|mix_calibration --reason TEXT] [--json] [--secs S] - [--no-bed-move] - Read-only checks before a print. `3d printer start` runs the same checks and refuses on FAIL. + [--no-bed-move] [--colour-delta-e N] [--fix [--fix-max-delta-e N]] + Read-only checks before a print (the one write is opt-in: --fix). `3d printer start` runs the same checks and refuses on FAIL. Every G-code gate runs; one whose input is missing is SKIP and says what to pass. thumbnails preview images required by the G-code's `; thumbnails =` spec are present @@ -42,8 +52,10 @@ is in view, judged there and put back; the line names the Z used. Never while printing/paused, never homing; --no-bed-move judges at the current Z slot Tn (--printer, per-head printers such as the Snapmaker U1) each head the G-code - uses is loaded, and its colour and material equal `; filament_colour` / - `; filament_type` for that tool + uses is loaded, its material equals `; filament_type` for that tool (a + mismatch is always a FAIL) and its colour is within --colour-delta-e of + `; filament_colour` (CIEDE2000, default 10; the line shows the distance: the + beige #E8D8B0 sliced for a head reporting #FEE5A5 is ΔE 6.4 and passes) axis travel every G0-G3 move (arcs by their real extremes, start/end G-code included) stays inside the printer's X/Y limits; SKIP when the limits are unknown print area plate moves stay inside the G-code's `; printable_area`; no extruding path @@ -118,6 +130,16 @@ carries --reason; only those two; needs --reason TEXT --reason TEXT why the accepted mismatch is printed --no-bed-move the bed line judges at the current Z: the printer's bed is not moved + --colour-delta-e N slot Tn colour tolerance, CIEDE2000 (default 10) + --fix (needs --printer) first rewrite `; filament_colour` / `; extruder_colour` of + every tool the G-code uses to the colour the printer reports, in the G-code and + its project 3MF (--source-3mf, else NAME.3mf beside it; Metadata/ + project_settings.config), keeping mode/mtime; NAME.slice-warnings.json and + NAME.manifest.json follow; old and new sha256 of each file are printed to + stderr. A head whose colour is further than --fix-max-delta-e away is refused + (another filament, nothing written). Then the checks run. The only option + that writes (re-upload the file after it: its hash changed) + --fix-max-delta-e N the largest ΔE --fix rewrites across (default 30) --json JSON report on stdout --secs S Creality: seconds to listen for pushed state (default 3) @@ -131,46 +153,65 @@ 3d print-preflight out/roof.gcode --axis-limits -5:229,-0.5:227 3d print-preflight print/k1se/k1_base_4_v3.gcode --axis-limits k1se 3d print-preflight out/walls.gcode --visible 'WALL*:y<104.8' + 3d print-preflight out/plate.gcode --printer u1 --fix # same beige PETG, other hex: rewrite + sha256s 3d print-preflight out/part.gcode --printer u1 --json | jq '.checks[] | select(.status=="FAIL")' 3d print-preflight out/part.gcode --printer u1 --printops-api http://192.168.0.39:8765 3d print-preflight out/part.gcode --offline # slicer hook: PrintOps snapshot, no network 3d print-preflight out/plate.gcode --printer k1se --accept preset_match --reason 'stock flow on purpose'""" -def run(argv: list[str]) -> int: - if not argv: - print(USAGE) - return 1 - if argv[0] in ("-h", "--help"): - print(USAGE) - return 0 - import json - from pathlib import Path +_VALUE_OPTIONS = ("--printer", "--source-3mf", "--secs", "--axis-limits", "--visible", "--bridge-max", + "--printops-api", "--printops-snapshot", "--accept", "--reason", "--colour-delta-e", + "--fix-max-delta-e") +_FLAG_OPTIONS = ("--json", "--offline", "--no-bed-move", "--fix") + + +@dataclass +class _Target: + """What the checks run against: the printer's device/status/limits, or an offline stand-in.""" + + device: Device | None + status: PrinterStatus | None + limits: Limits | None + limits_source: str + trusted: frozenset[str] + + +def _record_flag_or_positional(a: str, pos: list[str], opts: dict[str, str]) -> None: + """One argv token that takes no value: a flag, the G-code path, or an unknown option.""" + if a in _FLAG_OPTIONS: + opts[a] = "1" + elif a.startswith("--"): + raise UsageError(f"unknown option {a}", command="print-preflight", + remediation=["See `3d print-preflight --help`."]) + else: + pos.append(a) + +def _parse_args(argv: list[str]) -> tuple[list[str], dict[str, str], list[str]]: + """argv -> (positionals, options by flag, repeatable --visible values).""" pos: list[str] = [] opts: dict[str, str] = {} visible: list[str] = [] i = 0 while i < len(argv): a = argv[i] - if a in ("--printer", "--source-3mf", "--secs", "--axis-limits", "--visible", "--bridge-max", - "--printops-api", "--printops-snapshot", "--accept", "--reason"): - if i + 1 >= len(argv): - raise UsageError(f"{a} needs a value", command="print-preflight") - if a == "--visible": - visible.append(argv[i + 1]) - else: - opts[a] = argv[i + 1] - i += 2 + if a not in _VALUE_OPTIONS: + _record_flag_or_positional(a, pos, opts) + i += 1 continue - if a in ("--json", "--offline", "--no-bed-move"): - opts[a] = "1" - elif a.startswith("--"): - raise UsageError(f"unknown option {a}", command="print-preflight", - remediation=["See `3d print-preflight --help`."]) + if i + 1 >= len(argv): + raise UsageError(f"{a} needs a value", command="print-preflight") + if a == "--visible": + visible.append(argv[i + 1]) else: - pos.append(a) - i += 1 + opts[a] = argv[i + 1] + i += 2 + return pos, opts, visible + + +def _resolve_inputs(pos: list[str], opts: dict[str, str]) -> tuple[Path, Path | None]: + """The G-code and the optional source 3MF, both required to exist.""" if len(pos) != 1: raise UsageError(f"expected one G-code file, got {len(pos)}", command="print-preflight") gcode = Path(pos[0]) @@ -179,52 +220,117 @@ def run(argv: list[str]) -> int: source = Path(opts["--source-3mf"]) if "--source-3mf" in opts else None if source is not None and not source.is_file(): raise InputNotFound(str(source), command="print-preflight") + return gcode, source - from printer_ops.preflight import FAIL, run_preflight - from printer_ops.printops_checks import context_from_options - from printer_ops import adapter_by_name, bridge_max_for, device_for, limits_from_option, trusted_macros_for +def _offline_profile(opts: dict[str, str], printer: str | None, offline: bool) -> Device | None: + """The static profile standing in for --printer when it is offline or --offline is given. + + Such a printer is never contacted: its profile replaces --printer, and an explicit --axis-limits + replaces only its limits. Mutates `opts` accordingly.""" from printer_ops.devices import get_device + profile = get_device(printer, offline_ok=True) if printer is not None else None + if profile is None or not (offline or profile.access == "offline"): + return None + del opts["--printer"] + opts.setdefault("--axis-limits", profile.name) + return profile + + +def _static_device(opts: dict[str, str], profile: Device | None) -> tuple[Device | None, frozenset[str]]: + """Device and trusted macros known without contacting a printer.""" + from printer_ops import device_for, trusted_macros_for + + if profile is not None: + return profile, profile.trusted_macros + if "--printer" in opts: + return None, frozenset() + spec = opts.get("--axis-limits") + return device_for(spec), trusted_macros_for(spec) + + +def _fix_colours(gcode: Path, source: Path | None, status: PrinterStatus, opts: dict[str, str]) -> None: + """--fix: rewrite the file to the loaded colours before the checks run (report on stderr).""" + import sys + + from printer_ops.colour_fix import DEFAULT_FIX_MAX_DELTA_E, fix_to_loaded, parse_delta_e + + limit = parse_delta_e(opts.get("--fix-max-delta-e"), "--fix-max-delta-e", DEFAULT_FIX_MAX_DELTA_E, + command="print-preflight") + fixed = fix_to_loaded(gcode, status, source, limit, command="print-preflight") + print("\n".join(fixed.lines()) if fixed else "--fix: the loaded colours already equal the G-code's", + file=sys.stderr) + + +def _connect(opts: dict[str, str], profile: Device | None, gcode: Path, source: Path | None) -> _Target: + """Device, live status, axis limits and trusted macros for the run; reads the printer if --printer + (and, with --fix, rewrites the file to the loaded colours).""" + from printer_ops import adapter_by_name, limits_from_option + + device, trusted = _static_device(opts, profile) + limits: Limits | None = None + limits_source = "no --printer or --axis-limits given" + if "--axis-limits" in opts: + limits, limits_source = limits_from_option(opts["--axis-limits"], command="print-preflight") + if "--printer" not in opts: + return _Target(device, None, limits, limits_source, trusted) + adapter = adapter_by_name(opts["--printer"], secs=opts.get("--secs"), command="print-preflight") + status = adapter.status() + if "--fix" in opts: + _fix_colours(gcode, source, status, opts) + if "--axis-limits" not in opts: + limits, limits_source = adapter.axis_limits() + # this printer's macros only, whatever --axis-limits names + return _Target(adapter.device, status, limits, limits_source, adapter.trusted_macros) + + +def _bed_line(opts: dict[str, str], device: Device | None) -> Check | None: + """The camera bed line, only for a real --printer whose device is known.""" + if "--printer" not in opts or device is None: + return None + from printer_ops.preflight import check_bed + + return check_bed(device, move="--no-bed-move" not in opts) + + +def run(argv: list[str]) -> int: + if not argv: + print(USAGE) + return 1 + if argv[0] in ("-h", "--help"): + print(USAGE) + return 0 + import json + + pos, opts, visible = _parse_args(argv) + gcode, source = _resolve_inputs(pos, opts) + + from printer_ops import bridge_max_for + from printer_ops.colour_fix import DEFAULT_SLOT_DELTA_E, parse_delta_e + from printer_ops.preflight import FAIL, run_preflight + from printer_ops.printops_checks import context_from_options + + colour_de = parse_delta_e(opts.get("--colour-delta-e"), "--colour-delta-e", DEFAULT_SLOT_DELTA_E, + command="print-preflight") printer = opts.get("--printer") offline = "--offline" in opts - profile = get_device(printer, offline_ok=True) if printer is not None else None - if profile is not None and (offline or profile.access == "offline"): - # an offline fleet printer, or --offline: never contacted, its static profile stands in for - # --printer, an explicit --axis-limits replaces only its limits - del opts["--printer"] - opts.setdefault("--axis-limits", profile.name) - else: - profile = None + profile = _offline_profile(opts, printer, offline) + if "--fix" in opts and "--printer" not in opts: + raise UsageError("--fix needs the loaded colours of a live --printer (not --offline, not an offline printer)", + command="print-preflight", + remediation=["3d print-preflight FILE --printer NAME --fix"]) printops = context_from_options(printer, opts.get("--axis-limits"), api=opts.get("--printops-api"), offline=offline, snapshot=opts.get("--printops-snapshot"), command="print-preflight", accept=opts.get("--accept"), reason=opts.get("--reason")) - - status = None - device = profile or (None if "--printer" in opts else device_for(opts.get("--axis-limits"))) - limits, limits_source = None, "no --printer or --axis-limits given" - trusted = profile.trusted_macros if profile else \ - frozenset() if "--printer" in opts else trusted_macros_for(opts.get("--axis-limits")) - if "--axis-limits" in opts: - limits, limits_source = limits_from_option(opts["--axis-limits"], command="print-preflight") - if "--printer" in opts: - adapter = adapter_by_name(opts["--printer"], secs=opts.get("--secs"), command="print-preflight") - trusted = adapter.trusted_macros # this printer's macros only, whatever --axis-limits names - device = adapter.device - status = adapter.status() - if "--axis-limits" not in opts: - limits, limits_source = adapter.axis_limits() - - bed = None - if "--printer" in opts and device is not None: - from printer_ops.preflight import check_bed - - bed = check_bed(device, move="--no-bed-move" not in opts) - bridge, bridge_source = bridge_max_for(opts.get("--bridge-max"), device, command="print-preflight") - report = run_preflight(gcode, status=status, source_3mf=source, axis_limits=limits, limits_source=limits_source, - trusted_macros=trusted, visible=visible, bridge_max=bridge, bridge_source=bridge_source, - bed=bed, printops=printops) + target = _connect(opts, profile, gcode, source) + bed = _bed_line(opts, target.device) + bridge, bridge_source = bridge_max_for(opts.get("--bridge-max"), target.device, command="print-preflight") + report = run_preflight(gcode, status=target.status, source_3mf=source, axis_limits=target.limits, + limits_source=target.limits_source, trusted_macros=target.trusted, visible=visible, + bridge_max=bridge, bridge_source=bridge_source, bed=bed, printops=printops, + colour_delta_e=colour_de) print(json.dumps(report.to_json(), indent=1) if "--json" in opts else report.render()) if report.overall == FAIL: raise GateFailure("preflight FAIL", command="print-preflight", silent=True) diff --git a/lib/printer_ops/colour_fix.py b/lib/printer_ops/colour_fix.py new file mode 100644 index 00000000..7bd15a42 --- /dev/null +++ b/lib/printer_ops/colour_fix.py @@ -0,0 +1,438 @@ +"""colour_fix.py — slot colour tolerance and the rewrite that aligns a G-code with the loaded filament. + +WHAT: two things behind the preflight `slot Tn` line. + - `colour_delta(a, b)`: CIEDE2000 between two `#RRGGBB` colours (CIELAB, D65; printops/color.py). + `DEFAULT_SLOT_DELTA_E` (10) is the tolerance of the slot check: two beiges of the same PETG + (#E8D8B0 sliced, #FEE5A5 loaded: ΔE 6.4) are the same filament; white vs beige (16.7) or red vs + blue (52.9) are not. A material mismatch is never tolerated (preflight.py). + - `plan_colour_fix` + `apply_colour_fix` (`3d print-preflight --fix`, `3d printer start + --fix-colour`): for every tool the G-code uses, write the colour the PRINTER reports into the + `; filament_colour` / `; extruder_colour` entries of the G-code and into the 3MF project it was + sliced from (`Metadata/project_settings.config`, `Metadata/plate_N.json filament_colors`), so the + files record what is really loaded. Sidecars follow: `NAME.slice-warnings.json` gets the new + G-code SHA-256 (`slice_warnings.follow_edit`), `NAME.manifest.json` the new slot colour. Old and + new SHA-256 of every rewritten file are returned for the operator to print. + +WHY: on 2026-11-04 a SIMBOL plate sliced with #E8D8B0 met a printer that showed the same beige PETG + as #FEE5A5: the preflight FAILed on string inequality and the only way out was `--force`, whose + Telegram approval then failed. The cause is metadata, and the right fix is at the source, in + seconds, with the old and new hashes on record. + +INVARIANTS: stdlib only (print-preflight imports it in-process). The toolpath bytes are never + touched: only the colour entries of `; filament_colour =` / `; extruder_colour =` lines change; + mode and mtime are kept (a staleness check G-code vs project must not be fooled), and both files + are staged to temp files first, so a 3MF that cannot be rewritten leaves the G-code untouched. A + tool whose loaded colour is further than `max_delta_e` from the sliced one is REFUSED, not + rewritten: that is a different filament, and relabelling it would hide a wrong-colour print. +""" + +from __future__ import annotations + +import hashlib +import json +import os +import re +import tempfile +import zipfile +from dataclasses import dataclass, field +from pathlib import Path +from typing import TYPE_CHECKING + +from printops.color import hex_delta_e, parse_hex + +from printer_ops.base import PrinterRefused + +if TYPE_CHECKING: + from printer_ops.base import PrinterStatus + from printer_ops.preflight import GcodeMeta + +DEFAULT_SLOT_DELTA_E = 10.0 # CIEDE2000; the `colour.warn_delta_e` of fleet/policy.yaml +DEFAULT_FIX_MAX_DELTA_E = ( + 30.0 # above this the loaded filament is another colour: no rewrite +) + +_COLOUR_LINE = re.compile( + rb"^(;[ \t]*(?:filament_colour|extruder_colour)[ \t]*=[ \t]*)(.*?)(\r?\n?)$" +) +_COLOUR_ENTRY = re.compile(r"#?[0-9A-Fa-f]{6}(?:[0-9A-Fa-f]{2})?") +_PROJECT_SETTINGS = "Metadata/project_settings.config" +_PLATE_JSON = re.compile(r"Metadata/plate_\d+\.json") +_CHUNK = 1 << 22 + + +def colour_delta(a: str | None, b: str | None) -> float | None: + """CIEDE2000 between two colours (`#RRGGBB`, RGBA and bare forms accepted); None when either is not one.""" + pa, pb = parse_hex(a), parse_hex(b) + if pa is None or pb is None: + return None + return hex_delta_e(pa, pb) + + +def parse_delta_e( + value: str | None, option: str, default: float, *, command: str +) -> float: + """`--colour-delta-e` / `--fix-max-delta-e` value: a positive number (CIEDE2000), else `default`.""" + from errors import InvalidArgument + + if value is None: + return default + try: + number = float(value) + except ValueError: + number = -1.0 + if not number > 0: + raise InvalidArgument( + option, value, ["a positive number (CIEDE2000), e.g. 10"], command=command + ) + return number + + +@dataclass(frozen=True) +class ColourEdit: + """One tool's sliced colour replaced by the loaded one.""" + + tool: int # G-code tool number (0-based) + label: str # the printer's name of that head (U1: T1..T4) + old: str + new: str + delta_e: float + + +@dataclass(frozen=True) +class FileChange: + path: Path + old_sha256: str + new_sha256: str + + +@dataclass +class FixResult: + edits: list[ColourEdit] + gcode: FileChange + project: FileChange | None = None + manifest: Path | None = None # NAME.manifest.json whose slot colours were updated + sidecar: Path | None = ( + None # NAME.slice-warnings.json re-bound to the new G-code hash + ) + notes: list[str] = field(default_factory=list) + + def lines(self) -> list[str]: + out = [ + f"colour fix: tool T{e.tool} ({e.label}) {e.old} -> {e.new} (ΔE {e.delta_e:.1f})" + for e in self.edits + ] + out.extend( + f" {c.path}\n sha256 old {c.old_sha256}\n sha256 new {c.new_sha256}" + for c in (self.gcode, self.project) + if c is not None + ) + if self.manifest is not None: + out.append(f" {self.manifest}: slot colours updated") + if self.sidecar is not None: + out.append(f" {self.sidecar}: re-bound to the new G-code sha256") + out.extend(f" note: {n}" for n in self.notes) + return out + + +def plan_colour_fix( + meta: GcodeMeta, status: PrinterStatus, max_delta_e: float = DEFAULT_FIX_MAX_DELTA_E +) -> tuple[list[ColourEdit], list[str]]: + """(edits, refusals): the used tools whose sliced colour differs from the loaded one, and the + reasons a tool is not rewritten (empty head, a different colour beyond `max_delta_e`).""" + edits: list[ColourEdit] = [] + refusals: list[str] = [] + for tool in meta.used_tools: + slot = status.slots[tool] if tool < len(status.slots) else None + old = meta.colours[tool] if tool < len(meta.colours) else None + if ( + slot is None + or not slot.present + or not slot.color + or not old + or old == slot.color + ): + continue + de = colour_delta(old, slot.color) + if de is None: + continue + if de > max_delta_e: + refusals.append( + f"tool T{tool} ({slot.label}): G-code {old}, loaded {slot.color}: ΔE {de:.1f} > {max_delta_e:g}, " + "another colour, not the same filament reported differently" + ) + else: + edits.append(ColourEdit(tool, slot.label, old, slot.color, de)) + return edits, refusals + + +def _ident(st: os.stat_result) -> tuple[int, int, int]: + return st.st_ino, st.st_size, st.st_mtime_ns + + +def _entries(value: str, wanted: dict[int, str]) -> str: + """`a;b;c` with entry `t` of `wanted` replaced by its colour (quotes and spacing kept).""" + parts = value.split(";") + for tool, new in wanted.items(): + if tool < len(parts) and _COLOUR_ENTRY.search(parts[tool]): + parts[tool] = _COLOUR_ENTRY.sub(new, parts[tool], count=1) + return ";".join(parts) + + +def _patch_line(line: bytes, wanted: dict[int, str]) -> bytes: + m = _COLOUR_LINE.match(line) + if m is None: + return line + value = m.group(2).decode("utf-8", errors="surrogateescape") + return ( + m.group(1) + + _entries(value, wanted).encode("utf-8", errors="surrogateescape") + + m.group(3) + ) + + +def _stage_gcode( + path: Path, wanted: dict[int, str] +) -> tuple[Path, FileChange, os.stat_result]: + """Copy `path` with the colour lines patched to a temp file beside it; (temp, hashes, source stat).""" + old, new = hashlib.sha256(), hashlib.sha256() + fd, tmp = tempfile.mkstemp(prefix=".colour-fix-", dir=path.resolve().parent) + changed = 0 + try: + with open(path, "rb") as src, os.fdopen(fd, "wb") as dst: + st = os.fstat(src.fileno()) + for line in src: + old.update(line) + out = ( + _patch_line(line, wanted) + if line[:1] == b";" and b"colour" in line + else line + ) + changed += out != line + new.update(out) + dst.write(out) + if not changed: + raise PrinterRefused( + f"{path} has no `; filament_colour` / `; extruder_colour` entry to rewrite for tools " + f"{', '.join(f'T{t}' for t in sorted(wanted))}", + command="print-preflight", + remediation=[ + "The G-code carries no slicer config block: re-slice it, or load the colour that was sliced." + ], + ) + except BaseException: + Path(tmp).unlink(missing_ok=True) + raise + return Path(tmp), FileChange(path, old.hexdigest(), new.hexdigest()), st + + +def _indent_of(text: str) -> int: + for line in text.splitlines()[1:3]: + stripped = line.lstrip(" ") + if stripped and stripped != line: + return len(line) - len(stripped) + return 4 + + +def _patch_json( + raw: bytes, keys: tuple[str, ...], wanted: dict[int, str] +) -> bytes | None: + """JSON with entry `t` of each list in `keys` set to its colour; None when nothing changes.""" + text = raw.decode("utf-8") + data = json.loads(text) + if not isinstance(data, dict): + return None + changed = False + for key in keys: + arr = data.get(key) + if not isinstance(arr, list): + continue + for tool, new in wanted.items(): + if ( + tool < len(arr) + and isinstance(arr[tool], str) + and arr[tool].upper() != new.upper() + ): + arr[tool] = new + changed = True + if not changed: + return None + out = json.dumps(data, indent=_indent_of(text), ensure_ascii=False) + return (out + "\n" if text.endswith("\n") else out).encode("utf-8") + + +class _NotAProject(ValueError): + """The 3MF is not a slicer project: there is no colour list in it to rewrite.""" + + +def _sha256_and_stat(path: Path) -> tuple[str, os.stat_result]: + digest = hashlib.sha256() + with open(path, "rb") as fh: + st = os.fstat(fh.fileno()) + while chunk := fh.read(_CHUNK): + digest.update(chunk) + return digest.hexdigest(), st + + +def _patched_members(zin: zipfile.ZipFile, wanted: dict[int, str]) -> dict[str, bytes]: + """The members of the project whose colour lists change, as their new bytes.""" + patched: dict[str, bytes] = {} + for info in zin.infolist(): + if info.filename == _PROJECT_SETTINGS: + keys: tuple[str, ...] = ("filament_colour", "extruder_colour") + elif _PLATE_JSON.fullmatch(info.filename): + keys = ("filament_colors",) + else: + continue + if (new := _patch_json(zin.read(info), keys, wanted)) is not None: + patched[info.filename] = new + return patched + + +def _write_patched(zin: zipfile.ZipFile, patched: dict[str, bytes], tmp: Path) -> None: + """Copy every member of `zin` to the zip `tmp`, the `patched` ones with their new bytes.""" + with tmp.open("wb") as raw, zipfile.ZipFile(raw, "w") as zout: + for info in zin.infolist(): + zout.writestr( + info, + patched.get(info.filename) or zin.read(info), + compress_type=info.compress_type, + ) + + +def _stage_project( + path: Path, wanted: dict[int, str] +) -> tuple[Path, FileChange, os.stat_result] | None: + """The 3MF with its colour lists patched, as a temp file beside it; None when it already agrees.""" + old_sha, st = _sha256_and_stat(path) + try: + zin = zipfile.ZipFile(path) + except zipfile.BadZipFile as exc: + raise _NotAProject(f"{path} is not a 3MF (zip): {exc}") from exc + with zin: + if _PROJECT_SETTINGS not in zin.namelist(): + raise _NotAProject( + f"{path} has no {_PROJECT_SETTINGS}: not a slicer project 3MF" + ) + patched = _patched_members(zin, wanted) + if _PROJECT_SETTINGS not in patched: + return None + fd, name = tempfile.mkstemp(prefix=".colour-fix-", dir=path.resolve().parent) + os.close(fd) + tmp = Path(name) + try: + _write_patched(zin, patched, tmp) + except BaseException: + tmp.unlink(missing_ok=True) + raise + new_sha = hashlib.sha256(tmp.read_bytes()).hexdigest() + return tmp, FileChange(path, old_sha, new_sha), st + + +def _install(tmp: Path, dst: Path, st: os.stat_result) -> None: + """Replace `dst` by the staged `tmp`, refusing when `dst` changed meanwhile; mode and mtime kept.""" + os.chmod(tmp, st.st_mode & 0o7777) + os.utime(tmp, ns=(st.st_atime_ns, st.st_mtime_ns)) + if _ident(os.stat(dst)) != _ident(st): + raise PrinterRefused( + f"{dst} changed while its colours were being rewritten; run again", + command="print-preflight", + ) + os.replace(tmp, dst) + + +def _update_manifest(project: Path, wanted: dict[int, str]) -> Path | None: + """NAME.manifest.json beside NAME.3mf: the slot colours (`filaments[].hex`, tool = slot - 1).""" + path = project.with_name(project.stem + ".manifest.json") + if not path.is_file(): + return None + text = path.read_text(encoding="utf-8") + data = json.loads(text) + changed = False + for fil in data.get("filaments", []) if isinstance(data, dict) else []: + tool = fil.get("tool") if isinstance(fil, dict) else None + new = wanted.get(tool) if isinstance(tool, int) else None + if new is not None and str(fil.get("hex", "")).upper() != new.upper(): + fil["hex"] = new + changed = True + if not changed: + return None + path.write_text( + json.dumps(data, ensure_ascii=False, indent=_indent_of(text)), encoding="utf-8" + ) + return path + + +def apply_colour_fix( + gcode: Path, project: Path | None, edits: list[ColourEdit] +) -> FixResult: + """Rewrite the colours of `edits` in `gcode` (and `project`, a 3MF) in place; see the module doc.""" + wanted = {e.tool: e.new for e in edits} + staged_g, change_g, st_g = _stage_gcode(gcode, wanted) + staged_p = None + result = FixResult(edits, change_g) + try: + staged_p = _stage_project(project, wanted) if project is not None else None + except _NotAProject as exc: + project = None + result.notes.append(f"{exc}; left untouched") + except BaseException: + staged_g.unlink(missing_ok=True) + raise + try: + _install(staged_g, gcode, st_g) + if staged_p is not None and project is not None: + _install(staged_p[0], project, staged_p[2]) + result.project = staged_p[1] + finally: + staged_g.unlink(missing_ok=True) + if staged_p is not None: + staged_p[0].unlink(missing_ok=True) + from slicing.slice_warnings import follow_edit + + result.sidecar = follow_edit( + gcode, gcode, change_g.old_sha256, change_g.new_sha256, "colour fix" + ) + if project is not None: + result.manifest = _update_manifest(project, wanted) + if staged_p is None: + result.notes.append(f"{project} already carries the loaded colours") + elif not result.notes: + result.notes.append( + "no project 3MF found (--source-3mf / NAME.3mf): only the G-code was rewritten" + ) + return result + + +def fix_to_loaded( + gcode: Path, + status: PrinterStatus, + source_3mf: Path | None, + max_delta_e: float = DEFAULT_FIX_MAX_DELTA_E, + *, + command: str, +) -> FixResult | None: + """Rewrite `gcode` (+ its project 3MF: `source_3mf`, else NAME.3mf beside it) to the colours the + printer reports for the tools it uses. None when nothing differs; PrinterRefused (nothing written) + when the printer reports no per-head data or a tool's loaded colour is another colour.""" + from printer_ops.preflight import find_project_3mf, read_gcode_meta + + if not status.slots: + raise PrinterRefused( + "the printer reports no per-head filament data: there is no loaded colour to rewrite the file to", + command=command, + ) + edits, refusals = plan_colour_fix(read_gcode_meta(gcode), status, max_delta_e) + if refusals: + raise PrinterRefused( + "colour fix refused, nothing written: " + refusals[0], + command=command, + remediation=[ + *refusals[1:], + "Load the filament that was sliced (`3d printer slot set` fixes the printer's record), or, if " + "this really is the same filament, raise the limit: --fix-max-delta-e N.", + ], + ) + if not edits: + return None + project, _ = find_project_3mf(gcode, source_3mf) + return apply_colour_fix(gcode, project, edits) diff --git a/lib/printer_ops/preflight.py b/lib/printer_ops/preflight.py index 3e5eef47..be4d1618 100644 --- a/lib/printer_ops/preflight.py +++ b/lib/printer_ops/preflight.py @@ -6,8 +6,11 @@ - printer the target printer is free (not printing/paused/busy/unknown); - bed (printers with `watch.bed.enabled`) the camera saw the bed clear with the plate on (printer_ops/bed.py, issue #303): objects or no plate FAIL, unknown WARN; - - slot Tn for every tool the G-code uses: the printer's head is loaded, and its colour - and material equal the G-code's `; filament_colour` / `; filament_type` + - slot Tn for every tool the G-code uses: the printer's head is loaded, its material + equals the G-code's `; filament_type` and its colour is within a CIEDE2000 + tolerance of `; filament_colour` (default 10, the distance is shown; a beige + reported as #FEE5A5 for a #E8D8B0 slice passes) — printer_ops/colour_fix.py, + which `print-preflight --fix` uses to rewrite the file to the loaded colour (needs per-head data: Moonraker printers exposing print_task_config, i.e. U1); - halves `3d halves-check <3mf> --json` when the source 3MF is given; - axis travel every move (arcs by their real extremes) inside the printer's X/Y limits, @@ -57,7 +60,8 @@ from pathlib import Path from typing import TYPE_CHECKING, Any -from printer_ops.base import PrinterStatus +from printer_ops.base import PrinterStatus, Slot +from printer_ops.colour_fix import DEFAULT_SLOT_DELTA_E, colour_delta from printer_ops.move_bounds import ( BoundsReport, Limits, @@ -151,8 +155,8 @@ def _hex(value: str | None) -> str | None: ) -def read_gcode_meta(path: Path) -> GcodeMeta: - """Filament colours/types and the tools actually used, from slicer comments + Tn lines.""" +def _scan_gcode_config(path: Path) -> tuple[dict[str, str], set[int]]: + """(first value of every `; key = value` slicer comment, tool numbers of the `Tn` lines).""" config: dict[str, str] = {} tool_lines: set[int] = set() with open(path, encoding="utf-8", errors="replace") as fh: @@ -165,24 +169,34 @@ def read_gcode_meta(path: Path) -> GcodeMeta: t = _TOOL_LINE.match(line.strip()) if t: tool_lines.add(int(t.group(1))) - used: list[int] + return config, tool_lines + + +def _amount(value: str) -> float: + """One entry of `; filament used [mm]`; an unparsable amount counts as unused.""" + try: + return float(value) + except ValueError: + return 0.0 + + +def _used_tools(config: dict[str, str], tool_lines: set[int]) -> list[int]: + """Non-zero entries of `; filament used [mm]`, else the `Tn` lines, else tool 0.""" used_mm = config.get("filament used [mm]") if used_mm: - amounts = [] - for v in used_mm.split(","): - try: - amounts.append(float(v)) - except ValueError: # noqa: PERF203 — an unparsable amount counts as unused, keep reading - amounts.append(0.0) - used = [i for i, a in enumerate(amounts) if a > 0] - elif tool_lines: - used = sorted(tool_lines) - else: - used = [0] + return [ + i for i, a in enumerate(_amount(v) for v in used_mm.split(",")) if a > 0 + ] + return sorted(tool_lines) or [0] + + +def read_gcode_meta(path: Path) -> GcodeMeta: + """Filament colours/types and the tools actually used, from slicer comments + Tn lines.""" + config, tool_lines = _scan_gcode_config(path) return GcodeMeta( colours=[_hex(c) for c in _split(config.get("filament_colour", ""))], types=[(t or None) for t in _split(config.get("filament_type", ""))], - used_tools=used, + used_tools=_used_tools(config, tool_lines), ) @@ -211,58 +225,74 @@ def check_printer_free(status: PrinterStatus) -> Check: return Check("printer", PASS, f"{status.state} [{status.raw_state}]") -def check_slots(meta: GcodeMeta, status: PrinterStatus) -> list[Check]: - if not status.slots: - return [Check("slots", SKIP, "the printer reports no per-head filament data")] - out = [] - for t in meta.used_tools: - name = f"slot T{t}" - if t >= len(status.slots): - out.append( - Check( - name, - FAIL, - f"G-code uses tool {t} but the printer has {len(status.slots)} heads", - ) - ) - continue - slot = status.slots[t] - if not slot.present: - out.append( - Check( - name, - FAIL, - f"head {slot.label} is used by the G-code but has no filament / is disabled", - ) - ) - continue - want_c = meta.colours[t] if t < len(meta.colours) else None - want_m = meta.types[t] if t < len(meta.types) else None - problems = [] - if want_c and slot.color and want_c != slot.color: - problems.append(f"colour: G-code {want_c}, loaded {slot.color}") - if want_m and slot.material and want_m.upper() != slot.material.upper(): - problems.append(f"material: G-code {want_m}, loaded {slot.material}") - if problems: - out.append(Check(name, FAIL, f"head {slot.label}: " + "; ".join(problems))) - elif not want_c: - out.append( - Check( - name, - WARN, - f"head {slot.label}: G-code has no filament_colour for tool {t}; " - f"loaded {slot.color} {slot.material}", - ) +def _slot_problems( + want_c: str | None, want_m: str | None, slot: Slot, colour_delta_e: float +) -> tuple[list[str], str]: + """(problems, colour note): the mismatches of one loaded slot against the G-code's colour and + material, and the ΔE note of a colour that differs but is within tolerance.""" + problems: list[str] = [] + note = "" + if want_c and slot.color and want_c != slot.color: + de = colour_delta(want_c, slot.color) + if de is None or de > colour_delta_e: + shown = "" if de is None else f" (ΔE {de:.1f} > {colour_delta_e:g})" + problems.append( + f"colour: G-code {want_c}, loaded {slot.color}{shown}; " + "fix: print-preflight --fix / printer start --fix-colour" ) else: - out.append( - Check( - name, - PASS, - f"head {slot.label}: {slot.color} {slot.material or ''}".rstrip(), - ) + note = ( + f" (colour ΔE {de:.1f} from G-code {want_c}, within {colour_delta_e:g})" ) - return out + if want_m and slot.material and want_m.upper() != slot.material.upper(): + problems.append(f"material: G-code {want_m}, loaded {slot.material}") + return problems, note + + +def _slot_check( + tool: int, meta: GcodeMeta, status: PrinterStatus, colour_delta_e: float +) -> Check: + name = f"slot T{tool}" + if tool >= len(status.slots): + return Check( + name, + FAIL, + f"G-code uses tool {tool} but the printer has {len(status.slots)} heads", + ) + slot = status.slots[tool] + if not slot.present: + return Check( + name, + FAIL, + f"head {slot.label} is used by the G-code but has no filament / is disabled", + ) + want_c = meta.colours[tool] if tool < len(meta.colours) else None + want_m = meta.types[tool] if tool < len(meta.types) else None + problems, note = _slot_problems(want_c, want_m, slot, colour_delta_e) + if problems: + return Check(name, FAIL, f"head {slot.label}: " + "; ".join(problems)) + if not want_c: + return Check( + name, + WARN, + f"head {slot.label}: G-code has no filament_colour for tool {tool}; " + f"loaded {slot.color} {slot.material}", + ) + return Check( + name, + PASS, + f"head {slot.label}: {slot.color} {slot.material or ''}".rstrip() + note, + ) + + +def check_slots( + meta: GcodeMeta, status: PrinterStatus, colour_delta_e: float = DEFAULT_SLOT_DELTA_E +) -> list[Check]: + """One line per used tool. The colour matches within `colour_delta_e` (CIEDE2000, the distance is + in the line); a material mismatch is always a FAIL.""" + if not status.slots: + return [Check("slots", SKIP, "the printer reports no per-head filament data")] + return [_slot_check(t, meta, status, colour_delta_e) for t in meta.used_tools] def run_halves_check(source: Path) -> tuple[int, str, str]: @@ -377,6 +407,11 @@ def check_head_sequence( result = preflight_check(str(gcode), str(project)) except ProjectError as exc: return Check("head seq", SKIP, f"{project_source}: {exc}") + return _head_sequence_verdict(result, project_source) + + +def _head_sequence_verdict(result: dict[str, Any], project_source: str) -> Check: + """The `head seq` line of one `slicing.head_sequence.preflight_check` result.""" objects = result.get("objects") objs = ( [o for o in objects if isinstance(o, dict)] if isinstance(objects, list) else [] @@ -531,90 +566,86 @@ def fmt(names: list[str]) -> str: ] -def check_moves( - rep: BoundsReport, - axis_limits: Limits | None, - limits_source: str, - trusted_macros: frozenset[str] = frozenset(), -) -> list[Check]: - out: list[Check] = [] +def _axis_travel_check( + rep: BoundsReport, axis_limits: Limits | None, limits_source: str +) -> Check: if axis_limits is None: why = f"{limits_source}; " if limits_source else "" - out.append( - Check( - "axis travel", - SKIP, - f"printer axis limits unknown ({why}pass --axis-limits " - f"X0:X1,Y0:Y1 or set axis_limits in {PROFILE_FILES})", - ) + return Check( + "axis travel", + SKIP, + f"printer axis limits unknown ({why}pass --axis-limits " + f"X0:X1,Y0:Y1 or set axis_limits in {PROFILE_FILES})", ) - elif rep.axis_out_count: - out.append( - Check( - "axis travel", - FAIL, - f"{fmt_limits(axis_limits)} ({limits_source}): " - + _first(rep.axis_out, rep.axis_out_count), - ) + if rep.axis_out_count: + return Check( + "axis travel", + FAIL, + f"{fmt_limits(axis_limits)} ({limits_source}): " + + _first(rep.axis_out, rep.axis_out_count), ) - else: - out.append( - Check( - "axis travel", - PASS, - f"all moves {fmt_extent(rep.all_extent)} within " - f"{fmt_limits(axis_limits)} ({limits_source})", - ) + return Check( + "axis travel", + PASS, + f"all moves {fmt_extent(rep.all_extent)} within " + f"{fmt_limits(axis_limits)} ({limits_source})", + ) + + +def _print_area_violations(rep: BoundsReport) -> list[str]: + """printable_area / bed_exclude_area breaches; each is evaluated on its own, so a missing + printable_area must not hide an exclusion.""" + parts = [] + if rep.area_out_count and rep.printable_area: + parts.append( + f"printable_area {fmt_area(rep.printable_area)}: " + + _first(rep.area_out, rep.area_out_count) + ) + if rep.excluded_in_count and rep.exclude_area: + parts.append( + f"bed_exclude_area {fmt_area(rep.exclude_area)}: " + + _first(rep.excluded_in, rep.excluded_in_count, "inside") ) + return parts + +def _print_area_check(rep: BoundsReport) -> Check: if not rep.plate_found: - out.append( - Check( - "print area", - WARN, - "UNVERIFIED: no ;LAYER_CHANGE / ; CHANGE_LAYER markers, " - "the plate section cannot be told from start/end G-code", - ) + return Check( + "print area", + WARN, + "UNVERIFIED: no ;LAYER_CHANGE / ; CHANGE_LAYER markers, " + "the plate section cannot be told from start/end G-code", ) - else: - # bed_exclude_area is evaluated on its own: a missing printable_area must not hide it - parts = [] - if rep.area_out_count and rep.printable_area: - parts.append( - f"printable_area {fmt_area(rep.printable_area)}: " - + _first(rep.area_out, rep.area_out_count) - ) - if rep.excluded_in_count and rep.exclude_area: - parts.append( - f"bed_exclude_area {fmt_area(rep.exclude_area)}: " - + _first(rep.excluded_in, rep.excluded_in_count, "inside") - ) - if parts: - out.append( - Check( - "print area", - FAIL, - f"plate moves {fmt_extent(rep.plate_extent)}; " + "; ".join(parts), - ) - ) - elif rep.printable_area is None: - out.append( - Check( - "print area", - WARN, - "UNVERIFIED: the G-code has no `; printable_area`", - ) - ) - else: - out.append( - Check( - "print area", - PASS, - f"plate moves {fmt_extent(rep.plate_extent)} within " - f"{fmt_area(rep.printable_area)}", - ) - ) + parts = _print_area_violations(rep) + if parts: + return Check( + "print area", + FAIL, + f"plate moves {fmt_extent(rep.plate_extent)}; " + "; ".join(parts), + ) + if rep.printable_area is None: + return Check( + "print area", WARN, "UNVERIFIED: the G-code has no `; printable_area`" + ) + return Check( + "print area", + PASS, + f"plate moves {fmt_extent(rep.plate_extent)} within " + f"{fmt_area(rep.printable_area)}", + ) + +def check_moves( + rep: BoundsReport, + axis_limits: Limits | None, + limits_source: str, + trusted_macros: frozenset[str] = frozenset(), +) -> list[Check]: + out = [ + _axis_travel_check(rep, axis_limits, limits_source), + _print_area_check(rep), + ] if rep.unverified_count: more = ", ..." if rep.unverified_count > len(rep.unverified) else "" out.append( @@ -645,31 +676,25 @@ def bounds_report( ) -def check_bed_verdict(v: dict[str, Any]) -> Check: - """`bed_state.py status --json` (issue #303) -> the `bed` line. A verdict judged after the auto view - moved the bed names the Z it was judged at; a bed left away from where it was found is a WARN.""" - bed, plate = v.get("bed", "unknown"), v.get("plate", "unknown") +def _bed_when(v: dict[str, Any], view: dict[str, Any]) -> str: + """Where the verdict came from, and how the bed got to the Z it was judged at.""" age = v.get("age_s") when = ( f"{v.get('source', '?')}, {age:.0f} s ago" if isinstance(age, (int, float)) else str(v.get("source", "?")) ) - raw_view = v.get("view") - view: dict[str, Any] = raw_view if isinstance(raw_view, dict) else {} if view.get("action") == "moved": when += ( f", judged at Z {view.get('z_used', v.get('z'))} after moving the bed from Z " f"{view.get('moved_from')}" + (" and back" if view.get("restored") else "") ) - frame = [f"frame: {v['frame']}"] if v.get("frame") else [] - why = str(v.get("reason") or "") - if view.get("action") == "moved" and not view.get("restored"): - frame.append( - f"BED NOT PUT BACK to Z {view.get('moved_from')}: {view.get('restore_error')}" - ) - if plate != "missing" and bed != "occupied": - return Check("bed", WARN, f"bed state {bed} ({when}): {why}", frame) + return when + + +def _bed_verdict_line( + bed: object, plate: object, when: str, why: str, frame: list[str] +) -> Check: if plate == "missing": return Check("bed", FAIL, f"build plate missing ({when}): {why}", frame) if bed == "occupied": @@ -681,6 +706,24 @@ def check_bed_verdict(v: dict[str, Any]) -> Check: return Check("bed", WARN, f"bed state {bed} ({when}): {why}", frame) +def check_bed_verdict(v: dict[str, Any]) -> Check: + """`bed_state.py status --json` (issue #303) -> the `bed` line. A verdict judged after the auto view + moved the bed names the Z it was judged at; a bed left away from where it was found is a WARN.""" + bed, plate = v.get("bed", "unknown"), v.get("plate", "unknown") + raw_view = v.get("view") + view: dict[str, Any] = raw_view if isinstance(raw_view, dict) else {} + when = _bed_when(v, view) + frame = [f"frame: {v['frame']}"] if v.get("frame") else [] + why = str(v.get("reason") or "") + if view.get("action") == "moved" and not view.get("restored"): + frame.append( + f"BED NOT PUT BACK to Z {view.get('moved_from')}: {view.get('restore_error')}" + ) + if plate != "missing" and bed != "occupied": + return Check("bed", WARN, f"bed state {bed} ({when}): {why}", frame) + return _bed_verdict_line(bed, plate, when, why, frame) + + def check_bed( device: Device, *, @@ -744,8 +787,10 @@ def run_preflight( bridge_source: str = "default", bed: Check | None = None, printops: PrintopsContext | None = None, + colour_delta_e: float = DEFAULT_SLOT_DELTA_E, ) -> Report: - """`limits_source` names where `axis_limits` came from, or why they are unknown. + """`colour_delta_e` is the CIEDE2000 tolerance of the `slot Tn` colour (default 10; the line + shows the distance). `limits_source` names where `axis_limits` came from, or why they are unknown. `origin` is where the G-code lives when `gcode` is a snapshot copy (`printer start`): the sibling NAME.3mf and NAME.slice-warnings.json are looked up there. `bed` is the camera bed line (`check_bed` above), computed by the caller that knows the device.""" @@ -753,7 +798,9 @@ def run_preflight( report.checks.append(check_thumbnails(gcode)) if status is not None: report.checks.append(check_printer_free(status)) - report.checks.extend(check_slots(read_gcode_meta(gcode), status)) + report.checks.extend( + check_slots(read_gcode_meta(gcode), status, colour_delta_e) + ) if bed is not None: report.checks.append(bed) report.checks.extend( diff --git a/tests/colour_fix_fixtures.py b/tests/colour_fix_fixtures.py new file mode 100644 index 00000000..4b9c4d53 --- /dev/null +++ b/tests/colour_fix_fixtures.py @@ -0,0 +1,80 @@ +"""Fixtures for the slot colour tolerance and `--fix` tests: a G-code, its project 3MF, manifest and +slice-warnings sidecar for the SIMBOL case (beige PETG sliced as #E8D8B0, the U1 reporting #FEE5A5), +and a fake U1 that holds that PETG in head T4 (G-code tool 3). Never a real printer.""" + +from __future__ import annotations + +import json +import zipfile +from pathlib import Path + +from printer_fakes import FakeMoonraker + +SLICED = "#E8D8B0" # what the slicer wrote +LOADED = "#FEE5A5" # what the U1 screen shows for the same beige PETG +COLOURS = ["#000000", "#808080", "#1B5E20", SLICED] +TOOLPATH = "G28\nT3\nG1 X10 Y10 E1\nG1 X20 Y20 E1\n" + + +def gcode_text(colour: str = SLICED, material: str = "PETG") -> str: + colours = ";".join([*COLOURS[:3], colour]) + return ( + "; HEADER_BLOCK_START\n; HEADER_BLOCK_END\n" + + TOOLPATH + + "; filament used [mm] = 0.00, 0.00, 0.00, 30.50\n" + + f"; extruder_colour = {colours}\n" + + f"; filament_colour = {colours}\n" + + f"; filament_type = PLA;PLA;PLA;{material}\n" + ) + + +def write_project(path: Path, colour: str = SLICED) -> None: + """A slicer project 3MF: project_settings.config (4-space JSON), plate json, one model part.""" + colours = [*COLOURS[:3], colour] + settings = { + "filament_colour": colours, + "extruder_colour": list(colours), + "filament_type": ["PLA", "PLA", "PLA", "PETG"], + "layer_height": "0.2", + } + plate = {"filament_colors": list(colours), "filament_ids": [0, 1, 2, 3]} + with zipfile.ZipFile(path, "w", zipfile.ZIP_DEFLATED) as z: + z.writestr("[Content_Types].xml", "") + z.writestr("3D/3dmodel.model", "kept byte for byte") + z.writestr( + "Metadata/project_settings.config", json.dumps(settings, indent=4) + "\n" + ) + z.writestr("Metadata/plate_1.json", json.dumps(plate, indent=4)) + + +def write_manifest(path: Path, colour: str = SLICED) -> None: + filaments = [ + {"slot": i + 1, "key": f"f{i}", "name": f"F{i}", "hex": c, "tool": i} + for i, c in enumerate([*COLOURS[:3], colour]) + ] + path.write_text( + json.dumps({"file": "plate.3mf", "filaments": filaments}, indent=1), + encoding="utf-8", + ) + + +def write_plate(directory: Path, name: str = "plate") -> Path: + """NAME.gcode + NAME.3mf + NAME.manifest.json + NAME.slice-warnings.json; the G-code's path.""" + from slicing import slice_warnings as sw + + gcode = directory / f"{name}.gcode" + gcode.write_text(gcode_text(), encoding="utf-8") + write_project(directory / f"{name}.3mf") + write_manifest(directory / f"{name}.manifest.json") + sw.record(gcode, logs=[("slice.log", "")]) + return gcode + + +def load_beige_petg( + moon: FakeMoonraker, head: int = 3, colour: str = LOADED, material: str = "PETG" +) -> None: + """The fake U1 holds `material` of `colour` in head index `head` (label T).""" + cfg = moon.status["print_task_config"] + cfg["filament_exist"][head] = True + cfg["filament_color_rgba"][head] = colour.lstrip("#") + "FF" + cfg["filament_type"][head] = material diff --git a/tests/e2e/test_print_preflight_colour_workflows.py b/tests/e2e/test_print_preflight_colour_workflows.py new file mode 100644 index 00000000..82811073 --- /dev/null +++ b/tests/e2e/test_print_preflight_colour_workflows.py @@ -0,0 +1,124 @@ +"""E2E stories through `bin/3d print-preflight` for the 2026-11-04 SIMBOL print: beige PETG sliced as +#E8D8B0 on a U1 that shows it as #FEE5A5. The slot colour is judged by CIEDE2000 distance (no `--force` +for a same-family beige), and `--fix` rewrites the files to the printer's colour. Loopback fakes only.""" + +from __future__ import annotations + +import json +import zipfile +from pathlib import Path + +from colour_fix_fixtures import LOADED, SLICED, gcode_text, load_beige_petg, write_plate +from printer_fakes import FakeMoonraker, write_devices +from slicing import slice_warnings as sw + +from .workflow_helper import run_cli + + +def slot_line(stdout: str) -> str: + return next(line for line in stdout.splitlines() if "slot T3" in line) + + +def test_preflight_accepts_the_same_beige_and_prints_the_distance( + tmp_path: Path, +) -> None: + """The user preflights the SIMBOL plate: the slot line passes and says how far the colours are.""" + moon = FakeMoonraker() + load_beige_petg(moon) + g = write_plate(tmp_path) + with moon.serve(): + write_devices(tmp_path / "xdg-config", moon=moon) + default = run_cli(tmp_path, "print-preflight", str(g), "--printer", "u1") + strict = run_cli( + tmp_path, + "print-preflight", + str(g), + "--printer", + "u1", + "--colour-delta-e", + "5", + ) + + assert slot_line(default.stdout).split()[0] == "PASS" and "ΔE 6.4" in slot_line( + default.stdout + ) + assert slot_line(strict.stdout).split()[ + 0 + ] == "FAIL" and "(ΔE 6.4 > 5)" in slot_line(strict.stdout) + assert moon.writes() == [] + + +def test_preflight_still_fails_a_wrong_material_and_a_different_colour( + tmp_path: Path, +) -> None: + moon = FakeMoonraker() + load_beige_petg(moon) + pla = tmp_path / "pla.gcode" + pla.write_text(gcode_text(SLICED, "PLA"), encoding="utf-8") + blue = tmp_path / "blue.gcode" + blue.write_text(gcode_text("#0000FF"), encoding="utf-8") + with moon.serve(): + write_devices(tmp_path / "xdg-config", moon=moon) + wrong_material = run_cli( + tmp_path, "print-preflight", str(pla), "--printer", "u1" + ) + wrong_colour = run_cli( + tmp_path, "print-preflight", str(blue), "--printer", "u1" + ) + refused_fix = run_cli( + tmp_path, "print-preflight", str(blue), "--printer", "u1", "--fix" + ) + + assert ( + slot_line(wrong_material.stdout).split()[0] == "FAIL" + and "material: G-code PLA, loaded PETG" in wrong_material.stdout + ) + assert slot_line(wrong_colour.stdout).split()[0] == "FAIL" and "ΔE" in slot_line( + wrong_colour.stdout + ) + assert refused_fix.returncode == 1 and "another colour" in refused_fix.stderr + assert blue.read_text(encoding="utf-8") == gcode_text( + "#0000FF" + ) # nothing was rewritten + + +def test_preflight_fix_aligns_gcode_3mf_and_sidecars_and_prints_the_hashes( + tmp_path: Path, +) -> None: + """`--fix`: one command rewrites the colours at the source; old and new sha256 are on record.""" + moon = FakeMoonraker() + load_beige_petg(moon) + g = write_plate(tmp_path) + old = sw.sha256_file(g) + with moon.serve(): + write_devices(tmp_path / "xdg-config", moon=moon) + fixed = run_cli(tmp_path, "print-preflight", str(g), "--printer", "u1", "--fix") + + assert f"colour fix: tool T3 (T4) {SLICED} -> {LOADED} (ΔE 6.4)" in fixed.stderr + assert ( + old in fixed.stderr + and sw.sha256_file(g) in fixed.stderr + and "sha256 new" in fixed.stderr + ) + assert ( + slot_line(fixed.stdout).split()[0] == "PASS" and "slicer warn" in fixed.stdout + ) + assert f"; filament_colour = #000000;#808080;#1B5E20;{LOADED}" in g.read_text( + encoding="utf-8" + ) + with zipfile.ZipFile(g.with_suffix(".3mf")) as z: + assert ( + json.loads(z.read("Metadata/project_settings.config"))["filament_colour"][3] + == LOADED + ) + assert ( + json.loads((tmp_path / "plate.manifest.json").read_text())["filaments"][3][ + "hex" + ] + == LOADED + ) + assert sw.load(sw.sidecar_path(g))["gcode_sha256"] == sw.sha256_file(g) + assert [line for line in fixed.stdout.splitlines() if "slicer warn" in line][ + 0 + ].split()[0] == "PASS" + assert moon.writes() == [] diff --git a/tests/test_colour_fix.py b/tests/test_colour_fix.py new file mode 100644 index 00000000..03d2778f --- /dev/null +++ b/tests/test_colour_fix.py @@ -0,0 +1,323 @@ +"""Unit tests for lib/printer_ops/colour_fix.py: the CIEDE2000 slot colour tolerance and the rewrite +that aligns a G-code, its project 3MF, manifest and slice-warnings sidecar with the loaded colour +(issue https://github.com/alex-mextner/3d-cli/issues/728).""" + +from __future__ import annotations + +import hashlib +import json +import os +import zipfile +from pathlib import Path + +import pytest +from colour_fix_fixtures import ( + LOADED, + SLICED, + TOOLPATH, + gcode_text, + load_beige_petg, + write_plate, + write_project, +) +from printer_fakes import FakeMoonraker + +from errors import InvalidArgument +from printer_ops import colour_fix, moonraker, preflight +from printer_ops.base import PrinterRefused, PrinterStatus +from slicing import slice_warnings as sw + + +def status(colour: str = LOADED) -> PrinterStatus: + moon = FakeMoonraker() + load_beige_petg(moon, colour=colour) + return moonraker.parse_status(moon.status) + + +def sha(path: Path) -> str: + return hashlib.sha256(path.read_bytes()).hexdigest() + + +# ---- the distance ------------------------------------------------------------------------ + + +def test_the_real_beige_pair_is_close_and_other_colours_are_far() -> None: + assert colour_fix.colour_delta(SLICED, LOADED) == pytest.approx(6.4, abs=0.1) + assert colour_fix.colour_delta("#FFFFFF", SLICED) == pytest.approx(16.7, abs=0.1) + assert colour_fix.colour_delta("#FF0000", "#0000FF") == pytest.approx(52.9, abs=0.1) + assert colour_fix.colour_delta(SLICED, SLICED) == 0.0 + assert colour_fix.colour_delta("nonsense", SLICED) is None + + +@pytest.mark.parametrize("bad", ["0", "-3", "abc", "nan"]) +def test_delta_e_option_must_be_a_positive_number(bad: str) -> None: + with pytest.raises(InvalidArgument): + colour_fix.parse_delta_e( + bad, "--colour-delta-e", 10.0, command="print-preflight" + ) + assert colour_fix.parse_delta_e(None, "--colour-delta-e", 10.0, command="x") == 10.0 + assert colour_fix.parse_delta_e("7.5", "--colour-delta-e", 10.0, command="x") == 7.5 + + +# ---- the slot check ---------------------------------------------------------------------- + + +def slots_of( + tmp_path: Path, text: str, st: PrinterStatus, **kw: float +) -> preflight.Check: + g = tmp_path / "p.gcode" + g.write_text(text, encoding="utf-8") + (check,) = preflight.check_slots(preflight.read_gcode_meta(g), st, **kw) + return check + + +def test_same_family_beige_passes_and_reports_the_distance(tmp_path: Path) -> None: + check = slots_of(tmp_path, gcode_text(), status()) + assert check.status == "PASS" + assert ( + "ΔE 6.4" in check.detail and SLICED in check.detail and "PETG" in check.detail + ) + + +def test_a_different_colour_fails_with_the_distance_and_the_fix(tmp_path: Path) -> None: + check = slots_of(tmp_path, gcode_text("#FFFFFF"), status()) + assert check.status == "FAIL" + assert "G-code #FFFFFF, loaded #FEE5A5 (ΔE 20.0 > 10)" in check.detail + assert "--fix" in check.detail + + +def test_the_threshold_is_configurable(tmp_path: Path) -> None: + assert ( + slots_of(tmp_path, gcode_text(), status(), colour_delta_e=5.0).status == "FAIL" + ) + assert ( + slots_of(tmp_path, gcode_text("#FFFFFF"), status(), colour_delta_e=25.0).status + == "PASS" + ) + + +def test_a_material_mismatch_stays_a_fail_even_for_the_same_colour( + tmp_path: Path, +) -> None: + check = slots_of(tmp_path, gcode_text(SLICED, "PLA"), status()) + assert ( + check.status == "FAIL" and "material: G-code PLA, loaded PETG" in check.detail + ) + assert ( + slots_of(tmp_path, gcode_text(LOADED, "PLA"), status(colour=LOADED)).status + == "FAIL" + ) + + +# ---- the plan ---------------------------------------------------------------------------- + + +def test_plan_edits_the_used_tool_and_refuses_another_colour(tmp_path: Path) -> None: + g = write_plate(tmp_path) + edits, refusals = colour_fix.plan_colour_fix(preflight.read_gcode_meta(g), status()) + assert refusals == [] and [(e.tool, e.label, e.old, e.new) for e in edits] == [ + (3, "T4", SLICED, LOADED) + ] + assert edits[0].delta_e == pytest.approx(6.4, abs=0.1) + + edits, refusals = colour_fix.plan_colour_fix( + preflight.read_gcode_meta(g), status(colour="#0000FF") + ) + assert edits == [] and len(refusals) == 1 + assert "tool T3 (T4)" in refusals[0] and "another colour" in refusals[0] + # the operator may widen the limit on purpose + edits, _ = colour_fix.plan_colour_fix( + preflight.read_gcode_meta(g), status(colour="#0000FF"), max_delta_e=100.0 + ) + assert [e.new for e in edits] == ["#0000FF"] + + +def test_plan_leaves_matching_and_empty_heads_alone(tmp_path: Path) -> None: + g = write_plate(tmp_path) + assert colour_fix.plan_colour_fix( + preflight.read_gcode_meta(g), status(colour=SLICED) + ) == ([], []) + moon = FakeMoonraker() + moon.status["print_task_config"]["filament_exist"][3] = False + empty = moonraker.parse_status(moon.status) + assert colour_fix.plan_colour_fix(preflight.read_gcode_meta(g), empty) == ([], []) + + +# ---- the rewrite ------------------------------------------------------------------------- + + +def fix( + g: Path, st: PrinterStatus | None = None, source: Path | None = None +) -> colour_fix.FixResult: + result = colour_fix.fix_to_loaded( + g, st or status(), source, command="print-preflight" + ) + assert result is not None + return result + + +def test_fix_rewrites_gcode_3mf_manifest_and_sidecar_consistently( + tmp_path: Path, +) -> None: + g = write_plate(tmp_path) + project, manifest, side = ( + g.with_suffix(".3mf"), + tmp_path / "plate.manifest.json", + sw.sidecar_path(g), + ) + old_g, old_p = sha(g), sha(project) + g.chmod(0o640) + os.utime(g, (1_700_000_000, 1_700_000_000)) + os.utime(project, (1_700_000_100, 1_700_000_100)) + model_before = zipfile.ZipFile(project).read("3D/3dmodel.model") + + result = fix(g) + + # G-code: only the colour entries of tool 3 changed, the toolpath bytes did not + text = g.read_text(encoding="utf-8") + assert text.startswith("; HEADER_BLOCK_START\n; HEADER_BLOCK_END\n" + TOOLPATH) + assert f"; filament_colour = #000000;#808080;#1B5E20;{LOADED}\n" in text + assert f"; extruder_colour = #000000;#808080;#1B5E20;{LOADED}\n" in text + assert "; filament_type = PLA;PLA;PLA;PETG\n" in text + assert (g.stat().st_mode & 0o777, g.stat().st_mtime_ns) == ( + 0o640, + 1_700_000_000 * 10**9, + ) + assert project.stat().st_mtime_ns == 1_700_000_100 * 10**9 + # the reported hashes are the real ones + assert (result.gcode.old_sha256, result.gcode.new_sha256) == ( + old_g, + sha(g), + ) and old_g != sha(g) + assert result.project is not None and ( + result.project.old_sha256, + result.project.new_sha256, + ) == (old_p, sha(project)) + # 3MF: colour lists + plate json updated, the other members untouched + with zipfile.ZipFile(project) as z: + cfg = json.loads(z.read("Metadata/project_settings.config")) + assert cfg["filament_colour"][3] == cfg["extruder_colour"][3] == LOADED + assert ( + cfg["filament_colour"][:3] == ["#000000", "#808080", "#1B5E20"] + and cfg["layer_height"] == "0.2" + ) + assert ( + z.read("Metadata/project_settings.config") + .decode() + .startswith('{\n "filament_colour"') + ) + assert ( + json.loads(z.read("Metadata/plate_1.json"))["filament_colors"][3] == LOADED + ) + assert z.read("3D/3dmodel.model") == model_before and z.testzip() is None + # manifest + slice-warnings sidecar follow + assert [f["hex"] for f in json.loads(manifest.read_text())["filaments"]][ + 3 + ] == LOADED + data = json.loads(side.read_text(encoding="utf-8")) + assert ( + data["gcode_sha256"] == sha(g) + and data["post_processed"][-1]["step"] == "colour fix" + ) + assert sw.evaluate(g)[0] == "PASS" + assert result.manifest == manifest and result.sidecar == side + lines = "\n".join(result.lines()) + assert ( + f"{SLICED} -> {LOADED} (ΔE 6.4)" in lines and old_g in lines and sha(g) in lines + ) + # nothing is left behind and a second run finds nothing to do + assert [ + p.name for p in tmp_path.iterdir() if p.name.startswith(".colour-fix") + ] == [] + assert ( + colour_fix.fix_to_loaded(g, status(), None, command="print-preflight") is None + ) + + +def test_fix_uses_an_explicit_project_and_tolerates_a_non_project_3mf( + tmp_path: Path, +) -> None: + g = write_plate(tmp_path) + other = tmp_path / "other.3mf" + write_project(other) + assert fix(g, source=other).project is not None + assert ( + json.loads(zipfile.ZipFile(other).read("Metadata/project_settings.config"))[ + "filament_colour" + ][3] + == LOADED + ) + assert ( + json.loads( + zipfile.ZipFile(g.with_suffix(".3mf")).read( + "Metadata/project_settings.config" + ) + )["filament_colour"][3] + == SLICED + ) # the sibling was not the project asked for + + h = write_plate(tmp_path, "model") + with zipfile.ZipFile(h.with_suffix(".3mf"), "w") as z: + z.writestr( + "3D/3dmodel.model", "" + ) # a plain model, not a slicer project + result = fix(h) + assert result.project is None and "not a slicer project 3MF" in "\n".join( + result.lines() + ) + assert LOADED in h.read_text(encoding="utf-8") + + +def test_fix_without_a_project_rewrites_only_the_gcode(tmp_path: Path) -> None: + g = tmp_path / "solo.gcode" + g.write_text(gcode_text(), encoding="utf-8") + result = fix(g) + assert result.project is None and result.manifest is None and result.sidecar is None + assert "no project 3MF found" in "\n".join(result.lines()) + + +def test_fix_refuses_another_colour_and_writes_nothing(tmp_path: Path) -> None: + g = write_plate(tmp_path) + before = {p.name: p.read_bytes() for p in tmp_path.iterdir()} + with pytest.raises(PrinterRefused, match="another colour"): + colour_fix.fix_to_loaded( + g, status(colour="#0000FF"), None, command="print-preflight" + ) + assert {p.name: p.read_bytes() for p in tmp_path.iterdir()} == before + + +def test_fix_without_a_colour_line_refuses_before_touching_the_3mf( + tmp_path: Path, +) -> None: + g = write_plate(tmp_path) + g.write_text( + TOOLPATH + + "; filament used [mm] = 0.00, 0.00, 0.00, 30.50\n; filament_type = PLA;PLA;PLA;PETG\n" + ) + project = g.with_suffix(".3mf") + before = project.read_bytes() + edits = [colour_fix.ColourEdit(3, "T4", SLICED, LOADED, 6.4)] + with pytest.raises(PrinterRefused, match="no `; filament_colour`"): + colour_fix.apply_colour_fix(g, project, edits) + assert project.read_bytes() == before + + +def test_fix_refuses_a_printer_without_per_head_data(tmp_path: Path) -> None: + g = write_plate(tmp_path) + with pytest.raises(PrinterRefused, match="no per-head filament data"): + colour_fix.fix_to_loaded( + g, PrinterStatus("idle", "standby"), None, command="print-preflight" + ) + + +def test_fix_keeps_quotes_crlf_and_the_other_entries(tmp_path: Path) -> None: + g = tmp_path / "crlf.gcode" + g.write_bytes( + b"G28\r\nT3\r\n" + b'; filament_colour = "#000000";"#808080";"#1B5E20";"#E8D8B0FF"\r\n' + b"; filament used [mm] = 0.00, 0.00, 0.00, 30.50\r\n" + b"; filament_type = PLA;PLA;PLA;PETG\r\n" + ) + fix(g) + assert b'; filament_colour = "#000000";"#808080";"#1B5E20";"#FEE5A5"\r\n' in g.read_bytes() + assert g.read_bytes().count(b"\r\n") == 5 and g.read_bytes().startswith(b"G28\r\nT3\r\n") From d657c9d7df8c3ac63f0fec233dae17f8f25d36c3 Mon Sep 17 00:00:00 2001 From: alex-mextner <111590326+alex-mextner@users.noreply.github.com> Date: Thu, 5 Nov 2026 01:13:41 +0100 Subject: [PATCH 4/8] fix(printer): retry a start --force approval Telegram could not post, then point to the printer screen (Refs #728) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tg-ctl answers `ask-forward not-posted … daemon declined: reason=send-failed` when its single sendMessage failed (2026-11-04 22:02/22:03: `getaddrinfo EAI_AGAIN api.telegram.org`, a resolver blip; plain `tg` worked a moment later). No card is posted then, so asking again is safe: the ask is repeated after 3, 10 and 30 s, each reported on stderr. Every other outcome stays final at once. When it still fails the refusal says how to start from the printer's own screen (the file is already stored) and which daemon log lines to read. --- lib/printer_ops/force_hatch.py | 83 +++++++++++++++++++++++++++++++--- tests/e2e/hatch_runner.py | 11 +++-- tests/test_force_hatch.py | 76 +++++++++++++++++++++++++++++++ 3 files changed, 161 insertions(+), 9 deletions(-) diff --git a/lib/printer_ops/force_hatch.py b/lib/printer_ops/force_hatch.py index a1217fba..d18980cd 100644 --- a/lib/printer_ops/force_hatch.py +++ b/lib/printer_ops/force_hatch.py @@ -17,6 +17,14 @@ (fix the gate). Each approved or refused force is therefore an issue to analyse and a root cause to fix, not a habit — the refusal message says so. +RETRY (issue https://github.com/alex-mextner/3d-cli/issues/728): the tg-ctl daemon answers a card it + could not send with `ask-forward not-posted: … daemon declined: reason=send-failed` (tg-cli + `forwardAndPostQuestion`: ONE `sendMessage`, no retry; on 2026-11-04 22:02 and 22:03 it was + `getaddrinfo EAI_AGAIN api.telegram.org`, a resolver blip, while a plain `tg` a moment later + worked). Nothing was posted, so asking again is safe: the ask is repeated after each pause of + RETRY_DELAYS_S (3, 10, 30 s). When it still fails the refusal says how to start from the + printer's own screen and where the daemon logs the cause. + INVARIANTS: the helper is loaded from a FIXED path under the account's real home (never an env var, never the repo or $PATH), and only when its file is owned by you or root and not group/world writable; a helper that is missing or untrusted refuses the start (fail closed). @@ -31,6 +39,7 @@ import shlex import stat import sys +import time from collections.abc import Sequence from pathlib import Path from typing import Protocol, cast @@ -45,7 +54,15 @@ # agent-tools checkouts searched, relative to the real home; the first one that has the helper wins. _CHECKOUTS = (Path("dev") / "agent-tools", Path("xp") / "agent-tools") _DETAIL_CHARS = 240 -_CARD_VALUE_CHARS = 1000 # the shared hatch helper cuts each context value at this length +_CARD_VALUE_CHARS = ( + 1000 # the shared hatch helper cuts each context value at this length +) +# The daemon's own wording for a card it could not post (agent-tools `_ask_forward_verdict`, tg-cli +# question-cards.ts `dropAsk(socket, "send-failed")`). +_SEND_FAILED = "reason=send-failed" +# Pauses before each further ask while the card is not posted: a transient resolver/network failure +# of the daemon clears in seconds, so the owner still gets the card and the start does not stall. +RETRY_DELAYS_S = (3.0, 10.0, 30.0) class _Verdict(Protocol): @@ -129,7 +146,9 @@ def failing_summary(checks: Sequence[Check], budget: int | None = None) -> str: return "\n".join(lines) -def _refusal(failing: str, why: str, command: str) -> PrinterRefused: +def _refusal( + failing: str, why: str, command: str, extra: Sequence[str] = () +) -> PrinterRefused: return PrinterRefused( f"--force does not override preflight FAIL by itself: {why}", command="printer", @@ -139,13 +158,59 @@ def _refusal(failing: str, why: str, command: str) -> PrinterRefused: f"Only if forcing is truly needed, ask the owner for a one-time Telegram approval: " f'{ENV_VAR}="" {command}', "Every forced case is analysed afterwards and its root cause fixed.", + *extra, "Failing checks:\n" + failing, ], ) +def _send_failed(verdict: _Verdict) -> bool: + """The daemon declined to post the card (`ask-forward not-posted … reason=send-failed`): its + Telegram send failed (a DNS or network blip, 2026-11-04 `getaddrinfo EAI_AGAIN api.telegram.org`). + No card exists then, so asking again cannot double-approve.""" + return not verdict.approved and _SEND_FAILED in verdict.reason + + +def _ask_with_retry( + hatch: _HatchLib, context: dict[str, object], command: str +) -> _Verdict: + """`request_hatch_approval`, asked again after each `RETRY_DELAYS_S` pause while the card could not + be posted. Any other verdict (approved, denied, timed out, not requested) is final at once.""" + verdict = hatch.request_hatch_approval( + HOOK_ID, context, cwd=os.getcwd(), command=command, critical=True + ) + for delay in RETRY_DELAYS_S: + if not _send_failed(verdict): + break + print( + f"hatch: the approval card was not posted to Telegram ({verdict.reason}); asking again in {delay:g} s", + file=sys.stderr, + ) + time.sleep(delay) + verdict = hatch.request_hatch_approval( + HOOK_ID, context, cwd=os.getcwd(), command=command, critical=True + ) + return verdict + + +def _screen_hint(remote_name: str | None) -> list[str]: + """How to start without Telegram: from the printer's own screen.""" + what = f"`{remote_name}`" if remote_name else "the file" + return [ + f"Start from the printer screen instead: {what} is already stored on the printer (Files, then Print). " + "The screen does NOT run these checks: only do it when the FAIL lines below are understood.", + "Why Telegram failed: tg-ctl's daemon could not send the card (plain `tg` can still work); see " + "`~/.config/tg-cli/tg-ctl.*.log`, lines `sendMessage failed` / `ask-forward dropped: reason=send-failed`.", + ] + + def require_force_approval( - *, printer: str, file: str, sha256: str, checks: Sequence[Check] + *, + printer: str, + file: str, + sha256: str, + checks: Sequence[Check], + remote_name: str | None = None, ) -> str: """Return the approval reason when the owner approved the forced start, else raise PrinterRefused. @@ -169,11 +234,17 @@ def require_force_approval( "sha256": sha256, "failing_checks": failing_summary(checks, _CARD_VALUE_CHARS - 50), } - verdict = hatch.request_hatch_approval( - HOOK_ID, context, cwd=os.getcwd(), command=command, critical=True - ) + verdict = _ask_with_retry(hatch, context, command) if verdict.approved: return verdict.reason + if _send_failed(verdict): + raise _refusal( + failing, + f"the approval request could not be posted to Telegram after {1 + len(RETRY_DELAYS_S)} attempts " + f"({verdict.reason})", + command, + extra=_screen_hint(remote_name), + ) if verdict.should_stop: raise _refusal( failing, diff --git a/tests/e2e/hatch_runner.py b/tests/e2e/hatch_runner.py index 3ef6b8fe..a02f51c6 100644 --- a/tests/e2e/hatch_runner.py +++ b/tests/e2e/hatch_runner.py @@ -3,8 +3,10 @@ An e2e test cannot tap Approve on a real phone, and the shared hatch helper refuses every tg-ctl outside the test sandbox. This runner is `python hatch_runner.py <3d args>`: it swaps `printer_ops.force_hatch.load_hatch` for a fake whose verdict comes from THREED_TEST_HATCH_VERDICT -("approve" | "deny") and appends each request (hook id, context, command) as one JSON line to the +("approve" | "deny" | "send-failed") and appends each request (hook id, context, command) as one JSON line to the file in THREED_TEST_HATCH_LOG, then runs the real dispatcher. Everything else is the real CLI. +Verdict "send-failed" answers every request as the tg-ctl daemon does when its Telegram send failed +(`ask-forward not-posted … reason=send-failed`); the retry pauses are zeroed so the test does not wait. """ from __future__ import annotations @@ -52,8 +54,11 @@ def request_hatch_approval( ) + "\n" ) - if os.environ["THREED_TEST_HATCH_VERDICT"] == "approve": + verdict = os.environ["THREED_TEST_HATCH_VERDICT"] + if verdict == "approve": return _Verdict(True, True, "test double approved") + if verdict == "send-failed": + return _Verdict(False, True, "tg-ctl ask: ask-forward not-posted: daemon declined: reason=send-failed") return _Verdict(False, True, "test double denied") @@ -62,5 +67,5 @@ def _fake_hatch() -> _FakeHatch: if __name__ == "__main__": - with patch.object(force_hatch, "load_hatch", _fake_hatch): + with patch.object(force_hatch, "load_hatch", _fake_hatch), patch.object(force_hatch, "RETRY_DELAYS_S", (0.0,) * 3): sys.exit(main(sys.argv[1:])) diff --git a/tests/test_force_hatch.py b/tests/test_force_hatch.py index 81a746c5..4180b571 100644 --- a/tests/test_force_hatch.py +++ b/tests/test_force_hatch.py @@ -179,3 +179,79 @@ def test_missing_helper_fails_closed( _home(monkeypatch, tmp_path) with pytest.raises(force_hatch.HatchUnavailable, match="not found"): force_hatch.load_hatch() + + +# ---- tg-ctl `ask-forward not-posted … reason=send-failed` (issue #728) -------------------- + +NOT_POSTED = ( + "tg-ctl ask: ask-forward not-posted: agent=claude kind=permission req=hatch-3d-printer-start-force-d6543f0fc5b0 " + "daemon declined: reason=send-failed" +) + + +class ScriptedHatch: + """The shared hatch helper answering a fixed sequence of verdicts, one per request.""" + + def __init__(self, *verdicts: tuple[bool, bool, str]) -> None: + self.verdicts = list(verdicts) + self.requests = 0 + + def request_hatch_approval(self, hook_id: str, context: dict[str, object], **_: object) -> SimpleNamespace: + self.requests += 1 + approved, should_stop, reason = self.verdicts.pop(0) + return SimpleNamespace(approved=approved, should_stop=should_stop, reason=reason) + + +@pytest.fixture() +def sleeps(monkeypatch: pytest.MonkeyPatch) -> list[float]: + slept: list[float] = [] + monkeypatch.setattr(force_hatch.time, "sleep", slept.append) + return slept + + +def ask(hatch: ScriptedHatch, monkeypatch: pytest.MonkeyPatch) -> str: + monkeypatch.setattr(force_hatch, "load_hatch", lambda: hatch) + return force_hatch.require_force_approval( + printer="u1", file="plate.gcode", sha256="0" * 64, checks=CHECKS, remote_name="plate-0123456789abcdef.gcode" + ) + + +def test_a_card_that_was_not_posted_is_asked_again_with_backoff_until_the_owner_answers( + monkeypatch: pytest.MonkeyPatch, sleeps: list[float], capsys: pytest.CaptureFixture[str] +) -> None: + hatch = ScriptedHatch((False, True, NOT_POSTED), (False, True, NOT_POSTED), (True, True, "approved by tg-ctl ask")) + assert ask(hatch, monkeypatch) == "approved by tg-ctl ask" + assert hatch.requests == 3 and sleeps == [3.0, 10.0] + assert "asking again in 3 s" in capsys.readouterr().err + + +def test_a_card_that_never_posts_ends_with_the_printer_screen_way( + monkeypatch: pytest.MonkeyPatch, sleeps: list[float] +) -> None: + hatch = ScriptedHatch(*[(False, True, NOT_POSTED)] * 4) + with pytest.raises(PrinterRefused) as exc: + ask(hatch, monkeypatch) + assert hatch.requests == 4 and sleeps == [3.0, 10.0, 30.0] + assert "could not be posted to Telegram after 4 attempts" in exc.value.message + text = "\n".join(exc.value.remediation) + assert "Start from the printer screen" in text and "plate-0123456789abcdef.gcode" in text + assert "does NOT run these checks" in text and "tg-ctl.*.log" in text and "- macros:" in text + + +@pytest.mark.parametrize( + "verdict", + [ + (False, True, "tg-ctl ask decision was 'deny' (expected 'allow')"), + (False, True, "tg-ctl ask: ask-forward expired: messageId=7 — posted to Telegram; no decision"), + (False, True, "tg-ctl ask: ask-forward not-posted: daemon declined: reason=not-registered"), + (False, False, "RIG_HATCH_REQUEST_3D_PRINTER_START_FORCE is not set"), + ], +) +def test_other_outcomes_are_final_and_never_retried( + monkeypatch: pytest.MonkeyPatch, sleeps: list[float], verdict: tuple[bool, bool, str] +) -> None: + hatch = ScriptedHatch(verdict) + with pytest.raises(PrinterRefused) as exc: + ask(hatch, monkeypatch) + assert hatch.requests == 1 and sleeps == [] + assert "printer screen" not in "\n".join(exc.value.remediation) From 48ad3cab63e0db25ade20612cb780b5d7b9b4f15 Mon Sep 17 00:00:00 2001 From: alex-mextner <111590326+alex-mextner@users.noreply.github.com> Date: Thu, 5 Nov 2026 01:14:47 +0100 Subject: [PATCH 5/8] feat(printer): start --fix-colour and `printer slot set|show` align file and printer colour (Refs #728) `3d printer start NAME FILE --yes --fix-colour` rewrites FILE (and its project 3MF) to the colours the printer reports, prints old/new sha256, uploads the fixed file (its hash is part of the stored name) and then runs the preflight and starts: no --force, no Telegram. --colour-delta-e and --fix-max-delta-e are passed through. `3d printer slot set NAME T --material M --color #RRGGBB [--brand B]` writes the head's filament record the other way round: it is `3d printers filament set NAME/T` (U1: SET_PRINT_FILAMENT_CONFIG through /printer/gcode/script, read back), so it refuses a read-only/offline printer, a running job and official RFID filament, and has --dry-run. `slot show NAME` reads the heads. The printer command's legacy functions are split into helpers (the same decomposition as the open thumbnail PR) so the touched file passes the complexity gate. --- docs/commands/printer.md | 63 ++++ lib/commands/printer.py | 401 +++++++++++++++------ tests/e2e/test_printer_colour_workflows.py | 168 +++++++++ tests/test_printer_colour_commands.py | 365 +++++++++++++++++++ 4 files changed, 888 insertions(+), 109 deletions(-) create mode 100644 tests/e2e/test_printer_colour_workflows.py create mode 100644 tests/test_printer_colour_commands.py diff --git a/docs/commands/printer.md b/docs/commands/printer.md index 4ed17330..0f55f03d 100644 --- a/docs/commands/printer.md +++ b/docs/commands/printer.md @@ -18,6 +18,9 @@ Epic: [#90](https://github.com/alex-mextner/3d-cli/issues/90). 3d printer status NAME [--json] [--secs S] 3d printer upload NAME FILE.gcode [--as REMOTE] 3d printer start NAME FILE.gcode --yes [--as REMOTE] [--source-3mf X.3mf] [--bridge-max MM] [--force] [--secs S] [--no-bed-move] + [--colour-delta-e N] [--fix-colour [--fix-max-delta-e N]] +3d printer slot set NAME T --material M --color #RRGGBB [--brand B] [--sub-type S] [--dry-run] [--force-official] [--secs S] [--json] +3d printer slot show NAME [--secs S] [--json] 3d printer cancel NAME --yes 3d printer evidence NAME [--job ID | --last] [--out DIR] [--gcode FILE.gcode] [--note TEXT] [--klippy-log FILE] [--job-file FILE] 3d printer guard NAME --job-id ID --yes [--interval S] [--evidence DIR] @@ -86,6 +89,56 @@ Options: head-sequence check on it and `3d halves-check` (without it, the head-sequence check uses `NAME.3mf` next to `NAME.gcode` when present). `3d printer start u1 out/base.gcode --yes --source-3mf print/base.3mf` +- `--colour-delta-e N` — `start`: the preflight `slot Tn` colour tolerance, CIEDE2000, default 10 + (see [Slot colour](#slot-colour)). `3d printer start u1 out/plate.gcode --yes --colour-delta-e 8` +- `--fix-colour` — `start`: first rewrite the file (and its project 3MF) to the colours the printer + reports, print the old/new SHA-256, upload the fixed file, then run the preflight and start. + `3d printer start u1 out/plate.gcode --yes --fix-colour` +- `--fix-max-delta-e N` — the largest ΔE `--fix-colour` rewrites across (default 30). + `3d printer start u1 out/plate.gcode --yes --fix-colour --fix-max-delta-e 40` + +## Slot colour + +The preflight `slot Tn` line compares the colour the slicer wrote (`; filament_colour`) with the +colour the printer reports for that head by **CIEDE2000 ΔE**, default tolerance 10 +(`--colour-delta-e`), and prints the distance. The same beige PETG is `#E8D8B0` in the slice and +`#FEE5A5` on the U1 screen: ΔE 6.4, PASS. A material mismatch is always a FAIL. Three ways out of a +real mismatch, none of them `--force`: + +```bash +# 1. the file is wrong about the colour: rewrite it at the source (G-code + project 3MF + sidecars) +3d print-preflight out/plate.gcode --printer u1 --fix +3d printer start u1 out/plate.gcode --yes --fix-colour # rewrite, upload the fixed file, preflight, start +# 2. the printer is wrong about what is loaded: write the head's record (idle printers only) +3d printer slot set u1 T4 --material PETG --color '#E8D8B0' --brand Elegoo --dry-run +3d printer slot set u1 T4 --material PETG --color '#E8D8B0' --brand Elegoo +# 3. load the filament the file was sliced for +``` + +`--fix` / `--fix-colour` set entry *n* of `filament_colour` and `extruder_colour` to the printer's +colour for every tool the G-code uses, in the G-code and in its project 3MF +(`Metadata/project_settings.config`, `plate_N.json`), keep mode and mtime, move +`NAME.slice-warnings.json` to the new G-code SHA-256 and update `NAME.manifest.json`, and print the +old and new SHA-256 of each file (the hash is part of the stored file name, hence the upload in +`start --fix-colour`). A head whose colour is further than `--fix-max-delta-e` (default 30) from the +slice is refused with nothing written: that is another filament, and relabelling it would hide a +wrong-colour print. + +`slot set NAME T --material M --color #RRGGBB [--brand B] [--sub-type S] [--dry-run] +[--force-official]` is `3d printers filament set NAME/T` (`--brand` is its `--vendor`); `T` +is the label the printer shows (U1: T1..T4 = G-code tools T0..T3). `slot show NAME` reads the heads. + +**What the Snapmaker U1 accepts** (Snapmaker/u1-klipper `print_task_config.py`, issue #400): the +touchscreen's own Klipper command `SET_PRINT_FILAMENT_CONFIG CONFIG_EXTRUDER= VENDOR=… FILAMENT_TYPE=… +FILAMENT_SUBTYPE=… FILAMENT_COLOR_RGBA=RRGGBBFF`, sent through `POST /printer/gcode/script` (no +webhook endpoint exists for it) and read back from `print_task_config`. It needs vendor, type and +sub-type together (defaults Generic / Basic), persists to `print_task.json`, resets the head's +pressure advance (the answer prints the K it has now), and refuses a head with official RFID filament +unless `--force-official`. 3d refuses before sending when the fleet entry is not `access: full` +(read-only and offline printers), while printing or paused, or when Klipper is not ready. Not used: +the RFID path `FILAMENT_DT_UPDATE` / the Extended Firmware's `POST /printer/filament_detect/set` +(it marks the slot official, which locks later writes). Other adapters (Creality) have no +per-head write: `slot set` exits 2 and `slot show` is the read-only comparison. ## Examples @@ -827,6 +880,16 @@ RIG_HATCH_REQUEST_3D_PRINTER_START_FORCE="loaded_filament_match: slice has no co `RIG_HATCH_REQUEST_3D_PRINTER_START_FORCE=approved:hgr-…`, redeems. - Approved: the start continues, prints `FORCED: starting despite preflight FAIL (...)`, and the helper appends the use to `~/.config/agent-tools/overrides.log`. +- If the daemon could not even post the card (`tg-ctl ask-forward not-posted … daemon declined: + reason=send-failed`: its Telegram `sendMessage` failed, 2026-11-04 it was `getaddrinfo EAI_AGAIN + api.telegram.org`, a resolver blip while plain `tg` worked) nothing was posted, so the ask is + repeated after 3, 10 and 30 s (four requests in all, each reported on stderr). If it still + fails the start is refused with the way around Telegram: the file is already stored on the + printer, so start it **from the printer screen** (Files, then Print) — the screen does not run + these checks, do it only when the listed FAILs are understood — and with the log to read + (`~/.config/tg-cli/tg-ctl.*.log`, lines `sendMessage failed` / `ask-forward dropped`). + The daemon itself sends once and never retries (tg-cli `forwardAndPostQuestion`), which is why + the retry lives here. - Without `--force`, without the variable, denied, timed out, or without a trusted `tg-ctl` / helper (`~/dev/agent-tools`, then `~/xp/agent-tools`, owned by you or root and not group/world-writable): exit 1, nothing is sent to the printer. diff --git a/lib/commands/printer.py b/lib/commands/printer.py index 55563bd2..5bd65ab3 100644 --- a/lib/commands/printer.py +++ b/lib/commands/printer.py @@ -27,6 +27,7 @@ from errors import InputNotFound, UsageError if TYPE_CHECKING: + from collections.abc import Callable from pathlib import Path from printer_ops.base import Adapter, PrinterStatus @@ -47,13 +48,21 @@ history, settings: `3d printers show NAME`) upload NAME FILE.gcode [--as REMOTE] store the file on the printer; never starts a print start NAME FILE.gcode --yes [--as REMOTE] [--source-3mf X.3mf] [--bridge-max MM] [--force] [--secs S] - [--no-bed-move] + [--no-bed-move] [--colour-delta-e N] [--fix-colour [--fix-max-delta-e N]] start a file that was uploaded before; runs `3d print-preflight` first, plus the camera bed check (objects on the bed / plate missing FAIL) when the printer has watch.bed.enabled (an idle printer with watch.bed.auto_view has its bed moved in Z to where the camera sees the whole plate, and back: --no-bed-move) + slot set NAME T --material M --color #RRGGBB [--brand B] [--sub-type S] [--dry-run] [--force-official] + [--secs S] [--json] + write the filament one head shows it holds (U1: the touchscreen's + SET_PRINT_FILAMENT_CONFIG, read back) so the printer's record equals + what is loaded; T is the label on the printer (T1..T4 = G-code + T0..T3). Idle, `access: full` printers only; --dry-run prints the + before/after and every rule that would refuse, writing nothing. + `slot show NAME` reads the heads (see `3d printers filament --help`) cancel NAME --yes cancel the running/paused job evidence NAME [--job ID | --last] [--out DIR] [--gcode F] [--note T] after EVERY print (done/cancelled/failed): save the Moonraker @@ -117,6 +126,14 @@ --bridge-max MM preflight overhangs gate: longest bridge; overrides the printer's bridge_max_mm in printer-devices.yaml (default 15) --no-bed-move start: the camera bed check judges at the current Z (the bed is not moved) + --colour-delta-e N start: slot Tn colour tolerance, CIEDE2000 (default 10; the preflight line shows the + distance; a material mismatch is always a FAIL) + --fix-colour start: first rewrite `; filament_colour` / `; extruder_colour` of every tool the file + uses to the colour the printer reports, in FILE and its project 3MF (--source-3mf, + else NAME.3mf beside it), keeping mtime/mode; slice-warnings and manifest follow; + old/new sha256 are printed; the fixed FILE is uploaded (its hash, so its stored + name, changed), then the preflight runs. A head whose colour is further than + --fix-max-delta-e (default 30) away is refused: another filament Safety: start refuses while the printer is printing, paused, busy or unreadable, and when the content-addressed remote file is missing, differs in size, or (Moonraker, read back) @@ -134,6 +151,8 @@ 3d printer upload k1se "out/roof left.gcode" --as roof_left.gcode 3d printer start u1 out/part.gcode --yes 3d printer start u1 out/base.gcode --yes --source-3mf print/base.3mf + 3d printer start u1 out/plate.gcode --yes --fix-colour # slicer beige != the screen's beige: rewrite, upload, start + 3d printer slot set u1 T3 --material PETG --color '#FEE5A5' --brand Elegoo --dry-run RIG_HATCH_REQUEST_3D_PRINTER_START_FORCE="roof overhang FAIL is a stale map, issue #N" \\ 3d printer start k1se out/roof.gcode --yes --force # waits for the owner's Telegram tap 3d printer cancel k1se --yes @@ -150,8 +169,8 @@ 3d printer todo list u1 3d printer -n k1se issues add --title "X belt squeaks" --type inspect""" -_VALUE = {"--secs", "--as", "--source-3mf", "--bridge-max"} -_FLAGS = {"--json", "--yes", "--force", "--no-bed-move"} +_VALUE = {"--secs", "--as", "--source-3mf", "--bridge-max", "--colour-delta-e", "--fix-max-delta-e"} +_FLAGS = {"--json", "--yes", "--force", "--no-bed-move", "--fix-colour"} def _parse(argv: list[str], allowed: set[str], n_pos: int, sub: str) -> tuple[list[str], dict[str, str]]: @@ -196,32 +215,81 @@ def _adapter(name: str, opts: dict[str, str], *, write: bool = False) -> Adapter return adapter_by_name(name, secs=opts.get("--secs")) -def _list_row(name: str, printer: FleetPrinter | None, device: Device | None) -> dict[str, Any]: - """One `printer list` entry: fleet facts (owner, topology) + the device that would be contacted.""" +def _fleet_facts(printer: FleetPrinter | None) -> dict[str, Any]: + """The fleet-declared columns of a `printer list` row (empty facts when the printer is not in the fleet).""" from dataclasses import asdict + if printer is None: + return {"owner": None, "fleet": None, "heads": [], "ams": None, "enclosure": None, + "chamber_heater": None, "plates": None} + return { + "owner": printer.owner, + "fleet": str(printer.source), + "heads": [asdict(h) for h in printer.heads], + "ams": [asdict(u) for u in printer.ams] if printer.ams is not None else None, + "enclosure": printer.enclosure, + "chamber_heater": printer.chamber_heater, + "plates": list(printer.plates) if printer.plates is not None else None, + } + + +def _device_facts(device: Device | None) -> dict[str, Any]: + """The device-derived columns of a `printer list` row (the device that would be contacted).""" + if device is None: + return {"adapter": None, "address": None, "settings": None} + return {"adapter": device.adapter, "address": f"{device.host}:{device.port}", "settings": str(device.source)} + + +def _list_row(name: str, printer: FleetPrinter | None, device: Device | None) -> dict[str, Any]: + """One `printer list` entry: fleet facts (owner, topology) + the device that would be contacted.""" + fleet = _fleet_facts(printer) + dev = _device_facts(device) return { "name": name, "model": (device.model if device else None) or (printer.model if printer else None), - "owner": printer.owner if printer else None, + "owner": fleet["owner"], "access": device.access if device else printer.access if printer else None, - "adapter": device.adapter if device else None, - "address": f"{device.host}:{device.port}" if device else None, - "settings": str(device.source) if device else None, - "fleet": str(printer.source) if printer else None, - "heads": [asdict(h) for h in printer.heads] if printer else [], - "ams": [asdict(u) for u in printer.ams] if printer and printer.ams is not None else None, - "enclosure": printer.enclosure if printer else None, - "chamber_heater": printer.chamber_heater if printer else None, - "plates": list(printer.plates) if printer and printer.plates is not None else None, + "adapter": dev["adapter"], + "address": dev["address"], + "settings": dev["settings"], + "fleet": fleet["fleet"], + "heads": fleet["heads"], + "ams": fleet["ams"], + "enclosure": fleet["enclosure"], + "chamber_heater": fleet["chamber_heater"], + "plates": fleet["plates"], } +def _print_no_printers() -> None: + """The `printer list` hint when nothing is configured: where we looked and an example.""" + from printer_ops.devices import EXAMPLE, search_paths + from printops.fleet import fleet_path + + print("No printers configured. Looked in:") + for p in [fleet_path(), *search_paths()]: + print(f" {p}") + print("\nCreate one of them, e.g.:\n" + EXAMPLE) + + +def _print_list_row(r: dict[str, Any]) -> None: + """One human-readable `printer list` line plus its unusable-head and settings notes.""" + address = f"{r['adapter']} {r['address']}" if r["adapter"] else "- (never contacted)" + heads = " | ".join(h["label"] + ("" if h["usable"] else " UNUSABLE") for h in r["heads"]) or "-" + print(f" {r['name']:<13} {r['owner'] or '-':<12} {r['access']:<9} {r['model'] or '-':<20} " + f"{address:<31} {heads}") + for h in r["heads"]: + if not h["usable"]: + print(f" {h['label']}: {h['reason']}") + if r["settings"] and r["settings"] != r["fleet"]: + print(f" device settings: {r['settings']}") + + def _cmd_list(argv: list[str]) -> int: import json - from printer_ops.devices import EXAMPLE, load_devices, search_paths - from printops.fleet import fleet_path, load_fleet + from printer_ops.devices import load_devices + from printops.fleet import load_fleet _, opts = _parse(argv, {"--json"}, 0, "list") fleet = load_fleet() @@ -232,22 +300,11 @@ def _cmd_list(argv: list[str]) -> int: print(json.dumps(rows, indent=1, ensure_ascii=False)) return 0 if not rows: - print("No printers configured. Looked in:") - for p in [fleet_path(), *search_paths()]: - print(f" {p}") - print("\nCreate one of them, e.g.:\n" + EXAMPLE) + _print_no_printers() return 0 print(f" {'NAME':<13} {'OWNER':<12} {'ACCESS':<9} {'MODEL':<20} {'ADDRESS':<31} HEADS") for r in rows: - address = f"{r['adapter']} {r['address']}" if r["adapter"] else "- (never contacted)" - heads = " | ".join(h["label"] + ("" if h["usable"] else " UNUSABLE") for h in r["heads"]) or "-" - print(f" {r['name']:<13} {r['owner'] or '-':<12} {r['access']:<9} {r['model'] or '-':<20} " - f"{address:<31} {heads}") - for h in r["heads"]: - if not h["usable"]: - print(f" {h['label']}: {h['reason']}") - if r["settings"] and r["settings"] != r["fleet"]: - print(f" device settings: {r['settings']}") + _print_list_row(r) if fleet is not None: print(f"\nfleet: {fleet.path}") return 0 @@ -331,7 +388,8 @@ def _cmd_start(argv: list[str]) -> int: import tempfile from pathlib import Path - (name, file), opts = _parse(argv, {"--yes", "--force", "--as", "--source-3mf", "--secs", "--bridge-max", "--no-bed-move"}, 2, + (name, file), opts = _parse(argv, {"--yes", "--force", "--as", "--source-3mf", "--secs", "--bridge-max", "--no-bed-move", + "--colour-delta-e", "--fix-colour", "--fix-max-delta-e"}, 2, "start") if "--yes" not in opts: raise UsageError( @@ -351,20 +409,11 @@ def _cmd_start(argv: list[str]) -> int: return _start_snapshot(name, file, original, local, source, opts) -def _start_snapshot(name: str, file: str, original: Path, local: Path, source: Path | None, - opts: dict[str, str]) -> int: - from printer_ops.base import UNKNOWN, PrinterCommError, PrinterRefused, file_sha256, safe_remote_name - from printer_ops.render import render_status - from printer_ops import bridge_max_for - from printer_ops.preflight import FAIL, run_preflight - from printer_ops.printops_checks import context_from_options - - remote = safe_remote_name(local, opts.get("--as")) # content-addressed: -.gcode - size = local.stat().st_size - adapter = _adapter(name, opts, write=True) +def _require_stored_file(adapter: Adapter, name: str, file: str, remote: str, size: int, + opts: dict[str, str]) -> None: + """Refuse unless the printer holds `remote` with exactly the local size.""" + from printer_ops.base import PrinterRefused - st = adapter.status() - _refuse_if_busy(name, st, "before preflight") stored = adapter.list_files(expect=(remote, size)).get(remote) if stored is None or stored.size != size: got = "absent" if stored is None else f"{stored.size} B" @@ -373,6 +422,12 @@ def _start_snapshot(name: str, file: str, original: Path, local: Path, source: P command="printer", remediation=[f"Upload it first: 3d printer upload {name} {file}" + (f" --as {opts['--as']}" if "--as" in opts else "")], ) + + +def _verify_remote_identity(adapter: Adapter, name: str, file: str, remote: str, local: Path) -> str: + """Check the stored file is the local one; returns the local sha256.""" + from printer_ops.base import PrinterRefused, file_sha256 + # Identity of what will be printed with what is checked below: the read-back hash where # the firmware can return the file, else the hash in the name plus the size. remote_hash = adapter.remote_sha256(remote) @@ -385,11 +440,20 @@ def _start_snapshot(name: str, file: str, original: Path, local: Path, source: P ) identity = "read-back sha256 matches" if remote_hash else "content hash in the file name + size" print(f"remote file {remote}: {identity}") + return local_hash + + +def _preflight_report(name: str, adapter: Adapter, st: PrinterStatus, original: Path, local: Path, + source: Path | None, opts: dict[str, str]) -> Any: + """Run and print the preflight on the snapshot; the report is relabelled with the original path.""" + from printer_ops import bridge_max_for + from printer_ops.colour_fix import DEFAULT_SLOT_DELTA_E, parse_delta_e + from printer_ops.preflight import check_bed, run_preflight + from printer_ops.printops_checks import context_from_options limits, limits_source = adapter.axis_limits() bridge, bridge_source = bridge_max_for(opts.get("--bridge-max"), adapter.device, command="printer") # the snapshot lives in a temp dir: NAME.3mf and NAME.slice-warnings.json sit next to the original - from printer_ops.preflight import check_bed # plus the PrintOps material gates of `3d print-preflight --printer NAME` (live 3d-api state # when configured, else fleet facts only) @@ -398,22 +462,41 @@ def _start_snapshot(name: str, file: str, original: Path, local: Path, source: P report = run_preflight(local, status=st, source_3mf=source, axis_limits=limits, limits_source=limits_source, trusted_macros=adapter.trusted_macros, origin=original, bridge_max=bridge, bridge_source=bridge_source, printops=printops, + colour_delta_e=parse_delta_e(opts.get("--colour-delta-e"), "--colour-delta-e", + DEFAULT_SLOT_DELTA_E, command="printer"), bed=check_bed(adapter.device, move="--no-bed-move" not in opts) if adapter.device is not None else None) report.file = f"{original} (snapshot)" print(report.render()) - if report.overall == FAIL: - if "--force" not in opts: - raise PrinterRefused("preflight FAIL; start refused", command="printer", - remediation=["Fix the cause of each FAIL line above (a gate's false positive is a bug: " - "file an issue and fix it).", - "Forcing needs a one-time Telegram approval, not a flag: see --force in " - "`3d printer --help`."]) - from printer_ops.force_hatch import require_force_approval - - forced = ", ".join(c.name for c in report.checks if c.status == FAIL) - approval = require_force_approval(printer=name, file=str(original), sha256=local_hash, checks=report.checks) - print(f"FORCED: starting despite preflight FAIL ({forced}); owner approved on Telegram ({approval})") + return report + + +def _require_preflight_pass(report: Any, name: str, original: Path, local_hash: str, remote: str, + opts: dict[str, str]) -> None: + """Refuse on a preflight FAIL unless --force is given and the owner approves it.""" + from printer_ops.base import PrinterRefused + from printer_ops.preflight import FAIL + + if report.overall != FAIL: + return + if "--force" not in opts: + raise PrinterRefused("preflight FAIL; start refused", command="printer", + remediation=["Fix the cause of each FAIL line above (a gate's false positive is a bug: " + "file an issue and fix it).", + "Forcing needs a one-time Telegram approval, not a flag: see --force in " + "`3d printer --help`."]) + from printer_ops.force_hatch import require_force_approval + + forced = ", ".join(c.name for c in report.checks if c.status == FAIL) + approval = require_force_approval(printer=name, file=str(original), sha256=local_hash, checks=report.checks, + remote_name=remote) + print(f"FORCED: starting despite preflight FAIL ({forced}); owner approved on Telegram ({approval})") + + +def _start_and_report(adapter: Adapter, name: str, remote: str) -> None: + """Send the start and print the state after it; any unreadable outcome is reported as UNKNOWN.""" + from printer_ops.base import UNKNOWN, PrinterCommError + from printer_ops.render import render_status _refuse_if_busy(name, adapter.status(), "state changed during preflight") dont_resend = [f"Do NOT resend blindly. Check: 3d printer status {name}"] @@ -440,9 +523,79 @@ def _start_snapshot(name: str, file: str, original: Path, local: Path, source: P command="printer", remediation=dont_resend, ) print(render_status(name, adapter, after)) + + +def _fix_colour_and_upload(name: str, original: Path, local: Path, source: Path | None, st: PrinterStatus, + adapter: Adapter, opts: dict[str, str]) -> None: + """start --fix-colour: rewrite ORIGINAL (and its project 3MF) to the colours the printer reports, + refresh the snapshot LOCAL from it and upload it: its hash, so its stored name, changed.""" + import shutil + + from printer_ops.base import safe_remote_name + from printer_ops.colour_fix import DEFAULT_FIX_MAX_DELTA_E, fix_to_loaded, parse_delta_e + + limit = parse_delta_e(opts.get("--fix-max-delta-e"), "--fix-max-delta-e", DEFAULT_FIX_MAX_DELTA_E, + command="printer") + fixed = fix_to_loaded(original, st, source, limit, command="printer") + if fixed is None: + print("--fix-colour: the loaded colours already equal the G-code's; nothing rewritten") + return + print("\n".join(fixed.lines())) + shutil.copyfile(original, local) + res = adapter.upload(local, safe_remote_name(local, opts.get("--as"))) + print(f"uploaded the colour-fixed file to {name} as {res.name}: {res.size} B, size verified on the printer") + + +def _start_snapshot(name: str, file: str, original: Path, local: Path, source: Path | None, + opts: dict[str, str]) -> int: + from printer_ops.base import safe_remote_name + + safe_remote_name(local, opts.get("--as")) # a name the printer would refuse fails before anything is written + adapter = _adapter(name, opts, write=True) + + st = adapter.status() + _refuse_if_busy(name, st, "before preflight") + if "--fix-colour" in opts: + _fix_colour_and_upload(name, original, local, source, st, adapter, opts) + remote = safe_remote_name(local, opts.get("--as")) # content-addressed: -.gcode + size = local.stat().st_size + _require_stored_file(adapter, name, file, remote, size, opts) + local_hash = _verify_remote_identity(adapter, name, file, remote, local) + report = _preflight_report(name, adapter, st, original, local, source, opts) + _require_preflight_pass(report, name, original, local_hash, remote, opts) + _start_and_report(adapter, name, remote) return 0 +_SLOT_PASS_THROUGH = {"--brand": "--vendor", "--colour": "--color"} # `slot set` spellings -> `printers filament set` + + +def _cmd_slot(argv: list[str]) -> int: + """`slot set NAME T --material M --color #RRGGBB [--brand B]` and `slot show NAME`: the head + filament record of the printer, through `3d printers filament` (ONE implementation of the write: + Adapter.set_head_filament, read back; refusals for non-`full` access, a running job, official RFID).""" + import re + + from printers_cli.filament import run as filament + + if not argv or argv[0] in ("-h", "--help", "help"): + print(USAGE) + return 0 if argv else 1 + action, rest = argv[0], argv[1:] + if action == "show": + return filament(["show", *rest]) + if action != "set": + raise UsageError(f"unknown slot action {action!r}; accepted: set, show", command="printer", + remediation=["3d printer slot set NAME T --material PETG --color '#RRGGBB'"]) + if len(rest) < 2 or rest[0].startswith("-") or rest[1].startswith("-"): + raise UsageError("slot set needs the printer and the head: slot set NAME T --material M --color #RRGGBB", + command="printer", remediation=["3d printer slot set u1 T3 --material PETG --color '#FEE5A5'"]) + name, head, options = rest[0], rest[1], rest[2:] + if not re.fullmatch(r"[Tt][0-9]+", head): + raise UsageError(f"{head!r} is not a head label: T as the printer shows it (U1: T1..T4)", command="printer") + return filament(["set", f"{name}/{head.upper()}", *(_SLOT_PASS_THROUGH.get(o, o) for o in options)]) + + def _cmd_cancel(argv: list[str]) -> int: from printer_ops.base import ACTIVE_JOB_STATES, PrinterRefused from printer_ops.render import render_status @@ -487,6 +640,81 @@ def _cmd_bed(argv: list[str]) -> int: return exec_tool(BED_DEPS, BED_TOOL, argv) +def _run_evidence(rest: list[str], name: str | None) -> int: + from printer_ops.evidence import run as evidence + + return evidence(rest) + + +def _run_watch(rest: list[str], name: str | None) -> int: + from printer_ops.watch_run import run as watch + + return watch(rest) + + +def _run_supervise(rest: list[str], name: str | None) -> int: + from printer_ops.supervise import run as supervise + + return supervise(rest) + + +def _run_guard(rest: list[str], name: str | None) -> int: + from printer_ops.guard import run as guard + + return guard(rest, vision_deps=VISION_DEPS) + + +def _run_agent(rest: list[str], name: str | None) -> int: + from printer_ops.printer_agent import run as agent + + return agent(rest) + + +def _run_doctor(rest: list[str], name: str | None) -> int: + from printers_cli import dispatch + + return dispatch(["doctor", *rest]) + + +def _run_todo(rest: list[str], name: str | None) -> int: + from printops.todo_cli import run as todo + + return todo(rest, printer=name) + + +def _run_subscriptions(sub: str) -> Callable[[list[str], str | None], int]: + """Handler for subscribe/unsubscribe/subscriptions: the module takes the subcommand word too.""" + def handler(rest: list[str], name: str | None) -> int: + from printer_ops.subscriptions import run as subscriptions + + return subscriptions(sub, rest) + return handler + + +# subcommand -> handler(rest, -n/--name value). Add new subcommands here. +_SUBCOMMANDS: dict[str, Callable[[list[str], str | None], int]] = { + "list": lambda rest, name: _cmd_list(rest), + "status": lambda rest, name: _cmd_status(rest), + "upload": lambda rest, name: _cmd_upload(rest), + "start": lambda rest, name: _cmd_start(rest), + "slot": lambda rest, name: _cmd_slot(rest), + "cancel": lambda rest, name: _cmd_cancel(rest), + "evidence": _run_evidence, + "watch": _run_watch, + "supervise": _run_supervise, + "vision": lambda rest, name: _cmd_vision(rest), + "guard": _run_guard, + "bed": lambda rest, name: _cmd_bed(rest), + "subscribe": _run_subscriptions("subscribe"), + "unsubscribe": _run_subscriptions("unsubscribe"), + "subscriptions": _run_subscriptions("subscriptions"), + "agent": _run_agent, + "doctor": _run_doctor, + "todo": _run_todo, + "issues": _run_todo, +} + + def run(argv: list[str]) -> int: if not argv: print(USAGE) @@ -502,57 +730,12 @@ def run(argv: list[str]) -> int: if name is not None and sub not in ("todo", "issues"): raise UsageError(f"-n/--name selects the printer for `todo`/`issues`; `{sub}` takes the printer as NAME", command="printer", remediation=[f"3d printer {sub} {name} …"]) - if sub == "list": - return _cmd_list(rest) - if sub == "status": - return _cmd_status(rest) - if sub == "upload": - return _cmd_upload(rest) - if sub == "start": - return _cmd_start(rest) - if sub == "cancel": - return _cmd_cancel(rest) - if sub == "evidence": - from printer_ops.evidence import run as evidence - - return evidence(rest) - if sub == "watch": - from printer_ops.watch_run import run as watch - - return watch(rest) - - if sub == "supervise": - from printer_ops.supervise import run as supervise - - return supervise(rest) - - if sub == "vision": - return _cmd_vision(rest) - if sub == "guard": - from printer_ops.guard import run as guard - - return guard(rest, vision_deps=VISION_DEPS) - if sub == "bed": - return _cmd_bed(rest) - if sub in ("subscribe", "unsubscribe", "subscriptions"): - from printer_ops.subscriptions import run as subscriptions - - return subscriptions(sub, rest) - if sub == "agent": - from printer_ops.printer_agent import run as agent - - return agent(rest) - if sub == "doctor": - from printers_cli import dispatch - - return dispatch(["doctor", *rest]) - if sub in ("todo", "issues"): - from printops.todo_cli import run as todo - - return todo(rest, printer=name) - raise UsageError(f"unknown subcommand '{sub}'", command="printer", - remediation=["Use list, status, upload, start, cancel, evidence, watch, supervise, vision, guard, bed, subscribe, unsubscribe, " - "subscriptions, agent, doctor or todo (issues) — see `3d printer --help`."]) + handler = _SUBCOMMANDS.get(sub) + if handler is None: + raise UsageError(f"unknown subcommand '{sub}'", command="printer", + remediation=["Use list, status, upload, start, slot, cancel, evidence, watch, supervise, vision, guard, bed, subscribe, unsubscribe, " + "subscriptions, agent, doctor or todo (issues) — see `3d printer --help`."]) + return handler(rest, name) def _printer_name(argv: list[str]) -> tuple[str | None, list[str]]: @@ -574,7 +757,7 @@ def _printer_name(argv: list[str]) -> tuple[str | None, list[str]]: COMMAND = Command( name="printer", group="SLICING", - summary="drive configured printers: list / status / upload / start --yes / cancel --yes / evidence / watch / vision / subscribe / agent / todo", + summary="drive configured printers: list / status / upload / start --yes / slot set / cancel --yes / evidence / watch / vision / subscribe / agent / todo", usage=USAGE, run=run, ) diff --git a/tests/e2e/test_printer_colour_workflows.py b/tests/e2e/test_printer_colour_workflows.py new file mode 100644 index 00000000..8df8109e --- /dev/null +++ b/tests/e2e/test_printer_colour_workflows.py @@ -0,0 +1,168 @@ +"""E2E stories through `bin/3d printer` for the slot colour: `start --fix-colour` (rewrite, upload the +fixed file, start) and `slot set` / `slot show` (align the printer's head record with the file) on a +loopback fake U1; never a real printer.""" + +from __future__ import annotations + +import json + +from pathlib import Path + +from colour_fix_fixtures import LOADED, SLICED, load_beige_petg, write_plate +from fleet_fixtures import u1_entry, write_fleet +from printer_fakes import FakeMoonraker, u1_status, write_devices + +from .workflow_helper import hatch_requests, run_cli, run_cli_with_hatch + + +def slot_line(stdout: str) -> str: + return next(line for line in stdout.splitlines() if "slot T3" in line) + + +def test_printer_start_fix_colour_needs_no_force_for_the_slot_colour( + tmp_path: Path, +) -> None: + """The slot colour no longer blocks: `start --fix-colour` rewrites, uploads the fixed file and starts it; + only the unrelated thumbnail FAIL of this bare fixture needs the (approved) force.""" + moon = FakeMoonraker() + load_beige_petg(moon) + g = write_plate(tmp_path) + with moon.serve(): + write_devices(tmp_path / "xdg-config", moon=moon) + started = run_cli_with_hatch( + tmp_path, + "printer", + "start", + "u1", + str(g), + "--yes", + "--fix-colour", + "--force", + ) + + assert started.returncode == 0, started.stdout + started.stderr + assert ( + f"{SLICED} -> {LOADED}" in started.stdout + and "uploaded the colour-fixed file" in started.stdout + ) + assert slot_line(started.stdout).split()[0] == "PASS" + (request,) = hatch_requests(tmp_path) + failing = json.dumps(request["context"]) + assert "slot T3" not in failing and "- thumbnails:" in failing + assert [r.path for r in moon.writes()] == [ + "/server/files/upload", + "/printer/print/start", + ] + assert moon.contents[moon.writes()[1].query["filename"][0]] == g.read_bytes() + + +def test_printer_slot_set_dry_run_then_write_aligns_the_printer_with_the_file( + tmp_path: Path, +) -> None: + """The other direction: the printer's record is set to the file's colour (U1 SET_PRINT_FILAMENT_CONFIG).""" + moon = FakeMoonraker() + with moon.serve(): + env = { + "THREED_FLEET": str(write_fleet(tmp_path / "fleet.yaml", u1_entry(moon))) + } + dry = run_cli( + tmp_path, + "printer", + "slot", + "set", + "u1", + "T4", + "--material", + "PETG", + "--color", + SLICED, + "--brand", + "Elegoo", + "--dry-run", + env_extra=env, + ) + assert moon.scripts == [] + wrote = run_cli( + tmp_path, + "printer", + "slot", + "set", + "u1", + "T4", + "--material", + "PETG", + "--color", + SLICED, + "--brand", + "Elegoo", + env_extra=env, + ) + shown = run_cli(tmp_path, "printer", "slot", "show", "u1", env_extra=env) + moon.status = u1_status("printing", "big.gcode") + busy = run_cli( + tmp_path, + "printer", + "slot", + "set", + "u1", + "T4", + "--material", + "PLA", + "--color", + "#FFFFFF", + env_extra=env, + ) + + assert dry.returncode == 0, dry.stderr + assert "DRY RUN" in dry.stdout and SLICED in dry.stdout + assert wrote.returncode == 0, wrote.stderr + assert f"u1/T4 now shows PETG {SLICED} Elegoo Basic (read back)" in wrote.stdout + assert any( + f"PETG {SLICED}" in line and line.lstrip().startswith("T4") + for line in shown.stdout.splitlines() + ) + assert busy.returncode == 1 and "never done during a job" in busy.stderr + assert len(moon.scripts) == 1 and moon.scripts[0].startswith( + "SET_PRINT_FILAMENT_CONFIG CONFIG_EXTRUDER=3 " + ) + + +def test_slot_set_refuses_an_offline_printer_and_a_missing_head(tmp_path: Path) -> None: + moon = FakeMoonraker() + with moon.serve(): + env = { + "THREED_FLEET": str( + write_fleet(tmp_path / "fleet.yaml", u1_entry(moon, access="read-only")) + ) + } + readonly = run_cli( + tmp_path, + "printer", + "slot", + "set", + "u1", + "T4", + "--material", + "PETG", + "--color", + SLICED, + env_extra=env, + ) + no_head = run_cli( + tmp_path, + "printer", + "slot", + "set", + "u1", + "--material", + "PETG", + "--color", + SLICED, + env_extra=env, + ) + + assert readonly.returncode == 1 and "read-only" in readonly.stderr + assert ( + no_head.returncode == 2 and "needs the printer and the head" in no_head.stderr + ) + assert moon.scripts == [] diff --git a/tests/test_printer_colour_commands.py b/tests/test_printer_colour_commands.py new file mode 100644 index 00000000..b0cbc69a --- /dev/null +++ b/tests/test_printer_colour_commands.py @@ -0,0 +1,365 @@ +"""`3d print-preflight --colour-delta-e/--fix`, `3d printer start --fix-colour` and `3d printer slot set` +against a loopback fake U1 (tests/printer_fakes.py); the printer is never real. Issue +https://github.com/alex-mextner/3d-cli/issues/728.""" + +from __future__ import annotations + +import json +from collections.abc import Iterator +from pathlib import Path + +import pytest +from colour_fix_fixtures import LOADED, SLICED, gcode_text, load_beige_petg, write_plate +from fleet_fixtures import u1_entry, write_fleet +from printer_fakes import FakeMoonraker, u1_status, write_devices + +from errors import GateFailure, InvalidArgument, UsageError +from printer_ops import preflight +from printer_ops.base import PrinterRefused, safe_remote_name +from slicing import slice_warnings as sw + + +@pytest.fixture() +def moon(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Iterator[FakeMoonraker]: + monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "xdg")) + monkeypatch.chdir(tmp_path) + fake = FakeMoonraker() + load_beige_petg(fake) + with fake.serve(): + write_devices(tmp_path / "xdg", moon=fake) + yield fake + + +@pytest.fixture() +def gates_ok(monkeypatch: pytest.MonkeyPatch) -> None: + """The thumbnail / overhang / slicer-warning gates have their own tests: pin them to PASS.""" + monkeypatch.setattr( + preflight, + "check_thumbnails", + lambda p: preflight.Check("thumbnails", "PASS", "stub"), + ) + monkeypatch.setattr( + preflight, + "check_overhangs", + lambda p, *_: preflight.Check("overhangs", "PASS", "stub"), + ) + monkeypatch.setattr( + preflight, + "check_slice_warnings", + lambda o, g: preflight.Check("slicer warn", "PASS", "stub"), + ) + + +def preflight_cli(*argv: str) -> int: + from commands.print_preflight import run + + return run(list(argv)) + + +def printer(*argv: str) -> int: + from commands.printer import run + + return run(list(argv)) + + +# ---- print-preflight --------------------------------------------------------------------- + + +def test_preflight_passes_the_beige_pair_and_shows_the_distance( + moon: FakeMoonraker, + tmp_path: Path, + gates_ok: None, + capsys: pytest.CaptureFixture[str], +) -> None: + g = write_plate(tmp_path) + assert preflight_cli(str(g), "--printer", "u1") == 0 + out = capsys.readouterr().out + assert "slot T3" in out and "ΔE 6.4" in out and f"G-code {SLICED}" in out + assert moon.writes() == [] + + +def test_preflight_threshold_option_and_validation( + moon: FakeMoonraker, + tmp_path: Path, + gates_ok: None, + capsys: pytest.CaptureFixture[str], +) -> None: + g = write_plate(tmp_path) + with pytest.raises(GateFailure): + preflight_cli(str(g), "--printer", "u1", "--colour-delta-e", "5") + assert ( + f"colour: G-code {SLICED}, loaded {LOADED} (ΔE 6.4 > 5)" + in capsys.readouterr().out + ) + with pytest.raises(InvalidArgument): + preflight_cli(str(g), "--printer", "u1", "--colour-delta-e", "0") + + +def test_preflight_fails_a_different_colour_and_a_wrong_material( + moon: FakeMoonraker, + tmp_path: Path, + gates_ok: None, + capsys: pytest.CaptureFixture[str], +) -> None: + other = tmp_path / "other.gcode" + other.write_text(gcode_text("#0000FF"), encoding="utf-8") + with pytest.raises(GateFailure): + preflight_cli(str(other), "--printer", "u1") + assert "G-code #0000FF, loaded #FEE5A5 (ΔE " in capsys.readouterr().out + petg_as_pla = tmp_path / "pla.gcode" + petg_as_pla.write_text(gcode_text(SLICED, "PLA"), encoding="utf-8") + with pytest.raises(GateFailure): + preflight_cli(str(petg_as_pla), "--printer", "u1") + assert "material: G-code PLA, loaded PETG" in capsys.readouterr().out + + +def test_preflight_fix_rewrites_then_passes_and_prints_old_and_new_sha( + moon: FakeMoonraker, + tmp_path: Path, + gates_ok: None, + capsys: pytest.CaptureFixture[str], +) -> None: + g = write_plate(tmp_path) + old = sw.sha256_file(g) + assert preflight_cli(str(g), "--printer", "u1", "--fix") == 0 + cap = capsys.readouterr() + assert ( + f"{SLICED} -> {LOADED}" in cap.err + and old in cap.err + and sw.sha256_file(g) in cap.err + ) + assert f"G-code {LOADED}" not in cap.out # exact match now: no note + assert "slot T3" in cap.out and "PASS" in cap.out + assert f"; filament_colour = #000000;#808080;#1B5E20;{LOADED}" in g.read_text( + encoding="utf-8" + ) + assert moon.writes() == [] # --fix writes files, never the printer + + assert preflight_cli(str(g), "--printer", "u1", "--fix") == 0 + assert "already equal" in capsys.readouterr().err + + +def test_preflight_fix_json_stays_machine_readable( + moon: FakeMoonraker, + tmp_path: Path, + gates_ok: None, + capsys: pytest.CaptureFixture[str], +) -> None: + g = write_plate(tmp_path) + assert preflight_cli(str(g), "--printer", "u1", "--fix", "--json") == 0 + cap = capsys.readouterr() + assert any(c["name"] == "slot T3" for c in json.loads(cap.out)["checks"]) + assert "sha256 old" in cap.err + + +def test_preflight_fix_refuses_another_colour_and_needs_a_live_printer( + moon: FakeMoonraker, tmp_path: Path, gates_ok: None +) -> None: + g = tmp_path / "blue.gcode" + g.write_text(gcode_text("#0000FF"), encoding="utf-8") + before = g.read_bytes() + with pytest.raises(PrinterRefused, match="another colour"): + preflight_cli(str(g), "--printer", "u1", "--fix") + assert g.read_bytes() == before + # widening the limit is an explicit, per-run decision + assert preflight_cli(str(g), "--printer", "u1", "--fix", "--fix-max-delta-e", "100") == 0 + assert LOADED in g.read_text(encoding="utf-8") + g.write_bytes(before) + with pytest.raises(UsageError, match="--fix needs"): + preflight_cli(str(g), "--fix") + with pytest.raises(UsageError, match="--fix needs"): + preflight_cli(str(g), "--printer", "u1", "--offline", "--fix") + assert g.read_bytes() == before + + +# ---- printer start --fix-colour ---------------------------------------------------------- + + +def test_start_fix_colour_rewrites_uploads_the_fixed_file_and_starts( + moon: FakeMoonraker, + tmp_path: Path, + gates_ok: None, + capsys: pytest.CaptureFixture[str], +) -> None: + g = write_plate(tmp_path) + old_name = safe_remote_name(g) + assert printer("start", "u1", str(g), "--yes", "--fix-colour") == 0 + out = capsys.readouterr().out + assert f"{SLICED} -> {LOADED}" in out and "uploaded the colour-fixed file" in out + new_name = safe_remote_name(g) + assert ( + new_name != old_name and new_name in moon.files and old_name not in moon.files + ) + assert ( + moon.contents[new_name] == g.read_bytes() + and LOADED.encode() in moon.contents[new_name] + ) + assert [(r.path, r.query.get("filename")) for r in moon.writes()] == [ + ("/server/files/upload", None), + ("/printer/print/start", [new_name]), + ] + assert "slot T3" in out and "start acknowledged by u1" in out + + +def test_start_fix_colour_refuses_another_colour_before_writing_anything( + moon: FakeMoonraker, tmp_path: Path, gates_ok: None +) -> None: + g = tmp_path / "blue.gcode" + g.write_text(gcode_text("#0000FF"), encoding="utf-8") + before = g.read_bytes() + with pytest.raises(PrinterRefused, match="another colour"): + printer("start", "u1", str(g), "--yes", "--fix-colour") + assert g.read_bytes() == before and moon.writes() == [] + + +def test_start_without_fix_colour_passes_the_beige_without_rewriting( + moon: FakeMoonraker, + tmp_path: Path, + gates_ok: None, + capsys: pytest.CaptureFixture[str], +) -> None: + g = write_plate(tmp_path) + moon.put(safe_remote_name(g), g.read_bytes()) + before = g.read_bytes() + assert printer("start", "u1", str(g), "--yes") == 0 + assert "ΔE 6.4" in capsys.readouterr().out and g.read_bytes() == before + + +def test_start_fix_colour_is_refused_while_printing( + moon: FakeMoonraker, tmp_path: Path, gates_ok: None +) -> None: + g = write_plate(tmp_path) + before = g.read_bytes() + moon.status = u1_status("printing", "x.gcode") + load_beige_petg(moon) + with pytest.raises(PrinterRefused, match="printing"): + printer("start", "u1", str(g), "--yes", "--fix-colour") + assert g.read_bytes() == before and moon.writes() == [] + + +# ---- printer slot set -------------------------------------------------------------------- + + +@pytest.fixture() +def fleet_moon( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> Iterator[FakeMoonraker]: + fake = FakeMoonraker() + with fake.serve(): + monkeypatch.setenv( + "THREED_FLEET", str(write_fleet(tmp_path / "fleet.yaml", u1_entry(fake))) + ) + monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "xdg")) + monkeypatch.chdir(tmp_path) + yield fake + + +def test_slot_set_writes_the_head_and_reads_it_back( + fleet_moon: FakeMoonraker, capsys: pytest.CaptureFixture[str] +) -> None: + assert ( + printer( + "slot", + "set", + "u1", + "t4", + "--material", + "PETG", + "--color", + "fee5a5", + "--brand", + "Elegoo", + ) + == 0 + ) + assert ( + "u1/T4 now shows PETG #FEE5A5 Elegoo Basic (read back)" + in capsys.readouterr().out + ) + (script,) = fleet_moon.scripts + assert ( + script.startswith("SET_PRINT_FILAMENT_CONFIG CONFIG_EXTRUDER=3 ") + and 'VENDOR="Elegoo"' in script + ) + assert "FILAMENT_COLOR_RGBA=FEE5A5FF" in script and 'FILAMENT_TYPE="PETG"' in script + + +def test_slot_set_dry_run_sends_nothing_and_predicts_refusals( + fleet_moon: FakeMoonraker, capsys: pytest.CaptureFixture[str] +) -> None: + assert ( + printer( + "slot", + "set", + "u1", + "T4", + "--material", + "PETG", + "--color", + "#FEE5A5", + "--dry-run", + ) + == 0 + ) + out = capsys.readouterr().out + assert "DRY RUN" in out and "FEE5A5" in out + assert fleet_moon.writes() == [] + + fleet_moon.status = u1_status("printing", "x.gcode") + with pytest.raises(GateFailure): + printer( + "slot", + "set", + "u1", + "T4", + "--material", + "PETG", + "--color", + "#FEE5A5", + "--dry-run", + ) + assert "WOULD BE REFUSED" in capsys.readouterr().out and fleet_moon.writes() == [] + + +def test_slot_set_refuses_a_running_job_a_read_only_printer_and_bad_arguments( + tmp_path: Path, fleet_moon: FakeMoonraker, monkeypatch: pytest.MonkeyPatch +) -> None: + fleet_moon.status = u1_status("printing", "x.gcode") + with pytest.raises(PrinterRefused, match="never done during a job"): + printer("slot", "set", "u1", "T4", "--material", "PETG", "--color", "#FEE5A5") + assert fleet_moon.scripts == [] + + monkeypatch.setenv( + "THREED_FLEET", + str( + write_fleet(tmp_path / "ro.yaml", u1_entry(fleet_moon, access="read-only")) + ), + ) + with pytest.raises(PrinterRefused, match="read-only"): + printer( + "slot", + "set", + "u1", + "T4", + "--material", + "PETG", + "--color", + "#FEE5A5", + "--dry-run", + ) + assert fleet_moon.scripts == [] + + with pytest.raises(UsageError, match="not a head label"): + printer("slot", "set", "u1", "4", "--material", "PETG", "--color", "#FEE5A5") + with pytest.raises(UsageError, match="needs the printer and the head"): + printer("slot", "set", "u1") + with pytest.raises(UsageError, match="unknown slot action"): + printer("slot", "wipe", "u1") + + +def test_slot_show_lists_the_heads( + fleet_moon: FakeMoonraker, capsys: pytest.CaptureFixture[str] +) -> None: + assert printer("slot", "show", "u1") == 0 + out = capsys.readouterr().out + assert "T4" in out and "PLA" in out and fleet_moon.writes() == [] From 612148cadbcfe3f8c236887b238ac8d18c858044 Mon Sep 17 00:00:00 2001 From: alex-mextner <111590326+alex-mextner@users.noreply.github.com> Date: Thu, 5 Nov 2026 01:16:02 +0100 Subject: [PATCH 6/8] test(printer): e2e for a start --force approval that Telegram could not post (Refs #728) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Through bin/3d with the hatch answering `ask-forward not-posted … reason=send-failed`: four requests, then a refusal that names the stored file and the printer screen; nothing started. --- .../e2e/test_printer_force_retry_workflows.py | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 tests/e2e/test_printer_force_retry_workflows.py diff --git a/tests/e2e/test_printer_force_retry_workflows.py b/tests/e2e/test_printer_force_retry_workflows.py new file mode 100644 index 00000000..0a2de8d8 --- /dev/null +++ b/tests/e2e/test_printer_force_retry_workflows.py @@ -0,0 +1,44 @@ +"""E2E: a `3d printer start --force` approval that tg-ctl could not post to Telegram +(`ask-forward not-posted … reason=send-failed`, issue #728) is asked again, then refused with the way to +start from the printer's own screen. The hatch is a test double; the printer a loopback fake.""" + +from __future__ import annotations + +from pathlib import Path + +from colour_fix_fixtures import load_beige_petg, write_plate +from printer_fakes import FakeMoonraker, write_devices + +from .workflow_helper import hatch_requests, run_cli, run_cli_with_hatch + + +def test_force_approval_that_telegram_could_not_post_is_retried_then_points_to_the_printer_screen( + tmp_path: Path, +) -> None: + """tg-ctl said `ask-forward not-posted … reason=send-failed`: the CLI asks again (4 requests in all), + then refuses with how to start from the printer's own screen; nothing is started.""" + moon = FakeMoonraker() + load_beige_petg(moon) + g = write_plate(tmp_path) + with moon.serve(): + write_devices(tmp_path / "xdg-config", moon=moon) + assert run_cli(tmp_path, "printer", "upload", "u1", str(g)).returncode == 0 + refused = run_cli_with_hatch( + tmp_path, + "printer", + "start", + "u1", + str(g), + "--yes", + "--force", + verdict="send-failed", + ) + + assert refused.returncode == 1, refused.stdout + refused.stderr + assert "could not be posted to Telegram after 4 attempts" in refused.stderr + assert ( + "Start from the printer screen" in refused.stderr and "plate-" in refused.stderr + ) + assert refused.stderr.count("asking again in 0 s") == 3 + assert len(hatch_requests(tmp_path)) == 4 + assert "/printer/print/start" not in [r.path for r in moon.writes()] From 4f501dd46079eb13bedd0b658c9c5c1982c724e2 Mon Sep 17 00:00:00 2001 From: alex-mextner <111590326+alex-mextner@users.noreply.github.com> Date: Thu, 5 Nov 2026 16:15:30 +0100 Subject: [PATCH 7/8] test(thumbnails): user-workflow docstrings for the three e2e stories CI's story check names (Refs #722) --- tests/e2e/test_thumbnail_layers_workflows.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/e2e/test_thumbnail_layers_workflows.py b/tests/e2e/test_thumbnail_layers_workflows.py index b25ffef6..c9bc3a20 100644 --- a/tests/e2e/test_thumbnail_layers_workflows.py +++ b/tests/e2e/test_thumbnail_layers_workflows.py @@ -70,6 +70,7 @@ def test_slice_output_has_every_thumbnail_without_a_separate_step( def test_slice_dry_run_keeps_nothing_and_renders_nothing(tmp_path: Path) -> None: + """A user previews a slice with --dry-run: no G-code is written and no thumbnail is drawn or announced.""" model = tmp_path / "part.stl" model.write_text("solid x\nendsolid x\n") dry = run_cli( @@ -86,6 +87,8 @@ def test_slice_dry_run_keeps_nothing_and_renders_nothing(tmp_path: Path) -> None def test_slice_of_a_3mf_project_asks_the_slicer_for_the_sizes_and_leaves_the_input_alone( tmp_path: Path, ) -> None: + """A user slices a project 3MF: the slicer is asked for the thumbnail sizes through a temporary copy + and the 3MF on disk stays byte for byte as it was.""" project = plate_project(tmp_path / "p.3mf") before = project.read_bytes() slicer = tmp_path / "spy-slicer" @@ -159,6 +162,7 @@ def test_upload_adds_the_missing_thumbnails_and_the_printer_receives_them( def test_upload_of_a_complete_gcode_is_untouched_and_silent(tmp_path: Path) -> None: + """A user uploads a G-code that already has its previews: the file is not touched, nothing is announced.""" gcode = complete(tmp_path / "part.gcode") before = gcode.read_bytes() moon = FakeMoonraker() @@ -225,6 +229,7 @@ def test_gcode_thumbnails_add_updates_the_plate_pictures_of_a_project( def test_help_documents_the_layers(tmp_path: Path) -> None: + """A user reads the three --help pages and finds where thumbnails are added and how --fix does it.""" thumbs = run_cli(tmp_path, "gcode-thumbnails", "--help") flight = run_cli(tmp_path, "print-preflight", "--help") printer = run_cli(tmp_path, "printer", "--help") From 659302f5523a3969630cbce6224a25549526285c Mon Sep 17 00:00:00 2001 From: alex-mextner <111590326+alex-mextner@users.noreply.github.com> Date: Thu, 5 Nov 2026 17:26:43 +0100 Subject: [PATCH 8/8] test(printer): user-workflow docstrings for two colour e2e stories (Refs #728) --- tests/e2e/test_print_preflight_colour_workflows.py | 2 ++ tests/e2e/test_printer_colour_workflows.py | 1 + 2 files changed, 3 insertions(+) diff --git a/tests/e2e/test_print_preflight_colour_workflows.py b/tests/e2e/test_print_preflight_colour_workflows.py index 1d3be299..b2c9285b 100644 --- a/tests/e2e/test_print_preflight_colour_workflows.py +++ b/tests/e2e/test_print_preflight_colour_workflows.py @@ -58,6 +58,8 @@ def test_preflight_accepts_the_same_beige_and_prints_the_distance( def test_preflight_still_fails_a_wrong_material_and_a_different_colour( tmp_path: Path, ) -> None: + """A PLA file on a PETG head and a blue file on a beige head still FAIL; `--fix` refuses to rewrite across + another colour and leaves the file as it was.""" moon = FakeMoonraker() load_beige_petg(moon) pla = tmp_path / "pla.gcode" diff --git a/tests/e2e/test_printer_colour_workflows.py b/tests/e2e/test_printer_colour_workflows.py index 57f5360f..c7fe05b9 100644 --- a/tests/e2e/test_printer_colour_workflows.py +++ b/tests/e2e/test_printer_colour_workflows.py @@ -117,6 +117,7 @@ def test_printer_slot_set_dry_run_then_write_aligns_the_printer_with_the_file( def test_slot_set_refuses_an_offline_printer_and_a_missing_head(tmp_path: Path) -> None: + """The owner tries `slot set` on a read-only printer and on a head that is not there: both are refused.""" moon = FakeMoonraker() with moon.serve(): env = {