fix: viewer 3MF Bambu/generic, auto-liaison gcode.3mf->3mf, import gcode.3mf NC picker
Deploy via Portainer / deploy (push) Successful in 0s
Deploy via Portainer / deploy (push) Successful in 0s
This commit is contained in:
@@ -286,8 +286,9 @@ def parse_3mf(file_stream):
|
|||||||
Retourne un dict avec :
|
Retourne un dict avec :
|
||||||
- plates : liste de plaques [{index, weight_g, print_time_s, filaments:[{type,color,used_g}]}]
|
- plates : liste de plaques [{index, weight_g, print_time_s, filaments:[{type,color,used_g}]}]
|
||||||
- fallback : {weight_g, hours, minutes} si slice_info absent
|
- fallback : {weight_g, hours, minutes} si slice_info absent
|
||||||
|
- warning : message si le fichier n'a pas été slicé
|
||||||
"""
|
"""
|
||||||
result = {'plates': [], 'weight_g': None, 'hours': None, 'minutes': None}
|
result = {'plates': [], 'weight_g': None, 'hours': None, 'minutes': None, 'warning': None}
|
||||||
try:
|
try:
|
||||||
with zipfile.ZipFile(file_stream) as z:
|
with zipfile.ZipFile(file_stream) as z:
|
||||||
names = z.namelist()
|
names = z.namelist()
|
||||||
@@ -368,6 +369,14 @@ def parse_3mf(file_stream):
|
|||||||
pass
|
pass
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
# Si aucune donnée trouvée, ajouter un avertissement
|
||||||
|
if not result['plates'] and result['weight_g'] is None:
|
||||||
|
result['warning'] = (
|
||||||
|
"Aucune donnée de découpe dans ce fichier. "
|
||||||
|
"Dans Bambu Studio : slicez vos plateaux puis utilisez "
|
||||||
|
"Fichier → Exporter → Exporter le fichier découpé (pas Sauvegarder le projet). "
|
||||||
|
"Le fichier exporté contiendra poids, temps et consommation filament."
|
||||||
|
)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
def calc(weight_g, print_time_s, design_mult, gross_margin_pct, discount_pct,
|
def calc(weight_g, print_time_s, design_mult, gross_margin_pct, discount_pct,
|
||||||
@@ -1556,7 +1565,10 @@ def api_parse_3mf():
|
|||||||
if not f.filename.lower().endswith('.3mf'):
|
if not f.filename.lower().endswith('.3mf'):
|
||||||
return jsonify({'error': 'Format .3mf requis'}), 400
|
return jsonify({'error': 'Format .3mf requis'}), 400
|
||||||
result = parse_3mf(f.stream)
|
result = parse_3mf(f.stream)
|
||||||
result['filename'] = os.path.splitext(f.filename)[0]
|
# Nettoyer .gcode.3mf ou .3mf du nom
|
||||||
|
clean = re.sub(r'\.gcode\.3mf$', '', f.filename, flags=re.IGNORECASE)
|
||||||
|
clean = re.sub(r'\.3mf$', '', clean, flags=re.IGNORECASE)
|
||||||
|
result['filename'] = clean
|
||||||
# Si une seule plaque, pré-sélectionner automatiquement
|
# Si une seule plaque, pré-sélectionner automatiquement
|
||||||
if result['plates'] and len(result['plates']) == 1:
|
if result['plates'] and len(result['plates']) == 1:
|
||||||
p = result['plates'][0]
|
p = result['plates'][0]
|
||||||
@@ -1833,7 +1845,14 @@ def _parse_propfind(xml_bytes, base_url, username, current_path):
|
|||||||
name = rel.split('/')[-1] or rel
|
name = rel.split('/')[-1] or rel
|
||||||
|
|
||||||
# Filtrer : dossiers ou extensions 3D
|
# Filtrer : dossiers ou extensions 3D
|
||||||
ext = name.rsplit('.', 1)[-1].lower() if '.' in name else ''
|
# Gérer .gcode.3mf avant le split simple
|
||||||
|
name_lower = name.lower()
|
||||||
|
if name_lower.endswith('.gcode.3mf'):
|
||||||
|
ext = '3mf'
|
||||||
|
elif '.' in name:
|
||||||
|
ext = name.rsplit('.', 1)[-1].lower()
|
||||||
|
else:
|
||||||
|
ext = ''
|
||||||
if not is_dir and ext not in ('3mf', 'stl', 'step', 'stp', 'obj', 'gcode'):
|
if not is_dir and ext not in ('3mf', 'stl', 'step', 'stp', 'obj', 'gcode'):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
|||||||
+131
-18
@@ -277,6 +277,8 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block scripts %}
|
{% block scripts %}
|
||||||
|
<!-- JSZip pour parser les 3MF Bambu Studio -->
|
||||||
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js"></script>
|
||||||
<script type="importmap">
|
<script type="importmap">
|
||||||
{
|
{
|
||||||
"imports": {
|
"imports": {
|
||||||
@@ -360,14 +362,8 @@ function loadFromUrl(url, ext) {
|
|||||||
errDiv.style.display = 'none';
|
errDiv.style.display = 'none';
|
||||||
if (modelMesh) { scene.remove(modelMesh); modelMesh = null; }
|
if (modelMesh) { scene.remove(modelMesh); modelMesh = null; }
|
||||||
|
|
||||||
const mat = makeMat();
|
|
||||||
const onError = (e) => {
|
|
||||||
loader.style.display = 'none';
|
|
||||||
errDiv.style.display = '';
|
|
||||||
errMsg.textContent = 'Erreur chargement : ' + (e.message || e);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (ext === 'stl') {
|
if (ext === 'stl') {
|
||||||
|
const mat = makeMat();
|
||||||
new STLLoader().load(url, geo => {
|
new STLLoader().load(url, geo => {
|
||||||
geo.computeVertexNormals();
|
geo.computeVertexNormals();
|
||||||
modelMesh = new THREE.Mesh(geo, mat);
|
modelMesh = new THREE.Mesh(geo, mat);
|
||||||
@@ -375,24 +371,141 @@ function loadFromUrl(url, ext) {
|
|||||||
scene.add(modelMesh);
|
scene.add(modelMesh);
|
||||||
loader.style.display = 'none';
|
loader.style.display = 'none';
|
||||||
fitCamera();
|
fitCamera();
|
||||||
}, undefined, onError);
|
}, undefined, (e) => {
|
||||||
} 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';
|
loader.style.display = 'none';
|
||||||
fitCamera();
|
errDiv.style.display = '';
|
||||||
}, undefined, onError);
|
errMsg.textContent = 'Erreur STL : ' + (e.message || e);
|
||||||
|
});
|
||||||
|
|
||||||
|
} else if (ext === '3mf') {
|
||||||
|
// Parser Bambu custom : lit directement les 3D/Objects/*.model
|
||||||
|
loadBambu3MF(url);
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
loader.style.display = 'none';
|
loader.style.display = 'none';
|
||||||
errDiv.style.display = '';
|
errDiv.style.display = '';
|
||||||
errMsg.textContent = `Format .${ext} non supporté dans le viewer (STL et 3MF uniquement).`;
|
errMsg.textContent = `Format .${ext} non supporté (STL et 3MF uniquement).`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadBambu3MF(url) {
|
||||||
|
try {
|
||||||
|
const resp = await fetch(url);
|
||||||
|
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||||
|
const buffer = await resp.arrayBuffer();
|
||||||
|
|
||||||
|
const JSZip = window.JSZip;
|
||||||
|
const zip = await JSZip.loadAsync(buffer);
|
||||||
|
|
||||||
|
const allPositions = [];
|
||||||
|
const tmpVec = new THREE.Vector3();
|
||||||
|
|
||||||
|
// ── Stratégie A : fichiers externes 3D/Objects/*.model (projet Bambu) ──
|
||||||
|
const objectFiles = Object.keys(zip.files)
|
||||||
|
.filter(n => n.startsWith('3D/Objects/') && n.endsWith('.model'));
|
||||||
|
|
||||||
|
if (objectFiles.length) {
|
||||||
|
// Lire les transforms depuis 3dmodel.model
|
||||||
|
const transforms = {};
|
||||||
|
const mainFile = zip.files['3D/3dmodel.model'];
|
||||||
|
if (mainFile) {
|
||||||
|
const mainXml = await mainFile.async('string');
|
||||||
|
const doc = new DOMParser().parseFromString(mainXml, 'text/xml');
|
||||||
|
doc.querySelectorAll('component').forEach(c => {
|
||||||
|
const path = (c.getAttribute('p:path') || '').replace(/^\//, '');
|
||||||
|
const objId = c.getAttribute('objectid');
|
||||||
|
const tStr = c.getAttribute('transform');
|
||||||
|
if (path && objId) transforms[path + '#' + objId] = tStr || null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const objFile of objectFiles) {
|
||||||
|
const xmlStr = await zip.files[objFile].async('string');
|
||||||
|
const doc = new DOMParser().parseFromString(xmlStr, 'text/xml');
|
||||||
|
for (const objEl of doc.querySelectorAll('object')) {
|
||||||
|
const meshEl = objEl.querySelector('mesh');
|
||||||
|
if (!meshEl) continue;
|
||||||
|
const objId = objEl.getAttribute('id');
|
||||||
|
const tStr = transforms[objFile + '#' + objId];
|
||||||
|
let matrix = null;
|
||||||
|
if (tStr) {
|
||||||
|
const v = tStr.trim().split(/\s+/).map(Number);
|
||||||
|
if (v.length === 12) {
|
||||||
|
matrix = new THREE.Matrix4();
|
||||||
|
matrix.set(v[0],v[3],v[6],v[9], v[1],v[4],v[7],v[10], v[2],v[5],v[8],v[11], 0,0,0,1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
parseMeshEl(meshEl, matrix, tmpVec, allPositions);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Stratégie B : géométrie inline dans 3dmodel.model (generic / standard 3MF) ──
|
||||||
|
} else {
|
||||||
|
const mainFile = zip.files['3D/3dmodel.model'];
|
||||||
|
if (!mainFile) throw new Error('Aucun fichier 3D trouvé dans le 3MF');
|
||||||
|
const mainXml = await mainFile.async('string');
|
||||||
|
const doc = new DOMParser().parseFromString(mainXml, 'text/xml');
|
||||||
|
|
||||||
|
// Construire la map id→transform depuis <build><item>
|
||||||
|
const buildTransforms = {};
|
||||||
|
doc.querySelectorAll('build item').forEach(item => {
|
||||||
|
const objId = item.getAttribute('objectid');
|
||||||
|
const tStr = item.getAttribute('transform');
|
||||||
|
buildTransforms[objId] = tStr || null;
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const objEl of doc.querySelectorAll('resources object')) {
|
||||||
|
const meshEl = objEl.querySelector('mesh');
|
||||||
|
if (!meshEl) continue;
|
||||||
|
const objId = objEl.getAttribute('id');
|
||||||
|
const tStr = buildTransforms[objId];
|
||||||
|
let matrix = null;
|
||||||
|
if (tStr) {
|
||||||
|
const v = tStr.trim().split(/\s+/).map(Number);
|
||||||
|
if (v.length === 12) {
|
||||||
|
matrix = new THREE.Matrix4();
|
||||||
|
matrix.set(v[0],v[3],v[6],v[9], v[1],v[4],v[7],v[10], v[2],v[5],v[8],v[11], 0,0,0,1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
parseMeshEl(meshEl, matrix, tmpVec, allPositions);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!allPositions.length) throw new Error('Aucune géométrie dans le 3MF');
|
||||||
|
|
||||||
|
const geo = new THREE.BufferGeometry();
|
||||||
|
geo.setAttribute('position', new THREE.Float32BufferAttribute(allPositions, 3));
|
||||||
|
geo.computeVertexNormals();
|
||||||
|
modelMesh = new THREE.Mesh(geo, makeMat());
|
||||||
|
modelMesh.rotation.x = -Math.PI / 2;
|
||||||
|
scene.add(modelMesh);
|
||||||
|
loader.style.display = 'none';
|
||||||
|
fitCamera();
|
||||||
|
|
||||||
|
} catch (e) {
|
||||||
|
loader.style.display = 'none';
|
||||||
|
errDiv.style.display = '';
|
||||||
|
errMsg.textContent = 'Erreur 3MF : ' + (e.message || e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseMeshEl(meshEl, matrix, tmpVec, allPositions) {
|
||||||
|
const verts = Array.from(meshEl.querySelectorAll('vertices vertex'))
|
||||||
|
.map(v => [+v.getAttribute('x'), +v.getAttribute('y'), +v.getAttribute('z')]);
|
||||||
|
for (const tri of meshEl.querySelectorAll('triangles triangle')) {
|
||||||
|
for (const attr of ['v1', 'v2', 'v3']) {
|
||||||
|
const [x, y, z] = verts[+tri.getAttribute(attr)];
|
||||||
|
if (matrix) {
|
||||||
|
tmpVec.set(x, y, z).applyMatrix4(matrix);
|
||||||
|
allPositions.push(tmpVec.x, tmpVec.y, tmpVec.z);
|
||||||
|
} else {
|
||||||
|
allPositions.push(x, y, z);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
function showUploadZone(ext) {
|
function showUploadZone(ext) {
|
||||||
uploadZone.style.display = '';
|
uploadZone.style.display = '';
|
||||||
canvasWrap.style.display = 'none';
|
canvasWrap.style.display = 'none';
|
||||||
|
|||||||
+25
-8
@@ -23,7 +23,7 @@
|
|||||||
<div class="collapse" id="bambuBody">
|
<div class="collapse" id="bambuBody">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="d-flex align-items-center gap-2 flex-wrap">
|
<div class="d-flex align-items-center gap-2 flex-wrap">
|
||||||
<input type="file" id="file3mf" accept=".3mf" class="form-control" style="max-width:260px">
|
<input type="file" id="file3mf" accept=".3mf,.gcode.3mf" class="form-control" style="max-width:260px">
|
||||||
<button type="button" class="btn btn-outline-warning" onclick="parse3mf()">
|
<button type="button" class="btn btn-outline-warning" onclick="parse3mf()">
|
||||||
<i class="bi bi-magic me-1"></i>Extraire
|
<i class="bi bi-magic me-1"></i>Extraire
|
||||||
</button>
|
</button>
|
||||||
@@ -374,6 +374,11 @@ function parse3mf() {
|
|||||||
if (!document.getElementById('jobName').value)
|
if (!document.getElementById('jobName').value)
|
||||||
document.getElementById('jobName').value = d.filename;
|
document.getElementById('jobName').value = d.filename;
|
||||||
}
|
}
|
||||||
|
// ── Fichier non slicé ──
|
||||||
|
if (d.warning) {
|
||||||
|
status.innerHTML = `<span class="text-warning"><i class="bi bi-exclamation-triangle me-1"></i>${d.warning}</span>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
// ── Cas Bambu avec plaques détectées ──
|
// ── Cas Bambu avec plaques détectées ──
|
||||||
if (d.plates && d.plates.length > 1) {
|
if (d.plates && d.plates.length > 1) {
|
||||||
_3mfPlates = d.plates;
|
_3mfPlates = d.plates;
|
||||||
@@ -386,7 +391,7 @@ function parse3mf() {
|
|||||||
const ok = d.weight_g || d.hours != null;
|
const ok = d.weight_g || d.hours != null;
|
||||||
status.innerHTML = ok
|
status.innerHTML = ok
|
||||||
? '<span class="text-success"><i class="bi bi-check-circle me-1"></i>Données extraites</span>'
|
? '<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>';
|
: '<span class="text-warning"><i class="bi bi-exclamation-triangle me-1"></i>Données non trouvées — vérifiez que le fichier a bien été slicé</span>';
|
||||||
})
|
})
|
||||||
.catch(() => { status.innerHTML = '<span class="text-danger">Erreur de lecture</span>'; });
|
.catch(() => { status.innerHTML = '<span class="text-danger">Erreur de lecture</span>'; });
|
||||||
}
|
}
|
||||||
@@ -790,17 +795,30 @@ let ncPickerMode = 'source'; // 'source' = remplit seulement sourceFile, 'bambu'
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function selectFile(item) {
|
async function selectFile(item) {
|
||||||
// Toujours remplir le fichier source
|
const nameLower = item.name.toLowerCase();
|
||||||
sfInput.value = item.path;
|
const isGcode3mf = nameLower.endsWith('.gcode.3mf');
|
||||||
|
const is3mf = nameLower.endsWith('.3mf');
|
||||||
|
|
||||||
|
// Fichier source : si c'est un gcode.3mf, pointer vers le .3mf projet correspondant
|
||||||
|
if (isGcode3mf) {
|
||||||
|
// Chercher le .3mf projet dans le même dossier
|
||||||
|
const dir = item.path.substring(0, item.path.lastIndexOf('/') + 1);
|
||||||
|
const base = item.name.replace(/\.gcode\.3mf$/i, '');
|
||||||
|
sfInput.value = dir + base + '.3mf';
|
||||||
|
} else {
|
||||||
|
sfInput.value = item.path;
|
||||||
|
}
|
||||||
|
|
||||||
// Auto-remplir le nom de commande si vide
|
// Auto-remplir le nom de commande si vide
|
||||||
const nameField = document.getElementById('jobName');
|
const nameField = document.getElementById('jobName');
|
||||||
if (!nameField.value) {
|
if (!nameField.value) {
|
||||||
nameField.value = item.name.replace(/\.[^.]+$/, '');
|
const cleanName = item.name.replace(/\.gcode\.3mf$/i, '').replace(/\.[^.]+$/, '');
|
||||||
|
nameField.value = cleanName;
|
||||||
}
|
}
|
||||||
bootstrap.Modal.getInstance(modal).hide();
|
bootstrap.Modal.getInstance(modal).hide();
|
||||||
|
|
||||||
// Mode Bambu : récupérer le fichier depuis Nextcloud et le parser comme un 3MF
|
// Mode Bambu : télécharger et parser le 3MF (gcode.3mf ou projet)
|
||||||
if (ncPickerMode === 'bambu' && item.name.toLowerCase().endsWith('.3mf')) {
|
if (ncPickerMode === 'bambu' && (is3mf || isGcode3mf)) {
|
||||||
const status = document.getElementById('parseStatus');
|
const status = document.getElementById('parseStatus');
|
||||||
status.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Téléchargement…';
|
status.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Téléchargement…';
|
||||||
try {
|
try {
|
||||||
@@ -808,7 +826,6 @@ let ncPickerMode = 'source'; // 'source' = remplit seulement sourceFile, 'bambu'
|
|||||||
if (!resp.ok) throw new Error('Erreur téléchargement Nextcloud');
|
if (!resp.ok) throw new Error('Erreur téléchargement Nextcloud');
|
||||||
const blob = await resp.blob();
|
const blob = await resp.blob();
|
||||||
const file = new File([blob], item.name, { type: 'application/octet-stream' });
|
const file = new File([blob], item.name, { type: 'application/octet-stream' });
|
||||||
// Injecter dans l'input local (pour réutiliser parse3mf())
|
|
||||||
const dt = new DataTransfer();
|
const dt = new DataTransfer();
|
||||||
dt.items.add(file);
|
dt.items.add(file);
|
||||||
document.getElementById('file3mf').files = dt.files;
|
document.getElementById('file3mf').files = dt.files;
|
||||||
|
|||||||
Reference in New Issue
Block a user