PrintVault: GCode 3D path viewer with layer color gradient
Deploy HouseHub / deploy (push) Successful in 2s
Deploy HouseHub / deploy (push) Successful in 2s
- New API endpoint gcode_paths: parses G0/G1 moves, returns segments (max 30k) - Three.js LineSegments renderer: extrusion moves colored by Z height Blue(low) → Cyan → Green → Yellow → Red(high), travel moves dimmed - Legend overlay showing color scale - GCode files now show viewer + metadata cards (layout unchanged) - PHP syntax validated, API endpoint tested before deploy Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
b756517ab5
commit
811d186b46
@@ -238,6 +238,51 @@ if ($action === 'models') {
|
||||
}
|
||||
}
|
||||
|
||||
// ── GCODE PATHS ────────────────────────────────────────────────────────────────
|
||||
if ($action === 'gcode_paths') {
|
||||
$id = (int)($_GET['id'] ?? 0);
|
||||
$max = min((int)($_GET['max'] ?? 30000), 80000);
|
||||
$s = $pdo->prepare("SELECT filename, file_type FROM pf_pv_models WHERE id=?"); $s->execute([$id]);
|
||||
$row = $s->fetch(); if (!$row) pvErr('Introuvable', 404);
|
||||
$ext = strtolower($row['file_type']);
|
||||
if (!in_array($ext, ['gcode','gco','g'])) pvErr('Pas un fichier GCode');
|
||||
$path = PV_MODEL_DIR . $row['filename'];
|
||||
if (!file_exists($path)) pvErr('Fichier introuvable', 404);
|
||||
|
||||
$segments = [];
|
||||
$x=0.0; $y=0.0; $z=0.0; $e=0.0; $lastE=0.0;
|
||||
$minZ=PHP_FLOAT_MAX; $maxZ=0.0;
|
||||
|
||||
$handle = fopen($path, 'r');
|
||||
while (!feof($handle) && count($segments) < $max) {
|
||||
$line = trim(fgets($handle));
|
||||
if (($p = strpos($line, ';')) !== false) $line = substr($line, 0, $p);
|
||||
$line = trim($line);
|
||||
if (!$line) continue;
|
||||
$cmd = strtoupper(strtok($line, ' '));
|
||||
if ($cmd === 'G92') {
|
||||
if (preg_match('/E([-\d.]+)/i', $line, $m)) $lastE = (float)$m[1];
|
||||
continue;
|
||||
}
|
||||
if ($cmd !== 'G0' && $cmd !== 'G1') continue;
|
||||
$nx=$x; $ny=$y; $nz=$z; $ne=$e;
|
||||
if (preg_match('/X([-\d.]+)/i', $line, $m)) $nx = (float)$m[1];
|
||||
if (preg_match('/Y([-\d.]+)/i', $line, $m)) $ny = (float)$m[1];
|
||||
if (preg_match('/Z([-\d.]+)/i', $line, $m)) $nz = (float)$m[1];
|
||||
if (preg_match('/E([-\d.]+)/i', $line, $m)) $ne = (float)$m[1];
|
||||
$extruding = ($cmd === 'G1' && $ne > $lastE) ? 1 : 0;
|
||||
if ($nx !== $x || $ny !== $y || $nz !== $z) {
|
||||
$segments[] = [$x, $y, $z, $nx, $ny, $nz, $extruding];
|
||||
$minZ = min($minZ, $nz);
|
||||
$maxZ = max($maxZ, $nz);
|
||||
}
|
||||
$x=$nx; $y=$ny; $z=$nz;
|
||||
if ($ne !== $e) { $lastE = $e = $ne; }
|
||||
}
|
||||
fclose($handle);
|
||||
pvOk(['segments' => $segments, 'min_z' => $minZ === PHP_FLOAT_MAX ? 0 : $minZ, 'max_z' => $maxZ, 'total' => count($segments)]);
|
||||
}
|
||||
|
||||
// ── FILE SERVE ─────────────────────────────────────────────────────────────────
|
||||
if ($action === 'file') {
|
||||
$id = (int)($_GET['id'] ?? 0);
|
||||
|
||||
@@ -146,6 +146,20 @@
|
||||
font-size: .72rem; color: var(--text-muted); line-height: 1.3;
|
||||
}
|
||||
|
||||
/* GCode legend */
|
||||
.pv-gcode-viewer-legend {
|
||||
position: absolute; bottom: 2.5rem; left: 1rem;
|
||||
background: rgba(15,23,42,.8); backdrop-filter: blur(4px);
|
||||
border: 1px solid rgba(255,255,255,.1);
|
||||
border-radius: 8px; padding: .5rem .75rem;
|
||||
display: flex; flex-direction: column; gap: .3rem;
|
||||
pointer-events: none;
|
||||
}
|
||||
.pv-legend-item {
|
||||
font-size: .72rem; color: #94a3b8;
|
||||
display: flex; align-items: center; gap: .5rem;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 900px) {
|
||||
.pv-detail-body { grid-template-columns: 1fr; grid-template-rows: 55vh 1fr; }
|
||||
|
||||
+97
-17
@@ -14,7 +14,7 @@ $s2 = $pdo->query("SELECT name, color FROM pf_pv_categories ORDER BY name");
|
||||
$categories = $s2->fetchAll();
|
||||
|
||||
$ext = strtolower($model['file_type']);
|
||||
$canView = in_array($ext, ['stl','3mf']);
|
||||
$canView = in_array($ext, ['stl','3mf','gcode','gco','g']);
|
||||
$isGcode = in_array($ext, ['gcode','gco','g']);
|
||||
$thumbUrl = $model['thumb'] ? '/uploads/printvault/thumbs/' . rawurlencode($model['thumb']) : null;
|
||||
$typeIcon = ['stl'=>'🟣','3mf'=>'🔵','gcode'=>'🟢','gco'=>'🟢','g'=>'🟢'][$ext] ?? '📄';
|
||||
@@ -65,7 +65,17 @@ require __DIR__ . '/header.php';
|
||||
</div>
|
||||
<canvas id="viewer-canvas"></canvas>
|
||||
<div class="pv-viewer-controls-hint">Clic+glisser : rotation · Scroll : zoom</div>
|
||||
<?php if (!$isGcode): ?>
|
||||
<button class="pv-viewer-screenshot-btn" onclick="takeScreenshot()" title="Sauvegarder comme aperçu">📸</button>
|
||||
<?php endif; ?>
|
||||
<?php if ($isGcode): ?>
|
||||
<div class="pv-gcode-viewer-legend" id="gcode-legend">
|
||||
<div class="pv-legend-item"><span style="display:inline-block;width:12px;height:3px;background:#3b82f6;border-radius:2px;vertical-align:middle"></span> Couches basses</div>
|
||||
<div class="pv-legend-item"><span style="display:inline-block;width:12px;height:3px;background:#10b981;border-radius:2px;vertical-align:middle"></span> Couches moyennes</div>
|
||||
<div class="pv-legend-item"><span style="display:inline-block;width:12px;height:3px;background:#ef4444;border-radius:2px;vertical-align:middle"></span> Couches hautes</div>
|
||||
<div class="pv-legend-item" style="color:#475569"><span style="display:inline-block;width:12px;height:1px;background:#475569;border-radius:2px;vertical-align:middle;opacity:.5"></span> Déplacements</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<?php elseif ($thumbUrl): ?>
|
||||
<div class="pv-detail-thumb-large">
|
||||
@@ -241,10 +251,12 @@ document.querySelectorAll('.pv-modal-backdrop').forEach(m => m.addEventListener(
|
||||
<script type="module">
|
||||
import * as THREE from 'three';
|
||||
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
|
||||
<?php if (!$isGcode): ?>
|
||||
import { STLLoader } from 'three/addons/loaders/STLLoader.js';
|
||||
<?php if ($ext === '3mf'): ?>
|
||||
import { ThreeMFLoader } from 'three/addons/loaders/3MFLoader.js';
|
||||
<?php endif; ?>
|
||||
<?php endif; ?>
|
||||
|
||||
const canvas = document.getElementById('viewer-canvas');
|
||||
const wrap = document.getElementById('viewer-wrap');
|
||||
@@ -256,11 +268,11 @@ const key = new THREE.DirectionalLight(0xffffff, 1.2); key.position.set(5,10,7.5
|
||||
const fill= new THREE.DirectionalLight(0x8ab4f8, 0.4); fill.position.set(-5,-3,-5); scene.add(fill);
|
||||
const rim = new THREE.DirectionalLight(0xffffff, 0.3); rim.position.set(0,5,-10); scene.add(rim);
|
||||
|
||||
const grid = new THREE.GridHelper(200,40,0x1e293b,0x1e293b);
|
||||
const grid = new THREE.GridHelper(300,50,0x1e293b,0x1e293b);
|
||||
grid.material.opacity=0.4; grid.material.transparent=true; scene.add(grid);
|
||||
|
||||
const camera = new THREE.PerspectiveCamera(45, wrap.clientWidth/wrap.clientHeight, 0.1, 10000);
|
||||
camera.position.set(100,100,100);
|
||||
camera.position.set(150,150,150);
|
||||
|
||||
const renderer = new THREE.WebGLRenderer({canvas, antialias:true, preserveDrawingBuffer:true});
|
||||
renderer.setPixelRatio(window.devicePixelRatio);
|
||||
@@ -269,20 +281,87 @@ renderer.setSize(wrap.clientWidth, wrap.clientHeight);
|
||||
const controls = new OrbitControls(camera, renderer.domElement);
|
||||
controls.enableDamping=true; controls.dampingFactor=0.05;
|
||||
|
||||
const material = new THREE.MeshPhysicalMaterial({color:0x4cc9f0,metalness:0.3,roughness:0.4,side:THREE.DoubleSide});
|
||||
|
||||
let meshGroup = null;
|
||||
function fitCamera(obj) {
|
||||
const box = new THREE.Box3().setFromObject(obj);
|
||||
const size = box.getSize(new THREE.Vector3());
|
||||
const center = box.getCenter(new THREE.Vector3());
|
||||
const dist = Math.abs(Math.max(size.x,size.y,size.z)/Math.sin(camera.fov*Math.PI/180/2))*0.75;
|
||||
camera.position.set(center.x+dist*.7, center.y+dist*.5, center.z+dist*.7);
|
||||
const dist = Math.abs(Math.max(size.x,size.y,size.z)/Math.sin(camera.fov*Math.PI/180/2))*0.8;
|
||||
camera.position.set(center.x+dist*.7, center.y+dist*.6, center.z+dist*.7);
|
||||
controls.target.copy(center);
|
||||
grid.position.y = box.min.y;
|
||||
grid.position.y = box.min.y - 0.5;
|
||||
controls.update();
|
||||
}
|
||||
|
||||
<?php if ($isGcode): ?>
|
||||
// ── GCode path viewer ─────────────────────────────────────────────────────────
|
||||
function zToColor(z, minZ, maxZ) {
|
||||
const t = maxZ > minZ ? (z - minZ) / (maxZ - minZ) : 0;
|
||||
// Blue(0,0,1) → Cyan(0,1,1) → Green(0,1,0) → Yellow(1,1,0) → Red(1,0,0)
|
||||
let r, g, b;
|
||||
if (t < 0.25) { r=0; g=t*4; b=1; }
|
||||
else if (t < 0.5) { r=0; g=1; b=1-(t-0.25)*4; }
|
||||
else if (t < 0.75) { r=(t-0.5)*4; g=1; b=0; }
|
||||
else { r=1; g=1-(t-0.75)*4; b=0; }
|
||||
return new THREE.Color(r, g, b);
|
||||
}
|
||||
|
||||
document.getElementById('viewer-loading-text').textContent = 'Chargement des trajectoires…';
|
||||
|
||||
fetch('/modules/printvault/api.php?action=gcode_paths&id=<?= $id ?>&max=30000', {
|
||||
credentials: 'same-origin',
|
||||
headers: {'X-Requested-With':'XMLHttpRequest','Accept':'application/json'}
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(j => {
|
||||
if (!j.ok) { document.getElementById('viewer-loading-text').textContent = j.error; return; }
|
||||
const {segments, min_z, max_z} = j.data;
|
||||
|
||||
// Separate extrusion and travel moves
|
||||
const extPositions = [];
|
||||
const extColors = [];
|
||||
const travelPositions = [];
|
||||
|
||||
segments.forEach(([x1,y1,z1, x2,y2,z2, ext]) => {
|
||||
if (ext) {
|
||||
const col = zToColor(z1, min_z, max_z);
|
||||
extPositions.push(x1,z1,-y1, x2,z2,-y2); // swap Y/Z for Three.js
|
||||
extColors.push(col.r,col.g,col.b, col.r,col.g,col.b);
|
||||
} else {
|
||||
travelPositions.push(x1,z1,-y1, x2,z2,-y2);
|
||||
}
|
||||
});
|
||||
|
||||
const group = new THREE.Group();
|
||||
|
||||
if (extPositions.length) {
|
||||
const geo = new THREE.BufferGeometry();
|
||||
geo.setAttribute('position', new THREE.Float32BufferAttribute(extPositions, 3));
|
||||
geo.setAttribute('color', new THREE.Float32BufferAttribute(extColors, 3));
|
||||
const mat = new THREE.LineBasicMaterial({vertexColors: true, linewidth: 1});
|
||||
group.add(new THREE.LineSegments(geo, mat));
|
||||
}
|
||||
if (travelPositions.length) {
|
||||
const geo = new THREE.BufferGeometry();
|
||||
geo.setAttribute('position', new THREE.Float32BufferAttribute(travelPositions, 3));
|
||||
const mat = new THREE.LineBasicMaterial({color: 0x334155, linewidth: 1, transparent: true, opacity: 0.3});
|
||||
group.add(new THREE.LineSegments(geo, mat));
|
||||
}
|
||||
|
||||
scene.add(group);
|
||||
fitCamera(group);
|
||||
document.getElementById('viewer-loading').style.display = 'none';
|
||||
|
||||
const total = segments.length;
|
||||
const extCount = segments.filter(s=>s[6]).length;
|
||||
document.getElementById('viewer-loading-text').textContent = `${total.toLocaleString()} segments (${extCount.toLocaleString()} extrusion)`;
|
||||
})
|
||||
.catch(err => { document.getElementById('viewer-loading-text').textContent = 'Erreur: '+err.message; });
|
||||
|
||||
<?php else: ?>
|
||||
// ── STL / 3MF viewer ─────────────────────────────────────────────────────────
|
||||
const material = new THREE.MeshPhysicalMaterial({color:0x4cc9f0,metalness:0.3,roughness:0.4,side:THREE.DoubleSide});
|
||||
let meshGroup = null;
|
||||
|
||||
<?php if ($ext === 'stl'): ?>
|
||||
new STLLoader().load('/modules/printvault/api.php?action=model_data&id=<?= $id ?>',
|
||||
geo => {
|
||||
@@ -306,14 +385,6 @@ new ThreeMFLoader().load('/modules/printvault/api.php?action=model_data&id=<?= $
|
||||
);
|
||||
<?php endif; ?>
|
||||
|
||||
(function animate(){requestAnimationFrame(animate);controls.update();renderer.render(scene,camera);})();
|
||||
|
||||
window.addEventListener('resize',()=>{
|
||||
renderer.setSize(wrap.clientWidth,wrap.clientHeight);
|
||||
camera.aspect=wrap.clientWidth/wrap.clientHeight;
|
||||
camera.updateProjectionMatrix();
|
||||
});
|
||||
|
||||
window.takeScreenshot = async () => {
|
||||
renderer.render(scene,camera);
|
||||
canvas.toBlob(async blob=>{
|
||||
@@ -323,6 +394,15 @@ window.takeScreenshot = async () => {
|
||||
if(j.ok){toast('Aperçu sauvegardé ✓');}
|
||||
},'image/png',0.9);
|
||||
};
|
||||
<?php endif; ?>
|
||||
|
||||
(function animate(){requestAnimationFrame(animate);controls.update();renderer.render(scene,camera);})();
|
||||
|
||||
window.addEventListener('resize',()=>{
|
||||
renderer.setSize(wrap.clientWidth,wrap.clientHeight);
|
||||
camera.aspect=wrap.clientWidth/wrap.clientHeight;
|
||||
camera.updateProjectionMatrix();
|
||||
});
|
||||
</script>
|
||||
<?php endif; ?>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user