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
+249 -2
View File
@@ -9,7 +9,20 @@
</a>
<h1 class="mt-1">{{ job.name }}</h1>
</div>
<div class="d-flex gap-2">
<div class="d-flex gap-2 flex-wrap">
<button class="btn btn-sm btn-outline-secondary" id="shareBtn"
title="{{ 'Lien client actif — cliquer pour copier' if job.client_token else 'Générer un lien client' }}">
{% if job.client_token %}
<i class="bi bi-share-fill me-1 text-success"></i>Lien client
{% else %}
<i class="bi bi-share me-1"></i>Partager
{% endif %}
</button>
{% if job.client_token %}
<button class="btn btn-sm btn-outline-danger" id="revokeBtn" title="Révoquer le lien">
<i class="bi bi-x-circle"></i>
</button>
{% endif %}
<a href="{{ url_for('new_job', clone_from=job.id) }}" class="btn btn-sm btn-outline-secondary">
<i class="bi bi-copy me-1"></i>Dupliquer
</a>
@@ -38,7 +51,20 @@
</td>
</tr>
<tr><th class="text-muted fw-normal">Fichier source</th>
<td class="small font-monospace">{{ job.source_file or '—' }}</td></tr>
<td class="small">
{% if job.source_file %}
<span class="font-monospace">{{ job.source_file.split('/')[-1] }}</span>
{% set ext = job.source_file.rsplit('.',1)[-1].lower() %}
{% if ext in ['stl','3mf','step','stp','obj'] %}
<button class="btn btn-xs btn-outline-secondary py-0 px-1 ms-1"
style="font-size:.7rem"
data-bs-toggle="modal" data-bs-target="#viewerModal"
data-file="{{ job.source_file }}" data-ext="{{ ext }}">
<i class="bi bi-box me-1"></i>Voir
</button>
{% endif %}
{% else %}—{% endif %}
</td></tr>
<tr><th class="text-muted fw-normal">Date</th><td>{{ job.created_at[:10] }}</td></tr>
<tr><th class="text-muted fw-normal">Poids plateau</th><td>{{ job.weight_g }} g</td></tr>
<tr><th class="text-muted fw-normal">Pieces / plateau</th><td>{{ job.pieces_per_plate or 1 }}</td></tr>
@@ -207,4 +233,225 @@
</div>
</div>
</div>
<!-- ── Modale viewer 3D ──────────────────────────────────────────────────── -->
<div class="modal fade" id="viewerModal" tabindex="-1">
<div class="modal-dialog modal-xl modal-dialog-centered">
<div class="modal-content" style="background:#1a1d23">
<div class="modal-header border-0 pb-0">
<span class="text-white fw-semibold" id="viewerTitle">Viewer 3D</span>
<div class="ms-auto d-flex gap-2">
<button class="btn btn-sm btn-outline-light" id="viewerWire">Wireframe</button>
<button class="btn btn-sm btn-outline-light" id="viewerReset">Reset vue</button>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
</div>
</div>
<div class="modal-body p-0 position-relative" style="height:75vh">
<canvas id="viewerCanvas" style="width:100%;height:100%;display:block"></canvas>
<div id="viewerLoader" class="position-absolute top-50 start-50 translate-middle text-white text-center">
<div class="spinner-border mb-2"></div><br>Chargement du modèle…
</div>
<div id="viewerError" class="position-absolute top-50 start-50 translate-middle text-danger text-center" style="display:none">
<i class="bi bi-exclamation-triangle fs-1 d-block mb-2"></i>
<span id="viewerErrorMsg"></span>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script type="importmap">
{
"imports": {
"three": "https://unpkg.com/three@0.160.0/build/three.module.js",
"three/addons/": "https://unpkg.com/three@0.160.0/examples/jsm/"
}
}
</script>
<script type="module">
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
import { STLLoader } from 'three/addons/loaders/STLLoader.js';
import { ThreeMFLoader } from 'three/addons/loaders/3MFLoader.js';
const modal = document.getElementById('viewerModal');
const canvas = document.getElementById('viewerCanvas');
const loader = document.getElementById('viewerLoader');
const errDiv = document.getElementById('viewerError');
const errMsg = document.getElementById('viewerErrorMsg');
const title = document.getElementById('viewerTitle');
let renderer, scene, camera, controls, modelMesh;
let wireMode = false;
function initRenderer() {
if (renderer) return;
renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: true });
renderer.setPixelRatio(window.devicePixelRatio);
renderer.setClearColor(0x1a1d23, 1);
renderer.shadowMap.enabled = true;
scene = new THREE.Scene();
camera = new THREE.PerspectiveCamera(45, canvas.clientWidth / canvas.clientHeight, 0.1, 10000);
controls = new OrbitControls(camera, canvas);
controls.enableDamping = true;
controls.dampingFactor = 0.08;
// Lumières
const amb = new THREE.AmbientLight(0xffffff, 0.5);
const dir = new THREE.DirectionalLight(0xffffff, 1.2);
dir.position.set(5, 10, 7);
scene.add(amb, dir);
// Grille
const grid = new THREE.GridHelper(300, 30, 0x444444, 0x333333);
scene.add(grid);
function animate() {
requestAnimationFrame(animate);
controls.update();
renderer.setSize(canvas.clientWidth, canvas.clientHeight, false);
camera.aspect = canvas.clientWidth / canvas.clientHeight;
camera.updateProjectionMatrix();
renderer.render(scene, camera);
}
animate();
}
function fitCamera() {
if (!modelMesh) return;
const box = new THREE.Box3().setFromObject(modelMesh);
const size = box.getSize(new THREE.Vector3());
const center = box.getCenter(new THREE.Vector3());
const maxDim = Math.max(size.x, size.y, size.z);
camera.position.set(center.x + maxDim * 1.5, center.y + maxDim, center.z + maxDim * 1.5);
controls.target.copy(center);
controls.update();
}
function loadModel(path, ext) {
loader.style.display = '';
errDiv.style.display = 'none';
if (modelMesh) { scene.remove(modelMesh); modelMesh = null; }
const url = '/api/nextcloud/file?path=' + encodeURIComponent(path);
const mat = new THREE.MeshPhongMaterial({ color: 0xff6b35, specular: 0x333333, shininess: 40, side: THREE.DoubleSide });
const onError = (e) => {
loader.style.display = 'none';
errDiv.style.display = '';
errMsg.textContent = 'Erreur chargement : ' + (e.message || e);
};
if (ext === 'stl') {
new STLLoader().load(url, geo => {
geo.computeVertexNormals();
modelMesh = new THREE.Mesh(geo, mat);
modelMesh.rotation.x = -Math.PI / 2;
scene.add(modelMesh);
loader.style.display = 'none';
fitCamera();
}, undefined, onError);
} else if (ext === '3mf') {
new ThreeMFLoader().load(url, obj => {
obj.traverse(child => {
if (child.isMesh) child.material = mat.clone();
});
modelMesh = obj;
scene.add(modelMesh);
loader.style.display = 'none';
fitCamera();
}, undefined, onError);
} else {
loader.style.display = 'none';
errDiv.style.display = '';
errMsg.textContent = `Format .${ext} non supporté dans le viewer (STL et 3MF uniquement).`;
}
}
// Ouvrir la modale
modal.addEventListener('show.bs.modal', e => {
const btn = e.relatedTarget;
const path = btn.dataset.file;
const ext = btn.dataset.ext;
title.textContent = path.split('/').pop();
initRenderer();
loadModel(path, ext);
});
// Fermer → libérer
modal.addEventListener('hidden.bs.modal', () => {
if (modelMesh) { scene.remove(modelMesh); modelMesh = null; }
});
// Wireframe toggle
document.getElementById('viewerWire').addEventListener('click', function () {
wireMode = !wireMode;
this.classList.toggle('active', wireMode);
if (modelMesh) modelMesh.traverse(c => {
if (c.isMesh) c.material.wireframe = wireMode;
});
});
// Reset vue
document.getElementById('viewerReset').addEventListener('click', fitCamera);
</script>
<script>
// ── Partage client ───────────────────────────────────────────────────────────
const jobId = {{ job.id }};
const shareBtn = document.getElementById('shareBtn');
const revokeBtn= document.getElementById('revokeBtn');
let currentToken = {{ '"' + job.client_token + '"' if job.client_token else 'null' }};
if (shareBtn) {
shareBtn.addEventListener('click', async () => {
if (currentToken) {
// Copier l'URL existante
const url = location.origin + '/portal/' + currentToken;
await navigator.clipboard.writeText(url);
shareBtn.innerHTML = '<i class="bi bi-check-circle-fill me-1 text-success"></i>Copié !';
setTimeout(() => {
shareBtn.innerHTML = '<i class="bi bi-share-fill me-1 text-success"></i>Lien client';
}, 2000);
} else {
// Générer un nouveau token
const r = await fetch(`/jobs/${jobId}/generate-token`, {method:'POST'});
const d = await r.json();
currentToken = d.token;
await navigator.clipboard.writeText(d.url);
shareBtn.innerHTML = '<i class="bi bi-check-circle-fill me-1 text-success"></i>Lien copié !';
setTimeout(() => {
shareBtn.innerHTML = '<i class="bi bi-share-fill me-1 text-success"></i>Lien client';
}, 2500);
// Ajouter bouton révoquer
if (!document.getElementById('revokeBtn')) {
const rb = document.createElement('button');
rb.id = 'revokeBtn';
rb.className = 'btn btn-sm btn-outline-danger';
rb.title = 'Révoquer le lien';
rb.innerHTML = '<i class="bi bi-x-circle"></i>';
shareBtn.insertAdjacentElement('afterend', rb);
rb.addEventListener('click', revokeLink);
}
}
});
}
async function revokeLink() {
if (!confirm('Révoquer le lien client ? Il ne sera plus accessible.')) return;
await fetch(`/jobs/${jobId}/revoke-token`, {method:'POST'});
currentToken = null;
shareBtn.innerHTML = '<i class="bi bi-share me-1"></i>Partager';
shareBtn.title = 'Générer un lien client';
document.getElementById('revokeBtn')?.remove();
}
if (revokeBtn) revokeBtn.addEventListener('click', revokeLink);
</script>
{% endblock %}