Kacheln zeigen Moonraker-Thumbnails; Toolpath-Thinning behält ganze Schichten; Nicht-ASCII-Namen vermeiden 10115 beim Senden.
1307 lines
53 KiB
HTML
1307 lines
53 KiB
HTML
{% extends "layout.html" %}
|
||
{% block body %}
|
||
<div class="page-head">
|
||
<div>
|
||
<h1>Dateien</h1>
|
||
<p class="tiny" id="watch"></p>
|
||
</div>
|
||
<div class="page-head-side">
|
||
<select id="upload-project" title="Projekt für Upload" style="max-width:10rem">
|
||
<option value="">kein Projekt</option>
|
||
</select>
|
||
<label class="button ghost" for="upload-file"><svg class="ico"><use href="#i-upload"/></svg>Hochladen</label>
|
||
<input id="upload-file" type="file" accept=".gcode,.3mf,.gcode.3mf" multiple hidden>
|
||
<button type="button" class="ghost" id="rescan"><svg class="ico"><use href="#i-refresh"/></svg>Ordner neu lesen</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="send-layout">
|
||
<section class="card lib-card" id="lib-card">
|
||
<div class="lib-head">
|
||
<h2>Bibliothek</h2>
|
||
<span class="count" id="lib-count">0</span>
|
||
</div>
|
||
<div class="lib-tools">
|
||
<input id="file-search" type="search" placeholder="Datei suchen…" autocomplete="off">
|
||
<select id="file-filter-family">
|
||
<option value="">Alle Maschinen</option>
|
||
<option value="kobra_3">Kobra 3</option>
|
||
<option value="kobra_s1">Kobra S1</option>
|
||
</select>
|
||
<select id="file-filter-project">
|
||
<option value="">Alle Projekte</option>
|
||
</select>
|
||
</div>
|
||
<p id="file-note" class="tiny"></p>
|
||
<div class="lib-list" id="lib-list">
|
||
<div class="skeleton" style="height:4rem"></div>
|
||
<div class="skeleton" style="height:4rem"></div>
|
||
<div class="skeleton" style="height:4rem"></div>
|
||
</div>
|
||
<p class="drop-hint">Dateien hierher ziehen zum Hochladen.</p>
|
||
</section>
|
||
|
||
<section class="card send-panel">
|
||
<div class="tabs" role="tablist">
|
||
<button type="button" class="on" data-tab="send" role="tab"><svg class="ico"><use href="#i-send"/></svg>Senden</button>
|
||
<button type="button" data-tab="ablauf" role="tab"><svg class="ico"><use href="#i-sweep"/></svg>Ablauf <small>Kobra 3</small></button>
|
||
</div>
|
||
|
||
<div class="flow-step" id="step-file">
|
||
<h3><span class="num-dot">1</span>Datei</h3>
|
||
<div id="picked"><div class="picked-empty"><svg class="ico"><use href="#i-file"/></svg><span>Datei aus der Bibliothek wählen.</span></div></div>
|
||
</div>
|
||
|
||
<div class="flow-step" id="step-target">
|
||
<h3><span class="num-dot">2</span>Drucker</h3>
|
||
<div class="targets" id="targets"></div>
|
||
<p class="tiny" id="target-hint"></p>
|
||
</div>
|
||
|
||
<div class="flow-step ok" data-pane="send" id="step-mode">
|
||
<h3><span class="num-dot">3</span>Wann</h3>
|
||
<div class="segmented" id="mode" role="radiogroup">
|
||
<button type="button" data-mode="now" class="on">Jetzt<small>sofort starten</small></button>
|
||
<button type="button" data-mode="free">Wenn frei<small>auf diesem Drucker</small></button>
|
||
<button type="button" data-mode="next">Nächster freier<small>Grotti wählt</small></button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="flow-step ok" data-pane="ablauf" id="step-ablauf" hidden>
|
||
<h3><span class="num-dot">3</span>Schritte</h3>
|
||
<p class="tiny">Drucken → abkühlen → schieben → Mitte tasten. Kiste vor dem Bett.</p>
|
||
<div id="ablauf-preview" class="ablauf-preview"></div>
|
||
<div class="sweep-preview-wrap" id="sweep-preview-wrap" hidden>
|
||
<canvas id="sweep-canvas" class="sweep-canvas" width="320" height="240" aria-label="Schiebe-Vorschau"></canvas>
|
||
<p class="tiny">Draufsicht · orange = Vorderkante</p>
|
||
</div>
|
||
<div id="ablauf-steps" class="ablauf-steps"></div>
|
||
<div class="actions ablauf-add">
|
||
<button type="button" class="ghost tiny-btn" id="ablauf-add-print">+ Drucken</button>
|
||
<button type="button" class="ghost tiny-btn" id="ablauf-add-cool">+ Abkühlen</button>
|
||
<button type="button" class="ghost tiny-btn" id="ablauf-add-sweep">+ Schieben</button>
|
||
<button type="button" class="ghost tiny-btn" id="ablauf-add-check">+ Tasten</button>
|
||
</div>
|
||
<div class="repeat-row">
|
||
<label for="ablauf-repeat">Durchläufe</label>
|
||
<div class="stepper">
|
||
<button type="button" class="ghost" data-repeat="-1" aria-label="weniger">−</button>
|
||
<input id="ablauf-repeat" type="number" min="1" max="99" value="1">
|
||
<button type="button" class="ghost" data-repeat="1" aria-label="mehr">+</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<details class="options" id="options">
|
||
<summary><svg class="ico caret"><use href="#i-down"/></svg><span class="grow">Optionen</span><span class="tiny" id="options-sum"></span></summary>
|
||
<div class="opt-body">
|
||
<div id="loaded-spools" class="loaded-spools"></div>
|
||
<p class="tiny">Verbrauch geht von der Rolle am Drucker. Wechsel unter <a href="/material">Material</a>.</p>
|
||
<label class="check"><input id="send-bed-level" type="checkbox"> Bettnetz vor dem Druck messen</label>
|
||
<label class="check"><input id="send-override" type="checkbox"> Auch senden, wenn die Maschine nicht passt</label>
|
||
<label for="send-project">Projekt</label>
|
||
<select id="send-project"><option value="">keins</option></select>
|
||
<div id="ace-box" class="ace-box" hidden>
|
||
<h3>ACE-Zuordnung <span class="tip" title="Datei-Farbe auf ACE-Platz. Ohne Zuordnung startet der ACE oft nicht."><svg class="ico"><use href="#i-info"/></svg></span></h3>
|
||
<p class="tiny msg" id="ace-trailer-warn" hidden>Für Auto-Feed fehlen Slicer-Metadaten in der Datei.</p>
|
||
<div id="ace-maps"></div>
|
||
</div>
|
||
</div>
|
||
</details>
|
||
|
||
<p class="msg" id="spool-warn" hidden></p>
|
||
|
||
<div class="go-row" data-pane="send">
|
||
<button type="button" class="go-btn" id="send-go" disabled><svg class="ico"><use href="#i-play"/></svg><span>Starten</span></button>
|
||
<p class="go-hint" id="send-hint"></p>
|
||
</div>
|
||
<div class="go-row" data-pane="ablauf" hidden>
|
||
<button type="button" class="go-btn" id="ablauf-go" disabled><svg class="ico"><use href="#i-queue"/></svg><span>Ablauf einreihen</span></button>
|
||
<p class="go-hint" id="ablauf-hint"></p>
|
||
<div class="test-row">
|
||
<button type="button" class="ghost tiny-btn" id="ablauf-test-sweep"><svg class="ico"><use href="#i-sweep"/></svg>Schieben testen</button>
|
||
<label class="check"><input id="ablauf-test-empty" type="checkbox"> Bett ist leer</label>
|
||
</div>
|
||
<p id="ablauf-note"></p>
|
||
</div>
|
||
|
||
<div class="send-progress" id="send-progress" hidden>
|
||
<div id="send-progress-list"></div>
|
||
</div>
|
||
<p id="send-note"></p>
|
||
</section>
|
||
</div>
|
||
|
||
<section class="card viewer-card" id="viewer-card" hidden>
|
||
<div class="page-head">
|
||
<div>
|
||
<h2>3D-Pfad</h2>
|
||
<p class="tiny" id="viewer-caption">Datei wählen</p>
|
||
</div>
|
||
<span class="tiny" id="viewer-meta"></span>
|
||
</div>
|
||
<p class="tiny">Ziehen dreht · Rad zoomt · Rechtsklick/Shift schwenkt · Doppelklick setzt zurück</p>
|
||
<canvas id="gcode-canvas" class="gcode-canvas"></canvas>
|
||
</section>
|
||
{% endblock %}
|
||
{% block script %}
|
||
<script src="{{ url_for('static', filename='three.min.js') }}?v=1"></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>
|
||
const families = { kobra_s1: "Kobra S1", kobra_3: "Kobra 3" };
|
||
const ABLAUF_KEY = "grotti-ablauf-v2";
|
||
const SEND_KEY = "grotti-send-v1";
|
||
const STEP_TITLES = { print: "Drucken", cool: "Abkühlen", sweep: "Schieben", check: "Tasten" };
|
||
let files = [];
|
||
let printers = [];
|
||
let spools = [];
|
||
let projects = [];
|
||
let sendPoll = null;
|
||
let watchedIds = [];
|
||
let pathViewer = null;
|
||
let viewerFileId = null;
|
||
let ablaufDefaults = {
|
||
cool: { bed_max: 30, nozzle_max: 45, fan_percent: 100, timeout_min: 25 },
|
||
sweep: { z_push: 2, z_travel: 20, x_from: 25, x_to: 225, x_step: 20, y_from: 250, y_to: 5, speed: 25 },
|
||
check: { max_delta_mm: 3.0, timeout_min: 2, x: 125, y: 125, ref_x: 25, ref_y: 125, z_clear: 20 },
|
||
};
|
||
let ablaufSteps = [];
|
||
const sel = { fileId: null, hosts: new Set(), mode: "now", tab: "send" };
|
||
let aceHostsKey = "";
|
||
|
||
/* ---------- 3D-Pfad ---------- */
|
||
|
||
function ensureViewer() {
|
||
if (pathViewer) return pathViewer;
|
||
const canvas = document.getElementById("gcode-canvas");
|
||
if (!canvas || !window.GrottiGcodeViewer || !window.THREE) return null;
|
||
pathViewer = GrottiGcodeViewer.create(canvas);
|
||
return pathViewer;
|
||
}
|
||
|
||
async function showToolpath(file, progress) {
|
||
const card = document.getElementById("viewer-card");
|
||
const caption = document.getElementById("viewer-caption");
|
||
const meta = document.getElementById("viewer-meta");
|
||
if (!file || file.kind === "3mf") {
|
||
card.hidden = true;
|
||
return;
|
||
}
|
||
card.hidden = false;
|
||
caption.textContent = file.name;
|
||
const viewer = ensureViewer();
|
||
if (!viewer) {
|
||
meta.textContent = "Viewer nicht geladen.";
|
||
return;
|
||
}
|
||
if (viewerFileId !== file.id) {
|
||
meta.textContent = "Pfad wird gelesen …";
|
||
try {
|
||
const data = await api("/dateien/" + file.id + "/pfad?tp=8");
|
||
viewer.setPath(data);
|
||
viewerFileId = file.id;
|
||
meta.textContent = (data.count || 0).toLocaleString("de-DE") + " Punkte";
|
||
} catch (error) {
|
||
meta.textContent = error.message || String(error);
|
||
return;
|
||
}
|
||
}
|
||
if (progress != null) viewer.setProgress(progress);
|
||
else viewer.setProgress(null);
|
||
}
|
||
|
||
/* ---------- Auswahl ---------- */
|
||
|
||
function selectedHosts() {
|
||
if (sel.mode === "next" && sel.tab === "send") return [];
|
||
return printers.filter((p) => sel.hosts.has(p.host)).map((p) => p.host);
|
||
}
|
||
|
||
function selectedPrinters() {
|
||
const hosts = new Set(selectedHosts());
|
||
return printers.filter((printer) => hosts.has(printer.host));
|
||
}
|
||
|
||
function selectedFile() {
|
||
return files.find((item) => String(item.id) === String(sel.fileId));
|
||
}
|
||
|
||
function selectedKobra3() {
|
||
return printers.filter((p) => sel.hosts.has(p.host) && p.family === "kobra_3");
|
||
}
|
||
|
||
function saveSendMemory() {
|
||
try {
|
||
localStorage.setItem(SEND_KEY, JSON.stringify({ mode: sel.mode, tab: sel.tab, hosts: [...sel.hosts] }));
|
||
} catch (error) { /* ignore */ }
|
||
}
|
||
|
||
function loadSendMemory() {
|
||
try {
|
||
const data = JSON.parse(localStorage.getItem(SEND_KEY) || "{}");
|
||
if (["now", "free", "next"].includes(data.mode)) sel.mode = data.mode;
|
||
if (["send", "ablauf"].includes(data.tab)) sel.tab = data.tab;
|
||
if (Array.isArray(data.hosts)) sel.hosts = new Set(data.hosts);
|
||
} catch (error) { /* ignore */ }
|
||
const q = new URLSearchParams(location.search);
|
||
if (q.get("file")) sel.fileId = q.get("file");
|
||
if (q.get("host")) sel.hosts = new Set([q.get("host")]);
|
||
if (q.get("tab") === "ablauf") sel.tab = "ablauf";
|
||
}
|
||
|
||
/* ---------- ACE / Spule ---------- */
|
||
|
||
function slotLabel(slot) {
|
||
const place = Number(slot.index) + 1;
|
||
const box = place <= 4 ? "ACE 1 · " + place : "ACE 2 · " + (place - 4);
|
||
const kind = slot.type ? " · " + slot.type : "";
|
||
return box + kind;
|
||
}
|
||
|
||
function filamentLabel(filament, index) {
|
||
const kind = filament.type || "Filament";
|
||
const color = filament.color ? " · " + filament.color : "";
|
||
return "Farbe " + (index + 1) + " · " + kind + color;
|
||
}
|
||
|
||
function spoolsOnHost(host) {
|
||
return spools.filter((s) => s.printer_host === host);
|
||
}
|
||
|
||
function spoolWarnText() {
|
||
const file = selectedFile();
|
||
const need = file ? Number(file.weight_g) || 0 : 0;
|
||
if (!file || need <= 0) return "";
|
||
const maps = collectAceMaps();
|
||
const hosts = selectedHosts();
|
||
const fileTypes = ((file.filaments || []).map((f) => (f.type || "").toLowerCase()).filter(Boolean));
|
||
for (const host of hosts) {
|
||
const name = (printers.find((p) => p.host === host) || {}).name || host;
|
||
const mapping = maps[host] || [];
|
||
const onHost = spoolsOnHost(host);
|
||
if (mapping.length) {
|
||
let left = 0;
|
||
let any = false;
|
||
const types = [];
|
||
mapping.forEach((item) => {
|
||
const slot = Number(item.ams_index) + 1;
|
||
const spool = onHost.find((s) => Number(s.ace_slot) === slot);
|
||
if (spool) {
|
||
any = true;
|
||
left += Number(spool.remaining_g) || 0;
|
||
if (spool.material_type) types.push(spool.material_type.toLowerCase());
|
||
}
|
||
});
|
||
if (!any) return name + ": keine Grotti-Spule auf den gewählten ACE-Plätzen — Abzug entfällt.";
|
||
if (left + 0.5 < need) return "ACE auf " + name + ": nur " + grams(left) + ", Datei braucht " + grams(need) + ".";
|
||
if (fileTypes.length && types.length && !fileTypes.some((t) => types.includes(t))) {
|
||
return name + ": Material passt nicht (Datei " + fileTypes.join("/") + ", ACE " + types.join("/") + ").";
|
||
}
|
||
continue;
|
||
}
|
||
const external = onHost.filter((s) => !s.ace_slot);
|
||
if (!external.length && !onHost.length) {
|
||
return name + ": keine Spule eingelegt — Abzug entfällt. Unter Material einlegen.";
|
||
}
|
||
if (external.length) {
|
||
const left = external.reduce((sum, s) => sum + (Number(s.remaining_g) || 0), 0);
|
||
if (left + 0.5 < need) {
|
||
return name + ": nur " + grams(left) + ", Datei braucht " + grams(need) + ".";
|
||
}
|
||
const types = external.map((s) => (s.material_type || "").toLowerCase()).filter(Boolean);
|
||
if (fileTypes.length && types.length && !fileTypes.some((t) => types.includes(t))) {
|
||
return name + ": Material passt nicht (Datei " + fileTypes.join("/") + ", Drucker " + types.join("/") + ").";
|
||
}
|
||
}
|
||
}
|
||
return "";
|
||
}
|
||
|
||
function renderLoadedSpools() {
|
||
const box = document.getElementById("loaded-spools");
|
||
if (!box) return;
|
||
const chosen = selectedPrinters();
|
||
if (!chosen.length) {
|
||
box.innerHTML = '<p class="tiny">Drucker wählen — dann siehst du die eingelegte Rolle.</p>';
|
||
return;
|
||
}
|
||
box.innerHTML = chosen.map((p) => {
|
||
const rows = spoolsOnHost(p.host);
|
||
if (!rows.length) {
|
||
return `<div class="loaded-host"><b>${escapeHtml(p.name)}</b><span class="tiny msg">keine Spule — <a href="/material">einlegen</a></span></div>`;
|
||
}
|
||
return `<div class="loaded-host"><b>${escapeHtml(p.name)}</b>${rows.map((s) => {
|
||
const slot = s.ace_slot ? "Platz " + s.ace_slot + " · " : "";
|
||
return `<span class="loaded-spool">${escapeHtml(slot + s.name)} · ${grams(s.remaining_g)}${s.material_type ? " · " + escapeHtml(s.material_type) : ""}</span>`;
|
||
}).join("")}</div>`;
|
||
}).join("");
|
||
}
|
||
|
||
function updateSpoolWarn() {
|
||
const node = document.getElementById("spool-warn");
|
||
const text = spoolWarnText();
|
||
node.hidden = !text;
|
||
node.textContent = text;
|
||
}
|
||
|
||
function filePaints(file) {
|
||
const rows = (file && file.filaments) || [];
|
||
if (rows.length) return rows;
|
||
return [{ type: "PLA", color: "" }];
|
||
}
|
||
|
||
function renderAceMaps(force) {
|
||
const box = document.getElementById("ace-box");
|
||
const root = document.getElementById("ace-maps");
|
||
const file = selectedFile();
|
||
const chosen = selectedPrinters().filter((printer) => printer.slots && printer.slots.length);
|
||
const key = (file ? file.id : "") + "|" + chosen.map((p) => p.host).join(",");
|
||
if (!force && key === aceHostsKey) return;
|
||
aceHostsKey = key;
|
||
const trailerWarn = document.getElementById("ace-trailer-warn");
|
||
if (!file || !chosen.length) {
|
||
box.hidden = true;
|
||
root.innerHTML = "";
|
||
if (trailerWarn) trailerWarn.hidden = true;
|
||
return;
|
||
}
|
||
const paints = filePaints(file);
|
||
box.hidden = false;
|
||
document.getElementById("options").open = true;
|
||
if (trailerWarn) trailerWarn.hidden = !!file.ace_trailer;
|
||
root.innerHTML = "";
|
||
chosen.forEach((printer) => {
|
||
const block = document.createElement("div");
|
||
block.className = "ace-printer";
|
||
block.dataset.host = printer.host;
|
||
const title = document.createElement("p");
|
||
title.className = "ace-printer-name";
|
||
title.textContent = printer.name;
|
||
block.appendChild(title);
|
||
paints.forEach((filament, paintIndex) => {
|
||
const row = document.createElement("label");
|
||
row.className = "ace-row";
|
||
const swatch = document.createElement("span");
|
||
swatch.className = "ace-swatch";
|
||
if (filament.color) swatch.style.background = filament.color;
|
||
const text = document.createElement("span");
|
||
text.textContent = filamentLabel(filament, paintIndex);
|
||
const select = document.createElement("select");
|
||
select.dataset.paint = String(paintIndex);
|
||
select.innerHTML = '<option value="">Platz wählen</option>';
|
||
printer.slots.forEach((slot) => {
|
||
const option = document.createElement("option");
|
||
option.value = String(slot.index);
|
||
option.textContent = slotLabel(slot);
|
||
if (slot.color) option.style.borderLeft = "4px solid " + slot.color;
|
||
// einfache Vorauswahl: gleiche Sorte, sonst Platz in Reihenfolge
|
||
if (!select.dataset.picked && filament.type && slot.type && filament.type.toLowerCase() === String(slot.type).toLowerCase()) {
|
||
option.selected = true;
|
||
select.dataset.picked = "1";
|
||
}
|
||
select.appendChild(option);
|
||
});
|
||
if (!select.dataset.picked && printer.slots[paintIndex]) {
|
||
select.value = String(printer.slots[paintIndex].index);
|
||
}
|
||
select.addEventListener("change", updateSpoolWarn);
|
||
row.appendChild(swatch);
|
||
row.appendChild(text);
|
||
row.appendChild(select);
|
||
block.appendChild(row);
|
||
});
|
||
root.appendChild(block);
|
||
});
|
||
}
|
||
|
||
function collectAceMaps() {
|
||
const maps = {};
|
||
document.querySelectorAll("#ace-maps .ace-printer").forEach((block) => {
|
||
const host = block.dataset.host;
|
||
const mapping = [];
|
||
block.querySelectorAll("select[data-paint]").forEach((select) => {
|
||
if (select.value === "") return;
|
||
mapping.push({
|
||
paint_index: Number(select.dataset.paint),
|
||
ams_index: Number(select.value),
|
||
});
|
||
});
|
||
maps[host] = mapping;
|
||
});
|
||
return maps;
|
||
}
|
||
|
||
/* ---------- Bibliothek ---------- */
|
||
|
||
function filteredFiles() {
|
||
const q = (document.getElementById("file-search").value || "").trim().toLowerCase();
|
||
const family = document.getElementById("file-filter-family").value;
|
||
const project = document.getElementById("file-filter-project").value;
|
||
return files.filter((file) => {
|
||
if (family && file.printer_family !== family) return false;
|
||
if (project === "none" && file.project_id) return false;
|
||
if (project && project !== "none" && String(file.project_id) !== project) return false;
|
||
if (!q) return true;
|
||
return String(file.name || "").toLowerCase().includes(q);
|
||
});
|
||
}
|
||
|
||
function fillProjectFilter() {
|
||
const select = document.getElementById("file-filter-project");
|
||
const current = select.value;
|
||
select.innerHTML = '<option value="">Alle Projekte</option><option value="none">Ohne Projekt</option>';
|
||
projects.forEach((project) => {
|
||
const option = document.createElement("option");
|
||
option.value = project.id;
|
||
option.textContent = project.name;
|
||
select.appendChild(option);
|
||
});
|
||
if ([...select.options].some((o) => o.value === current)) select.value = current;
|
||
}
|
||
|
||
function fillProjects() {
|
||
const opts = projects.map((row) =>
|
||
`<option value="${escapeHtml(row.id)}">${escapeHtml(row.name)}</option>`
|
||
).join("");
|
||
const send = document.getElementById("send-project");
|
||
if (send) {
|
||
const keep = send.value;
|
||
send.innerHTML = '<option value="">keins</option>' + opts;
|
||
if (keep) send.value = keep;
|
||
}
|
||
const upload = document.getElementById("upload-project");
|
||
if (upload) {
|
||
const keep = upload.value;
|
||
upload.innerHTML = '<option value="">kein Projekt</option>' + opts;
|
||
if (keep) upload.value = keep;
|
||
}
|
||
}
|
||
|
||
function sizeText(bytes) {
|
||
const n = Number(bytes) || 0;
|
||
if (!n) return "";
|
||
return n >= 1048576 ? (n / 1048576).toFixed(1).replace(".", ",") + " MB" : Math.round(n / 1024) + " kB";
|
||
}
|
||
|
||
function swatchesHtml(file) {
|
||
const colors = ((file && file.filaments) || []).map((f) => f.color).filter(Boolean);
|
||
if (!colors.length) return "";
|
||
return `<span class="mini-swatches">${colors.slice(0, 6).map((c) => `<i style="background:${escapeHtml(c)}"></i>`).join("")}</span>`;
|
||
}
|
||
|
||
function sliceOf(file) {
|
||
return (file && file.slice_meta) || {};
|
||
}
|
||
|
||
function materialText(file) {
|
||
const types = [...new Set(((file && file.filaments) || []).map((f) => f.type).filter(Boolean))];
|
||
return types.join("+");
|
||
}
|
||
|
||
function layerHeightText(file) {
|
||
const height = Number(sliceOf(file).layer_height) || 0;
|
||
if (!height) return "";
|
||
return String(height).replace(".", ",") + " mm";
|
||
}
|
||
|
||
function alwaysMeta(file, project) {
|
||
const bits = [
|
||
materialText(file),
|
||
file.estimated_s ? hours(file.estimated_s) : "",
|
||
file.weight_g ? grams(file.weight_g) : "",
|
||
layerHeightText(file),
|
||
families[file.printer_family] || "",
|
||
project ? project.name : "",
|
||
];
|
||
if (!file.weight_g && !file.estimated_s) bits.push(sizeText(file.size_bytes));
|
||
return bits.filter(Boolean).map((t) => `<span>${escapeHtml(t)}</span>`).join("") + swatchesHtml(file);
|
||
}
|
||
|
||
function detailMeta(file) {
|
||
const sm = sliceOf(file);
|
||
return [
|
||
sm.layers ? sm.layers + " Schichten" : "",
|
||
sm.max_z_mm ? "Z max " + String(sm.max_z_mm).replace(".", ",") + " mm" : "",
|
||
sm.nozzle_mm ? "Düse Ø " + String(sm.nozzle_mm).replace(".", ",") + " mm" : "",
|
||
sm.temp_nozzle ? "Düse " + Math.round(sm.temp_nozzle) + "°" : "",
|
||
sm.temp_bed ? "Bett " + Math.round(sm.temp_bed) + "°" : "",
|
||
sizeText(file.size_bytes),
|
||
file.mtime ? new Date(Number(file.mtime) * 1000).toLocaleString("de-DE") : "",
|
||
sm.slicer || "",
|
||
file.kind === "3mf" ? "nur 3MF" : "",
|
||
file.ace_trailer ? "ACE-Trailer" : "",
|
||
].filter(Boolean);
|
||
}
|
||
|
||
function infoPopupHtml(file) {
|
||
const rows = detailMeta(file);
|
||
if (!rows.length) return "";
|
||
return `<details class="lib-info" onclick="event.stopPropagation()">
|
||
<summary class="icon-btn" title="Details">${icon("info")}</summary>
|
||
<div class="lib-info-pop">${rows.map((row) => `<div>${escapeHtml(row)}</div>`).join("")}</div>
|
||
</details>`;
|
||
}
|
||
|
||
function thumbHtml(file) {
|
||
return file.preview
|
||
? `<img alt="" loading="lazy" src="${escapeHtml(withToken(file.preview))}" onerror="this.style.visibility='hidden'">`
|
||
: `<span class="ph">${icon("cube")}</span>`;
|
||
}
|
||
|
||
function renderLibrary() {
|
||
const list = document.getElementById("lib-list");
|
||
const shown = filteredFiles();
|
||
document.getElementById("lib-count").textContent = shown.length === files.length ? String(files.length) : shown.length + "/" + files.length;
|
||
if (!files.length) {
|
||
list.innerHTML = '<div class="picked-empty">' + icon("file") + '<span>Noch keine Datei.</span><span class="tiny">Hochladen oder Slice-Ordner in den Einstellungen setzen.</span></div>';
|
||
return;
|
||
}
|
||
if (!shown.length) {
|
||
list.innerHTML = '<div class="picked-empty">' + icon("search") + '<span>Keine Datei passt.</span></div>';
|
||
return;
|
||
}
|
||
list.innerHTML = shown.map((file) => {
|
||
const project = projects.find((p) => String(p.id) === String(file.project_id));
|
||
const del = file.source === "upload"
|
||
? `<button type="button" class="icon-btn" data-delete="${file.id}" title="Hochgeladene Datei löschen">${icon("x")}</button>` : "";
|
||
return `<div class="lib-item ${String(file.id) === String(sel.fileId) ? "on" : ""}" role="button" tabindex="0" data-file="${file.id}">
|
||
${thumbHtml(file)}
|
||
<span style="min-width:0"><span class="t" title="${escapeHtml(file.name)}">${escapeHtml(file.name)}</span><span class="m">${alwaysMeta(file, project)}</span></span>
|
||
<span class="side">${infoPopupHtml(file)}${del}</span>
|
||
</div>`;
|
||
}).join("");
|
||
}
|
||
|
||
/* ---------- Flow rechts ---------- */
|
||
|
||
function renderPicked() {
|
||
const file = selectedFile();
|
||
const box = document.getElementById("picked");
|
||
document.getElementById("step-file").classList.toggle("ok", !!file);
|
||
if (!file) {
|
||
box.innerHTML = '<div class="picked-empty">' + icon("file") + '<span>Datei aus der Bibliothek wählen.</span></div>';
|
||
return;
|
||
}
|
||
const project = projects.find((p) => String(p.id) === String(file.project_id));
|
||
const details = detailMeta(file).map((t) => `<span>${escapeHtml(t)}</span>`).join("");
|
||
box.innerHTML = `<div class="picked-file">${thumbHtml(file)}<div style="min-width:0"><div class="t" title="${escapeHtml(file.name)}">${escapeHtml(file.name)}</div><div class="m">${alwaysMeta(file, project)}</div>${details ? `<div class="m picked-details">${details}</div>` : ""}</div></div>`;
|
||
}
|
||
|
||
function targetState(p) {
|
||
const st = String(p.state || "").toLowerCase();
|
||
if (p.ok === false || st === "offline") return { st: "offline", text: "offline", bad: true };
|
||
if (p.queue_running) {
|
||
const job = p.queue_running;
|
||
const step = job.step_label_current ? job.step_label_current + " · " : "";
|
||
return { st: "printing", text: (job.steps && job.steps.length ? "Ablauf · " : "") + step + (repeatText(job) || "läuft") };
|
||
}
|
||
if (st === "printing" || st === "paused") {
|
||
const pct = Math.round((Number(p.progress) || 0) * 100);
|
||
const rest = p.remain > 0 ? " · noch " + hours(p.remain) : "";
|
||
return { st, text: (STATE_LABELS[st] || st) + " " + pct + " %" + rest };
|
||
}
|
||
if (st === "error") return { st, text: "Fehler — auf der Wand löschen", bad: true };
|
||
if (p.needs_clear) return { st: "waiting", text: "Platte belegt", bad: true };
|
||
return { st: st || "standby", text: STATE_LABELS[st] || "bereit" };
|
||
}
|
||
|
||
function isFree(p) {
|
||
const st = String(p.state || "").toLowerCase();
|
||
return p.ok !== false && ["standby", "complete", "cancelled"].includes(st) && !p.queue_running;
|
||
}
|
||
|
||
function familyMismatch(p, file) {
|
||
return !!(file && file.printer_family && p.family && file.printer_family !== p.family);
|
||
}
|
||
|
||
function renderTargets() {
|
||
const box = document.getElementById("targets");
|
||
const file = selectedFile();
|
||
const override = document.getElementById("send-override").checked;
|
||
const nextMode = sel.tab === "send" && sel.mode === "next";
|
||
box.classList.toggle("dimmed", nextMode);
|
||
box.innerHTML = printers.map((p) => {
|
||
const s = targetState(p);
|
||
const mismatch = familyMismatch(p, file);
|
||
const notK3 = sel.tab === "ablauf" && p.family !== "kobra_3";
|
||
const off = (mismatch && !override) || notK3;
|
||
const on = !nextMode && sel.hosts.has(p.host) && !notK3;
|
||
const waiting = p.queue_waiting ? `<span class="count">${p.queue_waiting}</span>` : "";
|
||
const why = notK3 ? "Ablauf nur Kobra 3" : mismatch ? "andere Maschine" : s.text;
|
||
return `<button type="button" class="target ${on ? "on" : ""} ${off ? "off" : ""}" data-host="${escapeHtml(p.host)}" aria-pressed="${on}">
|
||
<span class="row1"><span class="chip pip ${escapeHtml(s.st)}"></span><span class="n">${escapeHtml(p.name)}</span>${waiting}${p.slots && p.slots.length ? '<span class="tiny">ACE</span>' : ""}</span>
|
||
<span class="row2 ${s.bad || off ? "bad" : ""}">${escapeHtml(why)}</span>
|
||
</button>`;
|
||
}).join("") || '<div class="picked-empty">' + icon("printer") + '<span>Noch kein Drucker.</span><span class="tiny"><a href="/einstellungen">In Einstellungen anlegen</a></span></div>';
|
||
document.getElementById("step-target").classList.toggle("ok", nextMode || selectedHosts().length > 0);
|
||
}
|
||
|
||
function familyOfSelection() {
|
||
const file = selectedFile();
|
||
return file && families[file.printer_family] ? families[file.printer_family] : "Drucker";
|
||
}
|
||
|
||
function namesText(list) {
|
||
if (!list.length) return "";
|
||
if (list.length <= 2) return list.map((p) => p.name).join(" und ");
|
||
return list.length + " Druckern";
|
||
}
|
||
|
||
function updateSendButton() {
|
||
const file = selectedFile();
|
||
const chosen = selectedPrinters();
|
||
const button = document.getElementById("send-go");
|
||
const hint = document.getElementById("send-hint");
|
||
const label = button.querySelector("span");
|
||
const targetHint = document.getElementById("target-hint");
|
||
if (button.dataset.armed === "1") return;
|
||
let text = "Starten";
|
||
let problem = "";
|
||
let note = "";
|
||
if (!file) problem = "Erst eine Datei wählen.";
|
||
else if (file.kind === "3mf") problem = "Die 3MF hat keinen G-code. Mit G-code slicen.";
|
||
if (sel.mode === "now") {
|
||
if (!problem && !chosen.length) problem = "Einen oder mehrere Drucker wählen.";
|
||
const busy = chosen.filter((p) => !isFree(p));
|
||
if (!problem && busy.length) note = namesText(busy) + " " + (busy.length === 1 ? "ist" : "sind") + " nicht frei. „Wenn frei“ reiht stattdessen ein.";
|
||
text = chosen.length ? "Jetzt starten auf " + namesText(chosen) : "Jetzt starten";
|
||
} else if (sel.mode === "free") {
|
||
text = chosen.length ? "Einreihen für " + namesText(chosen) : "Einreihen: nächster freier " + familyOfSelection();
|
||
note = chosen.length ? "Startet von selbst, sobald der Drucker bereit und die Platte frei ist." : "Ohne Drucker: der nächste freie passende übernimmt.";
|
||
} else {
|
||
text = "Einreihen: nächster freier " + familyOfSelection();
|
||
note = "Grotti nimmt den ersten freien Drucker mit passender Maschine.";
|
||
}
|
||
const mismatch = chosen.filter((p) => familyMismatch(p, file));
|
||
if (!problem && mismatch.length && !document.getElementById("send-override").checked) {
|
||
problem = "Datei passt nicht zu " + namesText(mismatch) + ". Abwählen oder unter Optionen erlauben.";
|
||
}
|
||
label.textContent = text;
|
||
button.disabled = !!problem || !!sendPoll;
|
||
hint.textContent = problem || note;
|
||
hint.classList.toggle("bad", !!problem);
|
||
targetHint.textContent = sel.mode === "next" && sel.tab === "send" ? "Ziel wählt Grotti." : chosen.length > 1 ? "Je Drucker ein eigener Auftrag." : "";
|
||
}
|
||
|
||
function updateAblaufButton() {
|
||
const file = selectedFile();
|
||
const k3 = selectedKobra3();
|
||
const button = document.getElementById("ablauf-go");
|
||
const hint = document.getElementById("ablauf-hint");
|
||
if (button.dataset.armed === "1") return;
|
||
const repeat = Math.max(1, Number(document.getElementById("ablauf-repeat").value) || 1);
|
||
let problem = "";
|
||
if (!ablaufSteps.length) problem = "Mindestens einen Schritt anlegen.";
|
||
else if (!ablaufSteps.some((s) => s.type === "print" && s.file_id)) problem = "Ein Druck-Schritt braucht eine Datei.";
|
||
else if (!k3.length) problem = "Einen oder mehrere Kobra 3 wählen.";
|
||
button.querySelector("span").textContent = k3.length
|
||
? "Ablauf einreihen auf " + namesText(k3) + (repeat > 1 ? " · " + repeat + "×" : "")
|
||
: "Ablauf einreihen";
|
||
button.disabled = !!problem;
|
||
hint.textContent = problem || (file ? "Startet, sobald der Drucker frei ist. In der Seitenleiste abbrechbar." : "");
|
||
hint.classList.toggle("bad", !!problem);
|
||
}
|
||
|
||
function updateOptionsSummary() {
|
||
const parts = [];
|
||
const hosts = selectedHosts();
|
||
const loaded = hosts.reduce((n, h) => n + spoolsOnHost(h).length, 0);
|
||
if (loaded) parts.push(loaded + (loaded === 1 ? " Spule" : " Spulen"));
|
||
if (document.getElementById("send-bed-level").checked) parts.push("Bettnetz");
|
||
if (document.getElementById("send-override").checked) parts.push("Maschine egal");
|
||
const project = projects.find((p) => String(p.id) === document.getElementById("send-project")?.value);
|
||
if (project) parts.push(project.name);
|
||
if (!document.getElementById("ace-box").hidden) parts.push("ACE");
|
||
document.getElementById("options-sum").textContent = parts.join(" · ");
|
||
}
|
||
|
||
function renderTabs() {
|
||
document.querySelectorAll(".tabs [data-tab]").forEach((b) => b.classList.toggle("on", b.dataset.tab === sel.tab));
|
||
document.querySelectorAll("[data-pane]").forEach((node) => { node.hidden = node.dataset.pane !== sel.tab; });
|
||
document.querySelectorAll("#mode [data-mode]").forEach((b) => b.classList.toggle("on", b.dataset.mode === sel.mode));
|
||
if (sel.tab === "ablauf") {
|
||
ensureAblaufDefaults();
|
||
renderAblauf();
|
||
}
|
||
}
|
||
|
||
function refreshFlow() {
|
||
renderPicked();
|
||
renderTargets();
|
||
renderAceMaps();
|
||
renderLoadedSpools();
|
||
updateSpoolWarn();
|
||
updateOptionsSummary();
|
||
updateSendButton();
|
||
updateAblaufButton();
|
||
}
|
||
|
||
function selectFile(id) {
|
||
sel.fileId = id;
|
||
const file = selectedFile();
|
||
if (file && file.project_id && document.getElementById("send-project")) {
|
||
document.getElementById("send-project").value = String(file.project_id);
|
||
}
|
||
if (file && ablaufSteps.length && ablaufSteps[0].type === "print") {
|
||
ablaufSteps[0].file_id = file.id;
|
||
saveAblaufMemory();
|
||
if (sel.tab === "ablauf") renderAblauf();
|
||
}
|
||
document.querySelectorAll("#lib-list .lib-item").forEach((n) => n.classList.toggle("on", n.dataset.file === String(id)));
|
||
refreshFlow();
|
||
showToolpath(file, 0);
|
||
if (window.matchMedia("(max-width: 1080px)").matches) {
|
||
document.querySelector(".send-panel").scrollIntoView({ behavior: "smooth", block: "start" });
|
||
}
|
||
}
|
||
|
||
/* ---------- Laden ---------- */
|
||
|
||
async function load() {
|
||
const [settings, fileRows, printerRows, spoolRows, projectRows, defaults] = await Promise.all([
|
||
api("/einstellungen"),
|
||
api("/dateien"),
|
||
api("/drucker?live=1"),
|
||
api("/spulen"),
|
||
api("/projekte").catch(() => []),
|
||
api("/abraeumen-defaults").catch(() => null),
|
||
]);
|
||
document.getElementById("watch").textContent = settings.watch_dir
|
||
? "Slice-Ordner: " + settings.watch_dir
|
||
: "Kein Slice-Ordner — Dateien hier hochladen.";
|
||
files = fileRows;
|
||
printers = printerRows;
|
||
spools = spoolRows;
|
||
projects = projectRows || [];
|
||
if (defaults && defaults.cool) ablaufDefaults.cool = defaults.cool;
|
||
if (defaults && defaults.sweep) ablaufDefaults.sweep = defaults.sweep;
|
||
if (defaults && defaults.check) ablaufDefaults.check = defaults.check;
|
||
if (sel.fileId && !selectedFile()) sel.fileId = null;
|
||
fillProjects();
|
||
fillProjectFilter();
|
||
renderLibrary();
|
||
renderTabs();
|
||
refreshFlow();
|
||
const file = selectedFile();
|
||
if (file) showToolpath(file, 0);
|
||
}
|
||
|
||
async function refreshPrinters() {
|
||
if (document.hidden) return;
|
||
try {
|
||
printers = await api("/drucker?live=1");
|
||
renderTargets();
|
||
updateSendButton();
|
||
updateAblaufButton();
|
||
} catch (error) { /* nächster Versuch */ }
|
||
}
|
||
|
||
/* ---------- Senden ---------- */
|
||
|
||
function showJobsProgress(jobs) {
|
||
const box = document.getElementById("send-progress");
|
||
const list = document.getElementById("send-progress-list");
|
||
const note = document.getElementById("send-note");
|
||
box.hidden = false;
|
||
list.innerHTML = jobs.map((job) => {
|
||
const pct = Math.max(0, Math.min(100, Number(job.progress) || 0));
|
||
const cls = job.status === "error" ? "bad" : job.status === "done" ? "good" : "";
|
||
return `<div class="send-job">
|
||
<div class="send-job-head"><strong>${escapeHtml(job.printer || "")}</strong><span>${escapeHtml(job.message || "")}</span></div>
|
||
<div class="send-progress-track"><div class="send-progress-bar ${cls}" style="width:${pct}%"></div></div>
|
||
</div>`;
|
||
}).join("");
|
||
const errors = jobs.filter((job) => job.status === "error");
|
||
const pending = jobs.filter((job) => job.status === "pending" || job.status === "running");
|
||
const done = jobs.filter((job) => job.status === "done");
|
||
note.className = "";
|
||
note.textContent = "";
|
||
if (pending.length) return;
|
||
if (errors.length && !done.length) {
|
||
toast(errors.map((job) => job.printer + ": " + job.message).join(" · "), "bad");
|
||
} else if (errors.length) {
|
||
toast(done.length + " gestartet, " + errors.length + " fehlgeschlagen.", "bad");
|
||
} else {
|
||
const warn = done.some((job) => (job.result && job.result.warnings && job.result.warnings.length) || (job.message || "").includes("ACE-Nachschub"));
|
||
toast(done.length === 1 ? (done[0].printer + ": " + (done[0].message || "gestartet")) : done.length + " Drucker gestartet.", "good");
|
||
if (warn) {
|
||
note.textContent = "ACE-Nachschub braucht Slicer-Metadaten (Trailer).";
|
||
note.className = "msg";
|
||
}
|
||
}
|
||
}
|
||
|
||
function stopSendPoll() {
|
||
if (sendPoll) {
|
||
clearInterval(sendPoll);
|
||
sendPoll = null;
|
||
}
|
||
}
|
||
|
||
async function watchJobs(ids) {
|
||
stopSendPoll();
|
||
watchedIds = ids.slice();
|
||
const tick = async () => {
|
||
try {
|
||
const jobs = await Promise.all(watchedIds.map((id) => api("/senden/" + id)));
|
||
showJobsProgress(jobs);
|
||
const busy = jobs.some((job) => job.status === "pending" || job.status === "running");
|
||
if (!busy) {
|
||
stopSendPoll();
|
||
updateSendButton();
|
||
refreshPrinters();
|
||
if (typeof refreshSendQueue === "function") refreshSendQueue();
|
||
}
|
||
} catch (error) {
|
||
stopSendPoll();
|
||
updateSendButton();
|
||
showNote("send-note", error);
|
||
}
|
||
};
|
||
sendPoll = setInterval(tick, 500);
|
||
updateSendButton();
|
||
await tick();
|
||
}
|
||
|
||
async function doSend() {
|
||
const button = document.getElementById("send-go");
|
||
const whenFree = sel.mode !== "now";
|
||
button.disabled = true;
|
||
document.getElementById("send-note").textContent = "";
|
||
try {
|
||
const payload = await api("/senden", {
|
||
method: "POST",
|
||
json: {
|
||
file_id: Number(sel.fileId),
|
||
hosts: selectedHosts(),
|
||
confirm: true,
|
||
override_machine: document.getElementById("send-override").checked,
|
||
bed_leveling: document.getElementById("send-bed-level").checked,
|
||
ace_maps: collectAceMaps(),
|
||
when_free: whenFree,
|
||
},
|
||
});
|
||
const projectId = document.getElementById("send-project")?.value;
|
||
if (projectId && sel.fileId) {
|
||
try {
|
||
await api("/dateien/" + sel.fileId, { method: "PATCH", json: { project_id: Number(projectId) } });
|
||
} catch (error) { /* Projektbindung optional */ }
|
||
}
|
||
const jobs = payload.jobs || [];
|
||
if (whenFree || payload.mode === "when_free") {
|
||
document.getElementById("send-progress").hidden = true;
|
||
toast((jobs.length > 1 ? jobs.length + " Aufträge" : "Auftrag") + " eingereiht. Steht unter „Wartet“.", "good");
|
||
updateSendButton();
|
||
if (typeof refreshPrintQueue === "function") refreshPrintQueue();
|
||
refreshPrinters();
|
||
return;
|
||
}
|
||
showJobsProgress(jobs);
|
||
if (typeof refreshSendQueue === "function") refreshSendQueue();
|
||
await watchJobs(jobs.map((job) => job.id));
|
||
} catch (error) {
|
||
updateSendButton();
|
||
toast(error.message || String(error), "bad");
|
||
}
|
||
}
|
||
|
||
/* ---------- Ablauf ---------- */
|
||
|
||
function loadAblaufMemory() {
|
||
try {
|
||
const raw = localStorage.getItem(ABLAUF_KEY);
|
||
if (!raw) return;
|
||
const data = JSON.parse(raw);
|
||
if (Array.isArray(data.steps) && data.steps.length) {
|
||
// altes 3×3 (probe_count/mesh_*) streichen; Mitte-Tasten behalten/ergänzen
|
||
ablaufSteps = data.steps.filter((s) => !(s.type === "check" && (s.probe_count != null || s.mesh_min_x != null)));
|
||
ablaufSteps = ablaufSteps.map((s) => {
|
||
if (s.type === "check" && (Number(s.max_delta_mm) === 2.5 || Number(s.max_delta_mm) === 1.0))
|
||
return { ...s, max_delta_mm: 3.0 };
|
||
return s;
|
||
});
|
||
const types = ablaufSteps.map((s) => s.type).join(",");
|
||
if (types === "print,cool,sweep") ablaufSteps = [...ablaufSteps, defaultCheckStep()];
|
||
}
|
||
if (data.repeat) document.getElementById("ablauf-repeat").value = String(data.repeat);
|
||
} catch (error) { /* ignore */ }
|
||
}
|
||
|
||
function saveAblaufMemory() {
|
||
try {
|
||
localStorage.setItem(ABLAUF_KEY, JSON.stringify({
|
||
steps: ablaufSteps,
|
||
repeat: Number(document.getElementById("ablauf-repeat").value) || 1,
|
||
}));
|
||
} catch (error) { /* ignore */ }
|
||
}
|
||
|
||
function defaultPrintStep() {
|
||
const file = selectedFile();
|
||
return {
|
||
type: "print",
|
||
file_id: file ? file.id : (files[0] && files[0].id) || null,
|
||
bed_leveling: true,
|
||
};
|
||
}
|
||
|
||
function defaultCoolStep() { return { type: "cool", ...ablaufDefaults.cool }; }
|
||
function defaultSweepStep() { return { type: "sweep", ...ablaufDefaults.sweep }; }
|
||
function defaultCheckStep() { return { type: "check", ...ablaufDefaults.check }; }
|
||
|
||
function ensureAblaufDefaults() {
|
||
if (!ablaufSteps.length) {
|
||
ablaufSteps = [defaultPrintStep(), defaultCoolStep(), defaultSweepStep(), defaultCheckStep()];
|
||
}
|
||
}
|
||
|
||
function numField(label, key, value, step) {
|
||
return `<label class="ablauf-field">${escapeHtml(label)}<input type="number" data-key="${escapeHtml(key)}" value="${escapeHtml(String(value))}" step="${step || "1"}"></label>`;
|
||
}
|
||
|
||
function stepSummary(step) {
|
||
if (step.type === "print") {
|
||
const file = files.find((f) => String(f.id) === String(step.file_id));
|
||
return file ? file.name : "keine Datei";
|
||
}
|
||
if (step.type === "cool") return "Bett ≤ " + step.bed_max + " °C · Düse ≤ " + step.nozzle_max + " °C";
|
||
if (step.type === "sweep") return "X " + step.x_from + "–" + step.x_to + " · Z " + step.z_push + " mm";
|
||
if (step.type === "check") {
|
||
return "Tast " + (step.x != null ? step.x : 125) + "/" + (step.y != null ? step.y : 125)
|
||
+ " · max Z " + step.max_delta_mm + " mm";
|
||
}
|
||
return "";
|
||
}
|
||
|
||
let sweepViewer = null;
|
||
|
||
function syncSweepPreview() {
|
||
const wrap = document.getElementById("sweep-preview-wrap");
|
||
const canvas = document.getElementById("sweep-canvas");
|
||
if (!wrap || !canvas || typeof SweepViewer !== "function") return;
|
||
const sweep = ablaufSteps.find((step) => step.type === "sweep");
|
||
wrap.hidden = !sweep;
|
||
if (!sweep) return;
|
||
if (!sweepViewer) sweepViewer = new SweepViewer(canvas);
|
||
sweepViewer.setSweep(sweep);
|
||
}
|
||
|
||
function renderAblauf() {
|
||
const root = document.getElementById("ablauf-steps");
|
||
if (!root) return;
|
||
document.getElementById("ablauf-preview").innerHTML = stepsHtml(ablaufSteps.map((s) => STEP_TITLES[s.type] || s.type), -1);
|
||
root.innerHTML = ablaufSteps.map((step, index) => {
|
||
const kind = step.type;
|
||
const title = STEP_TITLES[kind] || kind;
|
||
let body = "";
|
||
if (kind === "print") {
|
||
const options = files.map((file) =>
|
||
`<option value="${file.id}" ${String(file.id) === String(step.file_id) ? "selected" : ""}>${escapeHtml(file.name)}</option>`
|
||
).join("");
|
||
body = `<label class="ablauf-field wide">Datei<select data-key="file_id">${options}</select></label>
|
||
<label class="check"><input type="checkbox" data-key="bed_leveling" ${step.bed_leveling ? "checked" : ""}> Bettnetz messen</label>`;
|
||
} else if (kind === "cool") {
|
||
body = [
|
||
numField("Bett max °C", "bed_max", step.bed_max, "1"),
|
||
numField("Düse max °C", "nozzle_max", step.nozzle_max, "1"),
|
||
numField("Lüfter %", "fan_percent", step.fan_percent, "1"),
|
||
numField("Timeout min", "timeout_min", step.timeout_min, "1"),
|
||
].join("");
|
||
} else if (kind === "check") {
|
||
body = [
|
||
numField("Max Z mm", "max_delta_mm", step.max_delta_mm, "0.1"),
|
||
numField("Tast X", "x", step.x != null ? step.x : 125, "1"),
|
||
numField("Tast Y", "y", step.y != null ? step.y : 125, "1"),
|
||
numField("Bezug X", "ref_x", step.ref_x != null ? step.ref_x : 25, "1"),
|
||
numField("Bezug Y", "ref_y", step.ref_y != null ? step.ref_y : 125, "1"),
|
||
numField("Timeout min", "timeout_min", step.timeout_min, "1"),
|
||
].join("");
|
||
} else {
|
||
body = [
|
||
numField("Z schieben mm", "z_push", step.z_push, "0.1"),
|
||
numField("Z umsetzen mm", "z_travel", step.z_travel, "0.1"),
|
||
numField("X von", "x_from", step.x_from, "1"),
|
||
numField("X bis", "x_to", step.x_to, "1"),
|
||
numField("X Schritt", "x_step", step.x_step, "1"),
|
||
numField("Y hinten", "y_from", step.y_from, "1"),
|
||
numField("Y vorn", "y_to", step.y_to, "1"),
|
||
numField("Tempo mm/s", "speed", step.speed, "1"),
|
||
].join("");
|
||
}
|
||
return `<details class="ablauf-step" data-index="${index}" ${kind === "print" ? "open" : ""}>
|
||
<summary class="ablauf-step-head">
|
||
<span class="num-dot">${index + 1}</span>
|
||
<strong>${title}</strong>
|
||
<span class="ablauf-step-sum">${escapeHtml(stepSummary(step))}</span>
|
||
<span class="ablauf-step-tools">
|
||
<button type="button" class="icon-btn" data-move="-1" ${index === 0 ? "disabled" : ""} title="nach oben">${icon("up")}</button>
|
||
<button type="button" class="icon-btn" data-move="1" ${index === ablaufSteps.length - 1 ? "disabled" : ""} title="nach unten">${icon("down")}</button>
|
||
<button type="button" class="icon-btn" data-remove="1" title="entfernen">${icon("x")}</button>
|
||
</span>
|
||
</summary>
|
||
<div class="ablauf-step-body">${body}</div>
|
||
</details>`;
|
||
}).join("");
|
||
root.querySelectorAll(".ablauf-step").forEach((node) => {
|
||
const index = Number(node.dataset.index);
|
||
node.querySelectorAll("[data-key]").forEach((input) => {
|
||
const apply = () => {
|
||
const key = input.dataset.key;
|
||
if (input.type === "checkbox") ablaufSteps[index][key] = input.checked;
|
||
else ablaufSteps[index][key] = Number(input.value);
|
||
node.querySelector(".ablauf-step-sum").textContent = stepSummary(ablaufSteps[index]);
|
||
saveAblaufMemory();
|
||
updateAblaufButton();
|
||
if (ablaufSteps[index].type === "sweep") syncSweepPreview();
|
||
};
|
||
input.addEventListener("change", apply);
|
||
input.addEventListener("input", apply);
|
||
});
|
||
node.querySelectorAll("[data-move]").forEach((btn) => {
|
||
btn.addEventListener("click", (event) => {
|
||
event.preventDefault();
|
||
const target = index + Number(btn.dataset.move);
|
||
if (target < 0 || target >= ablaufSteps.length) return;
|
||
[ablaufSteps[index], ablaufSteps[target]] = [ablaufSteps[target], ablaufSteps[index]];
|
||
saveAblaufMemory();
|
||
renderAblauf();
|
||
});
|
||
});
|
||
node.querySelector("[data-remove]").addEventListener("click", (event) => {
|
||
event.preventDefault();
|
||
ablaufSteps.splice(index, 1);
|
||
saveAblaufMemory();
|
||
renderAblauf();
|
||
});
|
||
});
|
||
syncSweepPreview();
|
||
updateAblaufButton();
|
||
}
|
||
|
||
function addStep(step) {
|
||
ablaufSteps.push(step);
|
||
saveAblaufMemory();
|
||
renderAblauf();
|
||
}
|
||
|
||
function collectAblaufPayload() {
|
||
const steps = ablaufSteps.map((step) => {
|
||
if (step.type !== "print") return { ...step };
|
||
return {
|
||
type: "print",
|
||
file_id: Number(step.file_id),
|
||
bed_leveling: !!step.bed_leveling,
|
||
override_machine: document.getElementById("send-override").checked,
|
||
ace_map: [],
|
||
};
|
||
});
|
||
return {
|
||
hosts: selectedKobra3().map((printer) => printer.host),
|
||
steps,
|
||
ace_maps: collectAceMaps(),
|
||
repeat_total: Math.max(1, Math.min(99, Number(document.getElementById("ablauf-repeat").value) || 1)),
|
||
confirm: true,
|
||
override_machine: document.getElementById("send-override").checked,
|
||
bed_leveling: document.getElementById("send-bed-level").checked,
|
||
file_id: Number(sel.fileId) || null,
|
||
};
|
||
}
|
||
|
||
async function doAblauf() {
|
||
const note = document.getElementById("ablauf-note");
|
||
note.textContent = "";
|
||
try {
|
||
const payload = await api("/ablauf", { method: "POST", json: collectAblaufPayload() });
|
||
toast((payload.count || 0) > 1 ? payload.count + " Abläufe eingereiht." : "Ablauf eingereiht. Steht unter „Wartet“.", "good");
|
||
if (typeof refreshPrintQueue === "function") refreshPrintQueue();
|
||
refreshPrinters();
|
||
} catch (error) {
|
||
toast(error.message || String(error), "bad");
|
||
}
|
||
updateAblaufButton();
|
||
}
|
||
|
||
/* ---------- Ereignisse ---------- */
|
||
|
||
document.getElementById("lib-list").addEventListener("click", (event) => {
|
||
const del = event.target.closest("[data-delete]");
|
||
if (del) {
|
||
event.stopPropagation();
|
||
armButton(del, "löschen?", async () => {
|
||
try {
|
||
await api("/dateien/" + del.dataset.delete, { method: "DELETE" });
|
||
if (String(sel.fileId) === del.dataset.delete) sel.fileId = null;
|
||
toast("Datei gelöscht.", "good");
|
||
load();
|
||
} catch (error) {
|
||
toast(error.message || String(error), "bad");
|
||
}
|
||
});
|
||
return;
|
||
}
|
||
const item = event.target.closest("[data-file]");
|
||
if (item) selectFile(item.dataset.file);
|
||
});
|
||
document.getElementById("lib-list").addEventListener("keydown", (event) => {
|
||
const item = event.target.closest("[data-file]");
|
||
if (item && (event.key === "Enter" || event.key === " ")) {
|
||
event.preventDefault();
|
||
selectFile(item.dataset.file);
|
||
}
|
||
});
|
||
["file-search", "file-filter-family", "file-filter-project"].forEach((id) => {
|
||
document.getElementById(id).addEventListener("input", renderLibrary);
|
||
document.getElementById(id).addEventListener("change", renderLibrary);
|
||
});
|
||
|
||
document.getElementById("targets").addEventListener("click", (event) => {
|
||
const button = event.target.closest("[data-host]");
|
||
if (!button) return;
|
||
const host = button.dataset.host;
|
||
const p = printers.find((x) => x.host === host);
|
||
if (sel.tab === "ablauf" && p && p.family !== "kobra_3") {
|
||
toast("Abläufe gehen nur auf Kobra 3.", "bad");
|
||
return;
|
||
}
|
||
if (sel.tab === "send" && sel.mode === "next") sel.mode = "free";
|
||
if (sel.hosts.has(host)) sel.hosts.delete(host);
|
||
else sel.hosts.add(host);
|
||
saveSendMemory();
|
||
renderTabs();
|
||
refreshFlow();
|
||
});
|
||
|
||
document.getElementById("mode").addEventListener("click", (event) => {
|
||
const button = event.target.closest("[data-mode]");
|
||
if (!button) return;
|
||
sel.mode = button.dataset.mode;
|
||
saveSendMemory();
|
||
renderTabs();
|
||
refreshFlow();
|
||
});
|
||
|
||
document.querySelector(".tabs").addEventListener("click", (event) => {
|
||
const button = event.target.closest("[data-tab]");
|
||
if (!button) return;
|
||
sel.tab = button.dataset.tab;
|
||
if (sel.tab === "ablauf" && !selectedKobra3().length) {
|
||
const firstK3 = printers.find((p) => p.family === "kobra_3" && isFree(p)) || printers.find((p) => p.family === "kobra_3");
|
||
if (firstK3) sel.hosts.add(firstK3.host);
|
||
}
|
||
saveSendMemory();
|
||
renderTabs();
|
||
refreshFlow();
|
||
});
|
||
|
||
document.getElementById("send-bed-level").addEventListener("change", updateOptionsSummary);
|
||
document.getElementById("send-override").addEventListener("change", () => { refreshFlow(); });
|
||
document.getElementById("send-project")?.addEventListener("change", updateOptionsSummary);
|
||
|
||
document.getElementById("send-go").addEventListener("click", (event) => {
|
||
const button = event.currentTarget;
|
||
const warn = spoolWarnText();
|
||
if (sel.mode === "now") {
|
||
armButton(button, warn ? "Warnung — trotzdem starten?" : "Wirklich starten? Nochmal klicken", doSend);
|
||
} else if (warn) {
|
||
armButton(button, "Warnung — trotzdem einreihen?", doSend);
|
||
} else {
|
||
doSend();
|
||
}
|
||
});
|
||
document.getElementById("ablauf-go").addEventListener("click", (event) => {
|
||
armButton(event.currentTarget, "Ablauf wirklich einreihen? Nochmal klicken", doAblauf);
|
||
});
|
||
|
||
document.getElementById("ablauf-add-print").addEventListener("click", () => addStep(defaultPrintStep()));
|
||
document.getElementById("ablauf-add-cool").addEventListener("click", () => addStep(defaultCoolStep()));
|
||
document.getElementById("ablauf-add-sweep").addEventListener("click", () => addStep(defaultSweepStep()));
|
||
document.getElementById("ablauf-add-check").addEventListener("click", () => addStep(defaultCheckStep()));
|
||
document.getElementById("ablauf-repeat").addEventListener("change", () => { saveAblaufMemory(); updateAblaufButton(); });
|
||
document.querySelectorAll("[data-repeat]").forEach((button) => {
|
||
button.addEventListener("click", () => {
|
||
const input = document.getElementById("ablauf-repeat");
|
||
input.value = String(Math.max(1, Math.min(99, (Number(input.value) || 1) + Number(button.dataset.repeat))));
|
||
saveAblaufMemory();
|
||
updateAblaufButton();
|
||
});
|
||
});
|
||
|
||
document.getElementById("ablauf-test-sweep").addEventListener("click", async () => {
|
||
const k3 = selectedKobra3();
|
||
if (!k3.length) {
|
||
toast("Einen Kobra 3 wählen.", "bad");
|
||
return;
|
||
}
|
||
if (!document.getElementById("ablauf-test-empty").checked) {
|
||
toast("Erst „Bett ist leer“ anhaken. Die Düse fährt 2 mm über dem Bett.", "bad");
|
||
return;
|
||
}
|
||
const sweepStep = ablaufSteps.find((step) => step.type === "sweep") || defaultSweepStep();
|
||
try {
|
||
const result = await api("/schieben-testen", {
|
||
method: "POST",
|
||
json: { host: k3[0].host, confirm_empty: true, sweep: sweepStep },
|
||
});
|
||
toast(k3[0].name + ": " + (result.message || "Bahn gefahren."), "good");
|
||
} catch (error) {
|
||
toast(error.message || String(error), "bad");
|
||
}
|
||
});
|
||
|
||
async function uploadFiles(list) {
|
||
const items = Array.from(list || []);
|
||
if (!items.length) return;
|
||
const note = document.getElementById("file-note");
|
||
const projectId = document.getElementById("upload-project")?.value || "";
|
||
try {
|
||
for (const [i, file] of items.entries()) {
|
||
note.textContent = "Lade hoch " + (i + 1) + "/" + items.length + ": " + file.name;
|
||
const form = new FormData();
|
||
form.append("file", file);
|
||
if (projectId) form.append("project_id", projectId);
|
||
await api("/dateien", { method: "POST", body: form });
|
||
}
|
||
note.textContent = "";
|
||
toast(items.length === 1 ? items[0].name + " hochgeladen." : items.length + " Dateien hochgeladen.", "good");
|
||
await load();
|
||
} catch (error) {
|
||
note.textContent = "";
|
||
toast(error.message || String(error), "bad");
|
||
}
|
||
}
|
||
|
||
document.getElementById("upload-file").addEventListener("change", async (event) => {
|
||
await uploadFiles(event.target.files);
|
||
event.target.value = "";
|
||
});
|
||
const libCard = document.getElementById("lib-card");
|
||
["dragenter", "dragover"].forEach((name) => libCard.addEventListener(name, (event) => {
|
||
event.preventDefault();
|
||
libCard.classList.add("drop");
|
||
}));
|
||
["dragleave", "drop"].forEach((name) => libCard.addEventListener(name, (event) => {
|
||
event.preventDefault();
|
||
if (name === "dragleave" && libCard.contains(event.relatedTarget)) return;
|
||
libCard.classList.remove("drop");
|
||
}));
|
||
libCard.addEventListener("drop", (event) => uploadFiles(event.dataTransfer.files));
|
||
|
||
document.getElementById("rescan").addEventListener("click", async () => {
|
||
try {
|
||
const result = await api("/dateien/neu-lesen", { method: "POST" });
|
||
toast(result.hinweis || (result.gelesen + " Dateien gelesen."), "good");
|
||
load();
|
||
} catch (error) {
|
||
toast(error.message || String(error), "bad");
|
||
}
|
||
});
|
||
|
||
window.onQueueChanged = refreshPrinters;
|
||
loadSendMemory();
|
||
loadAblaufMemory();
|
||
load().catch((error) => toast(error.message || String(error), "bad"));
|
||
setInterval(refreshPrinters, 5000);
|
||
</script>
|
||
{% endblock %}
|