feat: viewer 3D + parser 3MF par plaque + échecs filament + portail client
Deploy via Portainer / deploy (push) Successful in 0s

This commit is contained in:
Jo
2026-07-07 08:58:12 +02:00
parent e410bfbc16
commit 7b65c4b2e2
11 changed files with 1508 additions and 69 deletions
+183 -13
View File
@@ -50,9 +50,16 @@
</div>
<div class="col-md-4">
<label class="form-label fw-semibold">Fichier source</label>
<input type="text" name="source_file" id="sourceFile" class="form-control"
placeholder="nom_fichier.3mf"
value="{{ prefill.source_file if prefill else '' }}">
<div class="input-group">
<input type="text" name="source_file" id="sourceFile" class="form-control"
placeholder="nom_fichier.3mf"
value="{{ prefill.source_file if prefill else '' }}">
<button type="button" class="btn btn-outline-secondary" id="ncBrowseBtn"
data-bs-toggle="modal" data-bs-target="#ncPickerModal"
title="Parcourir Nextcloud">
<i class="bi bi-cloud-arrow-down"></i>
</button>
</div>
</div>
</div>
<div class="row g-3 mb-3">
@@ -282,35 +289,109 @@
</div>
</div>
</div>
<!-- Modale Nextcloud File Picker -->
<div class="modal fade" id="ncPickerModal" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<div class="flex-fill">
<h5 class="modal-title mb-1"><i class="bi bi-cloud me-2"></i>Nextcloud — Choisir un fichier</h5>
<nav aria-label="breadcrumb">
<ol class="breadcrumb mb-0 small" id="ncBreadcrumb"></ol>
</nav>
</div>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body p-0" id="ncPickerBody" style="max-height:60vh;overflow-y:auto">
<div class="text-center text-muted py-5">
<i class="bi bi-cloud fs-1 d-block mb-2"></i>
Cliquez sur le bouton <i class="bi bi-cloud-arrow-down"></i> pour ouvrir.
</div>
</div>
<div class="modal-footer py-2">
<small class="text-muted me-auto">Cliquez sur un fichier pour le sélectionner.</small>
<button type="button" class="btn btn-sm btn-outline-secondary" data-bs-dismiss="modal">Annuler</button>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
// ── 3MF parser ────────────────────────────────────────────────────────────────
// ── 3MF parser avec sélecteur de plaque ──────────────────────────────────────
let _3mfPlates = [];
function parse3mf() {
const file = document.getElementById('file3mf').files[0];
if (!file) { alert('Selectionnez un fichier .3mf'); return; }
const status = document.getElementById('parseStatus');
status.textContent = 'Analyse...';
status.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Analyse';
const fd = new FormData();
fd.append('file', file);
fetch('/api/parse-3mf', {method:'POST', body:fd})
.then(r => r.json())
.then(d => {
let filled = [];
if (d.weight_g) { document.getElementById('weight_g').value = d.weight_g; filled.push('poids'); }
if (d.hours != null) { document.getElementById('hours').value = d.hours; filled.push('heures'); }
if (d.minutes != null){ document.getElementById('minutes').value = d.minutes; }
if (d.filename) {
document.getElementById('sourceFile').value = d.filename;
if (!document.getElementById('jobName').value)
document.getElementById('jobName').value = d.filename;
}
status.textContent = filled.length ? 'Extrait: ' + filled.join(', ') : 'Donnees non trouvees';
status.style.color = filled.length ? '#22c55e' : '#f59e0b';
recalc();
// ── Cas Bambu avec plaques détectées ──
if (d.plates && d.plates.length > 1) {
_3mfPlates = d.plates;
renderPlateSelector(d.plates);
status.innerHTML = `<span class="text-success">${d.plates.length} plateau(x) détecté(s) — choisissez ci-dessous</span>`;
return;
}
// ── Plaque unique ou fallback ──
applyPlateData(d.weight_g, d.hours, d.minutes);
const ok = d.weight_g || d.hours != null;
status.innerHTML = ok
? '<span class="text-success"><i class="bi bi-check-circle me-1"></i>Données extraites</span>'
: '<span class="text-warning"><i class="bi bi-exclamation-triangle me-1"></i>Données non trouvées</span>';
})
.catch(() => { status.textContent = 'Erreur de lecture'; status.style.color = '#ef4444'; });
.catch(() => { status.innerHTML = '<span class="text-danger">Erreur de lecture</span>'; });
}
function applyPlateData(weight_g, hours, minutes) {
if (weight_g != null) document.getElementById('weight_g').value = weight_g;
if (hours != null) document.getElementById('hours').value = hours;
if (minutes != null) document.getElementById('minutes').value = minutes;
recalc();
}
function renderPlateSelector(plates) {
// Supprimer un éventuel sélecteur précédent
document.getElementById('plateSelectorWrap')?.remove();
const wrap = document.createElement('div');
wrap.id = 'plateSelectorWrap';
wrap.className = 'mt-3 border rounded p-3 bg-light';
wrap.innerHTML = '<div class="fw-semibold small mb-2"><i class="bi bi-layers me-1"></i>Choisir le plateau à imprimer :</div>';
const grid = document.createElement('div');
grid.className = 'd-flex flex-wrap gap-2';
plates.forEach(p => {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'btn btn-sm btn-outline-warning';
// Pastilles filaments
const dots = p.filaments.map(f =>
`<span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:${f.color || '#ccc'};border:1px solid #aaa;margin-right:2px"></span>`
).join('');
btn.innerHTML = `${dots}<strong>Plateau ${p.index}</strong> — ${p.weight_g} g — ${Math.floor(p.print_time_s/3600)}h${String(Math.floor((p.print_time_s%3600)/60)).padStart(2,'0')}m`;
btn.addEventListener('click', () => {
document.querySelectorAll('#plateSelectorWrap button').forEach(b => b.classList.replace('btn-warning','btn-outline-warning'));
btn.classList.replace('btn-outline-warning','btn-warning');
applyPlateData(p.weight_g, Math.floor(p.print_time_s/3600), Math.floor((p.print_time_s%3600)/60));
document.getElementById('parseStatus').innerHTML =
`<span class="text-success"><i class="bi bi-check-circle me-1"></i>Plateau ${p.index} appliqué</span>`;
});
grid.appendChild(btn);
});
wrap.appendChild(grid);
// Insérer après le card d'import
document.querySelector('.card.border-warning').insertAdjacentElement('afterend', wrap);
}
// ── Coefficient design ────────────────────────────────────────────────────────
@@ -595,5 +676,94 @@ const _initMargin = parseFloat(document.getElementById('gross_margin_pct').value
updateDMLabel(_initDM);
updateMarginDisplay(_initMargin);
if (document.getElementById('weight_g').value) recalc();
// ── Nextcloud File Picker ─────────────────────────────────────────────────────
(function () {
const modal = document.getElementById('ncPickerModal');
const body = document.getElementById('ncPickerBody');
const bread = document.getElementById('ncBreadcrumb');
const sfInput = document.getElementById('sourceFile');
if (!modal) return;
let currentPath = '';
async function browse(path) {
body.innerHTML = '<div class="text-center py-4"><div class="spinner-border spinner-border-sm"></div> Chargement…</div>';
try {
const r = await fetch('/api/nextcloud/browse?path=' + encodeURIComponent(path));
const d = await r.json();
if (d.error) {
body.innerHTML = `<div class="alert alert-danger m-3">${d.error}</div>`;
return;
}
currentPath = d.path;
renderBreadcrumb(d.breadcrumb);
renderItems(d.items);
} catch (e) {
body.innerHTML = `<div class="alert alert-danger m-3">${e.message}</div>`;
}
}
function renderBreadcrumb(crumbs) {
bread.innerHTML = crumbs.map((c, i) =>
i < crumbs.length - 1
? `<li class="breadcrumb-item"><a href="#" class="nc-crumb" data-path="${c.path}">${c.name}</a></li>`
: `<li class="breadcrumb-item active">${c.name}</li>`
).join('');
bread.querySelectorAll('.nc-crumb').forEach(a =>
a.addEventListener('click', e => { e.preventDefault(); browse(a.dataset.path); }));
}
const EXT_ICONS = {
'3mf': 'bi-box-seam text-warning',
'stl': 'bi-triangle text-info',
'step': 'bi-bezier2 text-success',
'stp': 'bi-bezier2 text-success',
'obj': 'bi-grid-3x3 text-secondary',
'gcode':'bi-code-square text-danger',
'dir': 'bi-folder-fill text-warning',
};
function renderItems(items) {
if (!items.length) {
body.innerHTML = '<div class="text-center text-muted py-4">Dossier vide.</div>';
return;
}
body.innerHTML = '';
const list = document.createElement('div');
list.className = 'list-group list-group-flush';
items.forEach(item => {
const icon = EXT_ICONS[item.ext] || 'bi-file text-muted';
const size = item.is_dir ? '' : ` <small class="text-muted">(${(item.size/1024/1024).toFixed(1)} Mo)</small>`;
const el = document.createElement('button');
el.type = 'button';
el.className = 'list-group-item list-group-item-action d-flex align-items-center gap-2 py-2';
el.innerHTML = `<i class="bi ${icon} fs-5" style="flex-shrink:0"></i>
<span class="flex-fill text-start">${item.name}${size}</span>
${item.is_dir ? '<i class="bi bi-chevron-right text-muted"></i>' : '<i class="bi bi-check2-circle text-success"></i>'}`;
if (item.is_dir) {
el.addEventListener('click', () => browse(item.path));
} else {
el.addEventListener('click', () => selectFile(item));
}
list.appendChild(el);
});
body.appendChild(list);
}
function selectFile(item) {
sfInput.value = item.path;
// Auto-remplir le nom de commande si vide
const nameField = document.getElementById('jobName');
if (!nameField.value) {
nameField.value = item.name.replace(/\.[^.]+$/, '');
}
bootstrap.Modal.getInstance(modal).hide();
}
// Ouvrir = charger le dossier racine
modal.addEventListener('show.bs.modal', () => { if (!currentPath) browse(''); });
})();
</script>
{% endblock %}