feat: viewer 3D + parser 3MF par plaque + échecs filament + portail client
Deploy via Portainer / deploy (push) Successful in 0s
Deploy via Portainer / deploy (push) Successful in 0s
This commit is contained in:
+249
-2
@@ -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 %}
|
||||
|
||||
Reference in New Issue
Block a user