From b277ca48aac46ff034b783ceae6644f371507c04 Mon Sep 17 00:00:00 2001 From: Dominic Gundel Date: Sat, 17 Oct 2026 11:22:43 +0200 Subject: [PATCH] Grotti 2.2.6: Wand-Vorschau, dichterer 3D-Pfad, ASCII-Dateinamen. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Kacheln zeigen Moonraker-Thumbnails; Toolpath-Thinning behält ganze Schichten; Nicht-ASCII-Namen vermeiden 10115 beim Senden. --- CHANGELOG.md | 16 ++++ docs/Anleitung-Stoerungen.md | 4 + docs/internal/Chargen-Etiketten-Viewer.md | 2 +- docs/internal/Senden-Queue.md | 2 + grotti/jobs.py | 2 + grotti/moonraker.py | 47 +++++++++-- grotti/toolpath.py | 99 ++++++++++++++++------- grotti/version.py | 2 +- tests/test_moonraker_names.py | 25 ++++++ tests/test_toolpath.py | 55 +++++++++++++ web/app.py | 5 +- web/static/gcode-viewer.js | 4 +- web/static/grotti.css | 37 ++++++++- web/templates/dateien.html | 4 +- web/templates/layout.html | 2 +- web/templates/wand.html | 46 +++++++++-- 16 files changed, 302 insertions(+), 50 deletions(-) create mode 100644 tests/test_moonraker_names.py create mode 100644 tests/test_toolpath.py diff --git a/CHANGELOG.md b/CHANGELOG.md index c07e08d..e561227 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,22 @@ Alle Änderungen an Grotti. Format nach [Keep a Changelog](https://keepachangelo ## [Unreleased] +### Neu + +- Wand-Kacheln zeigen das Druck-Vorschaubild (Moonraker-Thumbnail) in der Job-Zeile. + +### Behoben + +- 3D-Pfad wirkte gesprenkelt: Ausdünnen behält ganze Schichten dicht (statt Stichproben entlang der Bahn); keine Parallel-Offsets. +- Toolpath-API mit `Cache-Control: no-store`, damit der Browser keinen alten Pfad behält. +- Senden: Dateinamen mit Nicht-ASCII (z. B. CJK) werden für Moonraker/Rinkhals auf ASCII normalisiert — sonst Fehler 10115 „gcode … not found“. + +## [2.2.2] – 2026-10-15 + +### Behoben + +- 3D-Pfad: bei großen G-codes zerbrach das Ausdünnen die Segment-Paare — Viewer zeigte nur Objekt-Umrisse, keinen Druckfortschritt (grün/grau). + ## [2.2.1] – 2026-10-15 ### Geändert diff --git a/docs/Anleitung-Stoerungen.md b/docs/Anleitung-Stoerungen.md index 62e30cd..f9f4d5c 100644 --- a/docs/Anleitung-Stoerungen.md +++ b/docs/Anleitung-Stoerungen.md @@ -45,6 +45,10 @@ Im Cockpit (nur bei aktivem Druck/Heizen). Danach oft **Strom aus und wieder an* Oft noch Verarbeitung auf dem Drucker. In der Sidebar unter **Senden** den Fortschritt beobachten. Nach einem Grotti-Update den Dienst einmal neu starten. +## „gcode … not found“ (10115) + +Oft Dateinamen mit Nicht-ASCII-Zeichen (z. B. Chinesisch). Grotti normalisiert den Namen beim Senden auf ASCII. Nochmal senden; in der Bibliothek darf der Originalname bleiben. + ## Wo nachschauen - Sidebar: Alerts, Läuft, Wartet, Senden diff --git a/docs/internal/Chargen-Etiketten-Viewer.md b/docs/internal/Chargen-Etiketten-Viewer.md index 1b5d9bd..af2482f 100644 --- a/docs/internal/Chargen-Etiketten-Viewer.md +++ b/docs/internal/Chargen-Etiketten-Viewer.md @@ -12,7 +12,7 @@ Etikett: `/etikett/`, Druck über den Browser. QR per lokalem `qrcode.min.js ## 3D-Pfad -`GET /api/v1/dateien//pfad` liefert einen gedünnten Toolpath (bis ca. 120 000 Punkte, plus `layer_breaks`, Cache-`version` 5) aus dem lokalen G-code. Die Dateien-Seite und das Cockpit zeichnen ihn mit Three.js. +`GET /api/v1/dateien//pfad` liefert einen gedünnten Toolpath (bis ca. 220 000 Punkte, plus `layer_breaks`, Cache-`version` 8) aus dem lokalen G-code. Thinning behält ganze Z-Höhen dicht und lässt nur ganze Schichten weg (sonst gesprenkelte Wände). Die Dateien-Seite und das Cockpit zeichnen ihn mit Three.js. Darstellung: nur Extrusionswege. Preview = orange. Live: gedruckt leicht verdickt grün, offen grau; Schnitt nach `progress`. Parser akzeptiert `E.39`/`Z.6`. Bett: Gitter 10 mm, mm-Sprites, Legende. diff --git a/docs/internal/Senden-Queue.md b/docs/internal/Senden-Queue.md index e5bc88f..08179ed 100644 --- a/docs/internal/Senden-Queue.md +++ b/docs/internal/Senden-Queue.md @@ -20,6 +20,8 @@ Ab 12.10.2026: Start legt Hintergrund-Aufträge an (`pending` → `running` → `watch_dir` in `config.json` zeigt auf den Slice-Ordner der Bibliothek. Ohne Ordner geht Hochladen über die Dateien-Seite trotzdem. +**Dateinamen:** Moonraker-Multipart nutzt nur noch einen ASCII-`filename` (`moonraker.safe_upload_name`). Früher standen Nicht-ASCII-Zeichen in `filename*=UTF-8''…`, während `filename=` Unterstriche bekam — gklib speicherte A, Start suchte B → **10115 not found**. + ## ACE / `.acm` Drucker mit ACE/MMU-Plätzen (`mmu.gate_color` / `gate_material`, Grotti-`slots`) brauchen vor `print/start` eine Sidecar-Datei `*.gcode.acm`. In der UI unter Optionen → ACE-Zuordnung (Datei-Farbe → Platz). Ohne passende `.acm` lehnt der Drucker oft mit „cannot parse the file“ ab — Details hier, nicht als Banner in der App: diff --git a/grotti/jobs.py b/grotti/jobs.py index 6784b4e..15617b2 100644 --- a/grotti/jobs.py +++ b/grotti/jobs.py @@ -374,6 +374,8 @@ def send_file( progress("vorbereiten", 30, "Datei vorbereiten…") filename, content, plate = library.printer_file(row) + # CJK/Unicode im Namen → Rinkhals 10115 not found (Upload ≠ Start-Name) + filename = moonraker.safe_upload_name(filename) ace_tools = [item.get("paint_index") for item in mapping] if mapping else None if (row.get("kind") or "") == "gcode" and str(filename).endswith(".gcode"): content = prepare_rinkhals_gcode(content, tools=ace_tools) diff --git a/grotti/moonraker.py b/grotti/moonraker.py index 657c053..3545ed6 100644 --- a/grotti/moonraker.py +++ b/grotti/moonraker.py @@ -165,18 +165,53 @@ def post_json(host, path, payload, timeout=20): raise PrinterError("Der Drucker ist nicht erreichbar.") from exc +def safe_upload_name(filename): + """Dateiname für Moonraker/Rinkhals/gklib — nur ASCII. + + `filename` vs. `filename*` mit CJK führte zu Upload unter `_` und Start + unter dem Originalnamen → Fehler 10115 „gcode … not found“. + """ + raw = (filename or "").replace("\\", "/") + parts = [part for part in raw.split("/") if part] + base = parts[-1] if parts else "job.gcode" + out = [] + for ch in base: + if ch.isascii() and ch not in '"\\': + out.append(ch if ch not in " \t" else "_") + else: + out.append("_") + text = "".join(out) + while "__" in text: + text = text.replace("__", "_") + while "_-" in text or "-_" in text: + text = text.replace("_-", "-").replace("-_", "-") + text = text.strip("._-") or "job" + lower = text.lower() + if not (lower.endswith(".gcode") or lower.endswith(".gcode.3mf") or lower.endswith(".3mf") or lower.endswith(".acm")): + # Suffix vom Original behalten, falls nur der Stamm zerstört wurde + if base.lower().endswith(".gcode.3mf"): + text += ".gcode.3mf" + elif base.lower().endswith(".gcode"): + text += ".gcode" + elif base.lower().endswith(".acm"): + text += ".acm" + if parts: + parts[-1] = text + return "/".join(parts) + return text + + def multipart(filename, content): boundary = "----GrottiBoundary7f3a" - ascii_name = "".join(ch if ch.isascii() and ch not in '"\\' else "_" for ch in filename) or "job.gcode" - quoted = urllib.parse.quote(filename) + name = safe_upload_name(filename) fields = ( "--%s\r\nContent-Disposition: form-data; name=\"root\"\r\n\r\ngcodes\r\n" % boundary ).encode("ascii") head = ( "--%s\r\n" - "Content-Disposition: form-data; name=\"file\"; filename=\"%s\"; filename*=UTF-8''%s\r\n" + "Content-Disposition: form-data; name=\"file\"; filename=\"%s\"\r\n" "Content-Type: application/octet-stream\r\n\r\n" - ) % (boundary, ascii_name, quoted) + ) % (boundary, name) tail = ("\r\n--%s--\r\n" % boundary).encode("ascii") return boundary, fields + head.encode("ascii") + content + tail @@ -217,6 +252,7 @@ def upload_timeout(size): def upload(host, filename, content, on_progress=None): + filename = safe_upload_name(filename) boundary, body = multipart(filename, content) stream = _UploadBody(body, on_progress=on_progress) request = urllib.request.Request( @@ -236,7 +272,8 @@ def upload(host, filename, content, on_progress=None): except (urllib.error.URLError, TimeoutError) as exc: raise PrinterError("Hochladen ist fehlgeschlagen.") from exc item = (payload.get("result") or {}).get("item") or {} - return item.get("path") or filename + # Immer denselben ASCII-Namen nutzen, den wir hochgeladen haben + return safe_upload_name(item.get("path") or filename) def start_print(host, filename, bed_leveling=False, ams_settings=None): diff --git a/grotti/toolpath.py b/grotti/toolpath.py index c8e452b..2935977 100644 --- a/grotti/toolpath.py +++ b/grotti/toolpath.py @@ -8,8 +8,8 @@ MOVE = re.compile(r"^(?:G0|G00|G1|G01)\b(.*)$", re.IGNORECASE) # Auch E.39 / Z.6 ohne führende Null (Anycubic/Prusa-Stil) AXIS = re.compile(r"([XYZE])\s*(-?\d*\.?\d+)", re.IGNORECASE) -# Cache-/Viewer-Version: Segment-IDs, kein Sehnen-Thinning -TOOLPATH_VERSION = 5 +# Cache-/Viewer-Version: ganze Z-Höhen dicht behalten (keine gesprenkelten Stichproben) +TOOLPATH_VERSION = 8 def _gcode_text(path: Path) -> str: @@ -28,48 +28,89 @@ def _gcode_text(path: Path) -> str: return path.read_text(encoding="utf-8", errors="replace") +def _z_key(z): + return round(float(z), 2) + + def _thin_points(points, layer_breaks, max_points): - """Extrusions-Segmente stichprobenartig behalten — nie Endpunkte einer Bahn - weglassen und die Nachbarn verbinden (sonst Sehnen statt Infill).""" + """Pfad kürzen, ohne Wände zu sprenkeln. + + Strategie: ganze Z-Höhen (Druckschichten) vollständig behalten und nur + ganze Höhen weglassen. Stichproben entlang der Bahn erzeugen Lücken. + """ if len(points) <= max_points: return points, layer_breaks - extrude_ends = [i for i in range(1, len(points)) if points[i][3]] - keep = {0, len(points) - 1} - for break_i in layer_breaks: - keep.add(max(0, min(len(points) - 1, break_i))) - if break_i > 0: - keep.add(break_i - 1) + # Nur Z-Höhen mit Extrusion — Z-Hops allein zählen nicht + z_order = [] + seen = set() + for point in points: + if not point[3]: + continue + key = _z_key(point[2]) + if key not in seen: + seen.add(key) + z_order.append(key) + if not z_order: + return points[:max_points], [0] - # Budget für Segment-Endpunkte (jedes Segment braucht 2 Indizes, viele teilen sich) - target_segs = max(2000, max_points // 2) - step = max(1, (len(extrude_ends) + target_segs - 1) // target_segs) - for j, end_i in enumerate(extrude_ends): - if j % step == 0: + def keep_for_zs(keep_zs): + keep = {0, len(points) - 1} + for i, point in enumerate(points): + if _z_key(point[2]) in keep_zs: + keep.add(i) + if i > 0: + keep.add(i - 1) + return sorted(keep) + + # So viele Höhen wie ins Budget passen (Schätzung über mittlere Punkte/Höhe) + per_z = max(1, len(points) // max(1, len(z_order))) + max_zs = max(2, min(len(z_order), max_points // per_z)) + stride = max(1, (len(z_order) + max_zs - 1) // max_zs) + + def pick_zs(step): + chosen = {z_order[0], z_order[-1]} + for i in range(0, len(z_order), step): + chosen.add(z_order[i]) + return chosen + + step = stride + ordered = keep_for_zs(pick_zs(step)) + while len(ordered) > max_points and step < len(z_order): + step += 1 + ordered = keep_for_zs(pick_zs(step)) + + # Immer noch zu groß: innerhalb behaltener Höhen jedes n-te Extrusionssegment + if len(ordered) > max_points: + keep_zs = pick_zs(step) + segs = [ + i + for i in range(1, len(points)) + if points[i][3] and _z_key(points[i][2]) in keep_zs + ] + budget = max(1, (max_points - 4) // 2) + seg_step = max(1, (len(segs) + budget - 1) // budget) + keep = {0, len(points) - 1} + for end_i in segs[::seg_step]: keep.add(end_i - 1) keep.add(end_i) - - ordered = sorted(keep) - # Falls immer noch zu groß: Segmente grober - if len(ordered) > max_points: - step2 = max(1, (len(ordered) + max_points - 1) // max_points) - ordered = ordered[::step2] - if ordered[-1] != len(points) - 1: - ordered.append(len(points) - 1) + ordered = sorted(keep) index_map = {old: new for new, old in enumerate(ordered)} kept = [points[i] for i in ordered] - new_breaks = sorted({ - index_map[b] - for b in layer_breaks - if b in index_map - }) + new_breaks = [] + last_z = None + for new_i, point in enumerate(kept): + z = _z_key(point[2]) + if last_z is None or abs(z - last_z) > 0.01: + new_breaks.append(new_i) + last_z = z if not new_breaks: new_breaks = [0] return kept, new_breaks -def build_toolpath(path: Path, max_points=120000): +def build_toolpath(path: Path, max_points=220000): text = _gcode_text(path) if not text: return { diff --git a/grotti/version.py b/grotti/version.py index 8f4b1b9..54b8270 100644 --- a/grotti/version.py +++ b/grotti/version.py @@ -1,3 +1,3 @@ """Version von Grotti (SemVer). Bei jeder Änderung CHANGELOG.md mitführen.""" -__version__ = "2.2.1" +__version__ = "2.2.6" diff --git a/tests/test_moonraker_names.py b/tests/test_moonraker_names.py new file mode 100644 index 0000000..dfd24a0 --- /dev/null +++ b/tests/test_moonraker_names.py @@ -0,0 +1,25 @@ +"""ASCII-Dateinamen für Moonraker-Upload.""" + +from grotti.moonraker import multipart, safe_upload_name + + +def test_safe_upload_strips_cjk(): + name = "1015-2159-虎钳式强力夹子_plate(01)_PLA_0.2_53m57s.gcode" + safe = safe_upload_name(name) + assert safe.isascii() + assert safe.endswith(".gcode") + assert "plate(01)" in safe + assert "虎" not in safe + + +def test_multipart_single_ascii_filename(): + name = "模型_test.gcode" + _boundary, body = multipart(name, b"G28\n") + head = body.split(b"G28\n", 1)[0].decode("ascii") + assert "filename*=UTF-8" not in head + assert 'filename="test.gcode"' in head + assert all(ord(ch) < 128 for ch in head) + + +def test_safe_upload_preserves_path(): + assert safe_upload_name("subdir/foo.gcode") == "subdir/foo.gcode" diff --git a/tests/test_toolpath.py b/tests/test_toolpath.py new file mode 100644 index 0000000..94ce2b2 --- /dev/null +++ b/tests/test_toolpath.py @@ -0,0 +1,55 @@ +"""Toolpath-Thinning: ganze Schichten dicht, keine gesprenkelten Stichproben.""" + +from grotti.toolpath import TOOLPATH_VERSION, _thin_points, build_toolpath + + +def _fake_points(layers=40, per_layer=800): + points = [] + breaks = [] + seq = 0 + for layer in range(layers): + z = 0.2 + layer * 0.2 + breaks.append(len(points)) + for i in range(per_layer): + extrude = 1 if i > 0 else 0 + points.append([float(i), float(layer), z, extrude, seq]) + seq += 1 + return points, breaks + + +def test_thin_keeps_consecutive_extrude_pairs(): + points, breaks = _fake_points() + kept, new_breaks = _thin_points(points, breaks, max_points=8_000) + assert len(kept) <= 8_000 + assert new_breaks + consec = 0 + for i in range(1, len(kept)): + if kept[i][3] and kept[i][4] == kept[i - 1][4] + 1: + consec += 1 + assert consec > 500, "Thinning darf keine zeichnbaren Segmente übrig lassen" + + +def test_thin_keeps_solid_layers(): + points, breaks = _fake_points(layers=50, per_layer=600) + kept, _ = _thin_points(points, breaks, max_points=10_000) + # Pro behaltener Z-Höhe: hoher Anteil aufeinanderfolgender Extrusionen + by_z = {} + for i in range(1, len(kept)): + if not (kept[i][3] and kept[i][4] == kept[i - 1][4] + 1): + continue + z = round(kept[i][2], 2) + by_z[z] = by_z.get(z, 0) + 1 + assert by_z, "keine Extrusionssegmente" + assert max(by_z.values()) >= 40, "behaltene Schichten sollen dicht sein" + + +def test_toolpath_version(): + assert TOOLPATH_VERSION >= 8 + + +def test_build_empty(tmp_path): + path = tmp_path / "empty.gcode" + path.write_text("; nix\n", encoding="utf-8") + data = build_toolpath(path) + assert data["points"] == [] + assert data["version"] == TOOLPATH_VERSION diff --git a/web/app.py b/web/app.py index aa2064b..1e00182 100644 --- a/web/app.py +++ b/web/app.py @@ -910,7 +910,10 @@ def create_app(cfg): pass data["file_id"] = file_id data["name"] = row["name"] - return jsonify(data) + data["version"] = TOOLPATH_VERSION + resp = jsonify(data) + resp.headers["Cache-Control"] = "no-store" + return resp @app.patch("/api/v1/dateien/") def file_patch(file_id): diff --git a/web/static/gcode-viewer.js b/web/static/gcode-viewer.js index 8bc9119..4b1d839 100644 --- a/web/static/gcode-viewer.js +++ b/web/static/gcode-viewer.js @@ -10,8 +10,8 @@ window.GrottiGcodeViewer = (function () { const COLOR_OBJ_EX = 0xef4444; const COLOR_OBJ_CUR = 0xfbbf24; const GRID_STEP = 10; - // Nur leichte Doppelung — starke Offsets + Punkte wirkten wie Vollkörper mit Löchern - const FAT_OFFSETS = [0, -0.22, 0.22]; + // Keine Parallel-Offsets: bei ausgedünnten Pfaden wirkten sie gesprenkelt + const FAT_OFFSETS = [0]; function create(canvas) { const wrap = document.createElement("div"); diff --git a/web/static/grotti.css b/web/static/grotti.css index d853f83..e813675 100644 --- a/web/static/grotti.css +++ b/web/static/grotti.css @@ -620,12 +620,44 @@ article.machine.offline:hover { opacity: 1; } .job-line { display: flex; align-items: center; - gap: 0.45rem; + gap: 0.55rem; min-width: 0; color: var(--muted); font-size: var(--t-sm); + padding: 0.45rem 0.5rem; + border-radius: 10px; + background: rgba(255, 255, 255, 0.03); + border: 1px solid var(--line); +} +.job-thumb { + width: 4.5rem; + height: 4.5rem; + object-fit: contain; + border-radius: 9px; + border: 1px solid var(--line); + background: var(--card-2); + flex: none; +} +.job-thumb[hidden] { display: none; } +.job-ico { display: inline-flex; flex: none; color: var(--faint); } +.job-ico .ico { width: 1.1rem; height: 1.1rem; } +.job-line.has-thumb .job-ico { display: none; } +.job-meta { + min-width: 0; + flex: 1; + display: flex; + flex-direction: column; + gap: 0.3rem; + align-items: flex-start; +} +.job-line .name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 100%; + color: var(--ink); + font-weight: 650; } -.job-line .name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; min-width: 0; color: var(--ink); } .job-line .swatches { padding: 0; min-height: 0; flex: none; } .badge { font-size: 0.65rem; @@ -1874,7 +1906,6 @@ body.desk-on main { .summary-text b { color: var(--ink); } .page-head-side { display: flex; align-items: center; gap: 0.75rem 1rem; flex-wrap: wrap; } .page-head-side .check { margin: 0; font-size: var(--t-sm); color: var(--muted); } -.job-line > .ico { color: var(--faint); } .job-line[hidden] { display: none; } article.machine.flash { animation: flash 2s var(--ease); } @keyframes flash { 0%, 40% { box-shadow: 0 0 0 2px var(--accent), var(--shadow); } } diff --git a/web/templates/dateien.html b/web/templates/dateien.html index 76690a0..8c94c22 100644 --- a/web/templates/dateien.html +++ b/web/templates/dateien.html @@ -146,7 +146,7 @@ {% endblock %} {% block script %} - + - +