Grotti 2.2.6: Wand-Vorschau, dichterer 3D-Pfad, ASCII-Dateinamen.

Kacheln zeigen Moonraker-Thumbnails; Toolpath-Thinning behält ganze Schichten; Nicht-ASCII-Namen vermeiden 10115 beim Senden.
This commit is contained in:
Dominic Gundel committed 2026-10-17 11:22:43 +02:00
1 parent 7da895bbcd
commit b277ca48aa
16 files changed
+301 -49

No files matched your search

+16
View File
@@ -4,6 +4,22 @@ Alle Änderungen an Grotti. Format nach [Keep a Changelog](https://keepachangelo
## [Unreleased] ## [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 ## [2.2.1] – 2026-10-15
### Geändert ### Geändert
+4
View File
@@ -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. 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 ## Wo nachschauen
- Sidebar: Alerts, Läuft, Wartet, Senden - Sidebar: Alerts, Läuft, Wartet, Senden
+1 -1
View File
@@ -12,7 +12,7 @@ Etikett: `/etikett/<id>`, Druck über den Browser. QR per lokalem `qrcode.min.js
## 3D-Pfad ## 3D-Pfad
`GET /api/v1/dateien/<id>/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/<id>/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. 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.
+2
View File
@@ -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. `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` ## 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: 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:
+2
View File
@@ -374,6 +374,8 @@ def send_file(
progress("vorbereiten", 30, "Datei vorbereiten…") progress("vorbereiten", 30, "Datei vorbereiten…")
filename, content, plate = library.printer_file(row) 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 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"): if (row.get("kind") or "") == "gcode" and str(filename).endswith(".gcode"):
content = prepare_rinkhals_gcode(content, tools=ace_tools) content = prepare_rinkhals_gcode(content, tools=ace_tools)
+42 -5
View File
@@ -165,18 +165,53 @@ def post_json(host, path, payload, timeout=20):
raise PrinterError("Der Drucker ist nicht erreichbar.") from exc 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): def multipart(filename, content):
boundary = "----GrottiBoundary7f3a" boundary = "----GrottiBoundary7f3a"
ascii_name = "".join(ch if ch.isascii() and ch not in '"\\' else "_" for ch in filename) or "job.gcode" name = safe_upload_name(filename)
quoted = urllib.parse.quote(filename)
fields = ( fields = (
"--%s\r\nContent-Disposition: form-data; name=\"root\"\r\n\r\ngcodes\r\n" % boundary "--%s\r\nContent-Disposition: form-data; name=\"root\"\r\n\r\ngcodes\r\n" % boundary
).encode("ascii") ).encode("ascii")
head = ( head = (
"--%s\r\n" "--%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" "Content-Type: application/octet-stream\r\n\r\n"
) % (boundary, ascii_name, quoted) ) % (boundary, name)
tail = ("\r\n--%s--\r\n" % boundary).encode("ascii") tail = ("\r\n--%s--\r\n" % boundary).encode("ascii")
return boundary, fields + head.encode("ascii") + content + tail return boundary, fields + head.encode("ascii") + content + tail
@@ -217,6 +252,7 @@ def upload_timeout(size):
def upload(host, filename, content, on_progress=None): def upload(host, filename, content, on_progress=None):
filename = safe_upload_name(filename)
boundary, body = multipart(filename, content) boundary, body = multipart(filename, content)
stream = _UploadBody(body, on_progress=on_progress) stream = _UploadBody(body, on_progress=on_progress)
request = urllib.request.Request( request = urllib.request.Request(
@@ -236,7 +272,8 @@ def upload(host, filename, content, on_progress=None):
except (urllib.error.URLError, TimeoutError) as exc: except (urllib.error.URLError, TimeoutError) as exc:
raise PrinterError("Hochladen ist fehlgeschlagen.") from exc raise PrinterError("Hochladen ist fehlgeschlagen.") from exc
item = (payload.get("result") or {}).get("item") or {} 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): def start_print(host, filename, bed_leveling=False, ams_settings=None):
+69 -28
View File
@@ -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) # Auch E.39 / Z.6 ohne führende Null (Anycubic/Prusa-Stil)
AXIS = re.compile(r"([XYZE])\s*(-?\d*\.?\d+)", re.IGNORECASE) AXIS = re.compile(r"([XYZE])\s*(-?\d*\.?\d+)", re.IGNORECASE)
# Cache-/Viewer-Version: Segment-IDs, kein Sehnen-Thinning # Cache-/Viewer-Version: ganze Z-Höhen dicht behalten (keine gesprenkelten Stichproben)
TOOLPATH_VERSION = 5 TOOLPATH_VERSION = 8
def _gcode_text(path: Path) -> str: 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") 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): def _thin_points(points, layer_breaks, max_points):
"""Extrusions-Segmente stichprobenartig behalten — nie Endpunkte einer Bahn """Pfad kürzen, ohne Wände zu sprenkeln.
weglassen und die Nachbarn verbinden (sonst Sehnen statt Infill)."""
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: if len(points) <= max_points:
return points, layer_breaks return points, layer_breaks
extrude_ends = [i for i in range(1, len(points)) if points[i][3]] # Nur Z-Höhen mit Extrusion — Z-Hops allein zählen nicht
keep = {0, len(points) - 1} z_order = []
for break_i in layer_breaks: seen = set()
keep.add(max(0, min(len(points) - 1, break_i))) for point in points:
if break_i > 0: if not point[3]:
keep.add(break_i - 1) 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) def keep_for_zs(keep_zs):
target_segs = max(2000, max_points // 2) keep = {0, len(points) - 1}
step = max(1, (len(extrude_ends) + target_segs - 1) // target_segs) for i, point in enumerate(points):
for j, end_i in enumerate(extrude_ends): if _z_key(point[2]) in keep_zs:
if j % step == 0: 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 - 1)
keep.add(end_i) keep.add(end_i)
ordered = sorted(keep) 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)
index_map = {old: new for new, old in enumerate(ordered)} index_map = {old: new for new, old in enumerate(ordered)}
kept = [points[i] for i in ordered] kept = [points[i] for i in ordered]
new_breaks = sorted({ new_breaks = []
index_map[b] last_z = None
for b in layer_breaks for new_i, point in enumerate(kept):
if b in index_map 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: if not new_breaks:
new_breaks = [0] new_breaks = [0]
return kept, new_breaks return kept, new_breaks
def build_toolpath(path: Path, max_points=120000): def build_toolpath(path: Path, max_points=220000):
text = _gcode_text(path) text = _gcode_text(path)
if not text: if not text:
return { return {
+1 -1
View File
@@ -1,3 +1,3 @@
"""Version von Grotti (SemVer). Bei jeder Änderung CHANGELOG.md mitführen.""" """Version von Grotti (SemVer). Bei jeder Änderung CHANGELOG.md mitführen."""
__version__ = "2.2.1" __version__ = "2.2.6"
+25
View File
@@ -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"
+55
View File
@@ -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
+4 -1
View File
@@ -910,7 +910,10 @@ def create_app(cfg):
pass pass
data["file_id"] = file_id data["file_id"] = file_id
data["name"] = row["name"] 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/<int:file_id>") @app.patch("/api/v1/dateien/<int:file_id>")
def file_patch(file_id): def file_patch(file_id):
+2 -2
View File
@@ -10,8 +10,8 @@ window.GrottiGcodeViewer = (function () {
const COLOR_OBJ_EX = 0xef4444; const COLOR_OBJ_EX = 0xef4444;
const COLOR_OBJ_CUR = 0xfbbf24; const COLOR_OBJ_CUR = 0xfbbf24;
const GRID_STEP = 10; const GRID_STEP = 10;
// Nur leichte Doppelung — starke Offsets + Punkte wirkten wie Vollkörper mit Löchern // Keine Parallel-Offsets: bei ausgedünnten Pfaden wirkten sie gesprenkelt
const FAT_OFFSETS = [0, -0.22, 0.22]; const FAT_OFFSETS = [0];
function create(canvas) { function create(canvas) {
const wrap = document.createElement("div"); const wrap = document.createElement("div");
+34 -3
View File
@@ -620,12 +620,44 @@ article.machine.offline:hover { opacity: 1; }
.job-line { .job-line {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.45rem; gap: 0.55rem;
min-width: 0; min-width: 0;
color: var(--muted); color: var(--muted);
font-size: var(--t-sm); 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; } .job-line .swatches { padding: 0; min-height: 0; flex: none; }
.badge { .badge {
font-size: 0.65rem; font-size: 0.65rem;
@@ -1874,7 +1906,6 @@ body.desk-on main {
.summary-text b { color: var(--ink); } .summary-text b { color: var(--ink); }
.page-head-side { display: flex; align-items: center; gap: 0.75rem 1rem; flex-wrap: wrap; } .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); } .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; } .job-line[hidden] { display: none; }
article.machine.flash { animation: flash 2s var(--ease); } article.machine.flash { animation: flash 2s var(--ease); }
@keyframes flash { 0%, 40% { box-shadow: 0 0 0 2px var(--accent), var(--shadow); } } @keyframes flash { 0%, 40% { box-shadow: 0 0 0 2px var(--accent), var(--shadow); } }
+2 -2
View File
@@ -146,7 +146,7 @@
{% endblock %} {% endblock %}
{% block script %} {% block script %}
<script src="{{ url_for('static', filename='three.min.js') }}?v=1"></script> <script src="{{ url_for('static', filename='three.min.js') }}?v=1"></script>
<script src="{{ url_for('static', filename='gcode-viewer.js') }}?v=8"></script> <script src="{{ url_for('static', filename='gcode-viewer.js') }}?v=10"></script>
<script src="{{ url_for('static', filename='sweep-viewer.js') }}?v=1"></script> <script src="{{ url_for('static', filename='sweep-viewer.js') }}?v=1"></script>
<script> <script>
const families = { kobra_s1: "Kobra S1", kobra_3: "Kobra 3" }; const families = { kobra_s1: "Kobra S1", kobra_3: "Kobra 3" };
@@ -198,7 +198,7 @@ async function showToolpath(file, progress) {
if (viewerFileId !== file.id) { if (viewerFileId !== file.id) {
meta.textContent = "Pfad wird gelesen …"; meta.textContent = "Pfad wird gelesen …";
try { try {
const data = await api("/dateien/" + file.id + "/pfad"); const data = await api("/dateien/" + file.id + "/pfad?tp=8");
viewer.setPath(data); viewer.setPath(data);
viewerFileId = file.id; viewerFileId = file.id;
meta.textContent = (data.count || 0).toLocaleString("de-DE") + " Punkte"; meta.textContent = (data.count || 0).toLocaleString("de-DE") + " Punkte";
+1 -1
View File
@@ -9,7 +9,7 @@
<link rel="icon" type="image/png" sizes="32x32" href="{{ url_for('static', filename='brand/favicon-32.png') }}"> <link rel="icon" type="image/png" sizes="32x32" href="{{ url_for('static', filename='brand/favicon-32.png') }}">
<link rel="icon" type="image/png" sizes="512x512" href="{{ url_for('static', filename='brand/favicon.png') }}"> <link rel="icon" type="image/png" sizes="512x512" href="{{ url_for('static', filename='brand/favicon.png') }}">
<link rel="apple-touch-icon" href="{{ url_for('static', filename='brand/apple-touch-icon.png') }}"> <link rel="apple-touch-icon" href="{{ url_for('static', filename='brand/apple-touch-icon.png') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='grotti.css') }}?v=39"> <link rel="stylesheet" href="{{ url_for('static', filename='grotti.css') }}?v=41">
<script> <script>
(function () { (function () {
try { try {
+41 -5
View File
@@ -86,7 +86,7 @@
{% endblock %} {% endblock %}
{% block script %} {% block script %}
<script src="{{ url_for('static', filename='three.min.js') }}?v=1"></script> <script src="{{ url_for('static', filename='three.min.js') }}?v=1"></script>
<script src="{{ url_for('static', filename='gcode-viewer.js') }}?v=8"></script> <script src="{{ url_for('static', filename='gcode-viewer.js') }}?v=10"></script>
<script> <script>
const labels = STATE_LABELS; const labels = STATE_LABELS;
const rank = { printing: 0, paused: 1, error: 2, heating: 3, complete: 4, cancelled: 5, standby: 6, offline: 7 }; const rank = { printing: 0, paused: 1, error: 2, heating: 3, complete: 4, cancelled: 5, standby: 6, offline: 7 };
@@ -490,7 +490,14 @@ function buildCard(printer) {
<div data-role="bar"></div> <div data-role="bar"></div>
<div class="stats" data-role="stats"></div> <div class="stats" data-role="stats"></div>
<div data-role="temps"></div> <div data-role="temps"></div>
<div class="job-line" data-role="jobline">${icon("cube")}<span class="name" data-role="job"></span><span class="swatches" data-role="swatches"></span></div> <div class="job-line" data-role="jobline">
<img class="job-thumb" data-role="thumb" alt="" hidden>
<span class="job-ico" data-role="jobico">${icon("cube")}</span>
<div class="job-meta">
<span class="name" data-role="job"></span>
<span class="swatches" data-role="swatches"></span>
</div>
</div>
<div data-role="flow"></div> <div data-role="flow"></div>
<div data-role="strips"></div> <div data-role="strips"></div>
<div class="card-actions" data-role="controls"></div> <div class="card-actions" data-role="controls"></div>
@@ -551,7 +558,36 @@ function patchCard(article, printer) {
setHtml(role("stats"), statsHtml(printer, state)); setHtml(role("stats"), statsHtml(printer, state));
setHtml(role("temps"), tempbarsHtml(printer, state)); setHtml(role("temps"), tempbarsHtml(printer, state));
const jobline = role("jobline"); const jobline = role("jobline");
if (jobline) jobline.hidden = !jobLineVisible(printer, state); const jobVisible = jobLineVisible(printer, state);
const hasThumb = !!(jobVisible && printer.preview);
if (jobline) {
jobline.hidden = !jobVisible;
jobline.classList.toggle("has-thumb", hasThumb);
}
const thumb = role("thumb");
const jobico = role("jobico");
if (thumb) {
if (hasThumb) {
const next = withToken(printer.preview);
if (thumb.dataset.srcKey !== next) {
thumb.dataset.srcKey = next;
thumb.src = next;
}
thumb.hidden = false;
thumb.onerror = () => {
thumb.hidden = true;
thumb.dataset.srcKey = "";
if (jobline) jobline.classList.remove("has-thumb");
if (jobico) jobico.hidden = false;
};
if (jobico) jobico.hidden = true;
} else {
thumb.hidden = true;
thumb.removeAttribute("src");
thumb.dataset.srcKey = "";
if (jobico) jobico.hidden = false;
}
}
const job = role("job"); const job = role("job");
if (job) { if (job) {
job.textContent = jobName(printer.filename) || ""; job.textContent = jobName(printer.filename) || "";
@@ -762,10 +798,10 @@ async function syncDeskViewer(printer) {
} }
try { try {
let data = toolpathCache.get(file.id); let data = toolpathCache.get(file.id);
const needLoad = !data || data.version !== 5 || deskViewerFileId !== file.id; const needLoad = !data || data.version !== 8 || deskViewerFileId !== file.id;
if (needLoad) { if (needLoad) {
if (caption) caption.textContent = "wird gelesen …"; if (caption) caption.textContent = "wird gelesen …";
data = await api("/dateien/" + file.id + "/pfad"); data = await api("/dateien/" + file.id + "/pfad?tp=8");
toolpathCache.set(file.id, data); toolpathCache.set(file.id, data);
const sameFile = deskViewerFileId === file.id; const sameFile = deskViewerFileId === file.id;
deskViewer.setPath(data, { keepView: sameFile }); deskViewer.setPath(data, { keepView: sameFile });