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:
1 parent
7da895bbcd
commit
b277ca48aa
16 files changed
+301
-49
No files matched your search
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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"
|
||||||
@@ -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"
|
||||||
@@ -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
@@ -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):
|
||||||
|
|||||||
@@ -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
@@ -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); } }
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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
@@ -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 });
|
||||||
|
|||||||
Reference in new issue
Block a user