PrintVault: GCode 3D path viewer with layer color gradient
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:
perco
2026-05-15 11:34:28 +02:00
co-authored by Claude Sonnet 4.6
parent b756517ab5
commit 811d186b46
3 changed files with 159 additions and 20 deletions
+45
View File
@@ -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);
+14
View File
@@ -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
View File
@@ -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; ?>