PrintVault: full native rewrite — local storage, MariaDB, Three.js viewer
Deploy HouseHub / deploy (push) Successful in 1s
Deploy HouseHub / deploy (push) Successful in 1s
- All files stored in HouseHub (/uploads/printvault/), zero dependency on external service - PHP parsers: binary/ascii STL (dimensions + volume), 3MF (XML bounding box), GCode metadata - Three.js viewer (STL + 3MF) in printvault-viewer.php with importmap CDN · OrbitControls, metallic material, environment lighting, grid floor · Screenshot button saves thumbnail back to HouseHub - printvault.php: model grid + upload drag&drop + detail modal + edit modal - API: models CRUD, categories, file serve (inline for viewer), thumb save - pf_pv_models + pf_pv_categories tables added to schema Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
7272772c9f
commit
99e135ec74
@@ -413,3 +413,38 @@ CREATE TABLE IF NOT EXISTS pf_calendar_event_links (
|
|||||||
UNIQUE KEY uq_calendar_event (calendar_event_id),
|
UNIQUE KEY uq_calendar_event (calendar_event_id),
|
||||||
UNIQUE KEY uq_external_link (external_uid)
|
UNIQUE KEY uq_external_link (external_uid)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
-- ─── PrintVault ───────────────────────────────────────────────────────────────
|
||||||
|
CREATE TABLE IF NOT EXISTS pf_pv_models (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
description TEXT DEFAULT '',
|
||||||
|
category VARCHAR(100) DEFAULT 'Non classé',
|
||||||
|
tags VARCHAR(500) DEFAULT '',
|
||||||
|
file_type VARCHAR(20) NOT NULL,
|
||||||
|
filename VARCHAR(255) NOT NULL,
|
||||||
|
original_name VARCHAR(255) NOT NULL,
|
||||||
|
file_size INT DEFAULT 0,
|
||||||
|
dim_x DECIMAL(10,3) DEFAULT 0,
|
||||||
|
dim_y DECIMAL(10,3) DEFAULT 0,
|
||||||
|
dim_z DECIMAL(10,3) DEFAULT 0,
|
||||||
|
volume DECIMAL(10,3) DEFAULT 0,
|
||||||
|
gcode_time VARCHAR(50) DEFAULT '',
|
||||||
|
gcode_filament VARCHAR(50) DEFAULT '',
|
||||||
|
gcode_nozzle VARCHAR(20) DEFAULT '',
|
||||||
|
gcode_bed VARCHAR(20) DEFAULT '',
|
||||||
|
thumb VARCHAR(255) DEFAULT '',
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS pf_pv_categories (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
name VARCHAR(100) NOT NULL UNIQUE,
|
||||||
|
color VARCHAR(20) DEFAULT '#8b5cf6'
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
INSERT IGNORE INTO pf_pv_categories (name, color) VALUES
|
||||||
|
('Non classé','#64748b'),('Déco','#ec4899'),('Fonctionnel','#3b82f6'),
|
||||||
|
('Mécanique','#f59e0b'),('Jouets','#10b981'),('Outils','#ef4444'),
|
||||||
|
('Architecture','#8b5cf6'),('Art','#06b6d4');
|
||||||
|
|||||||
+257
-79
@@ -2,110 +2,288 @@
|
|||||||
ob_start();
|
ob_start();
|
||||||
require_once dirname(__DIR__, 2) . '/includes/auth.php';
|
require_once dirname(__DIR__, 2) . '/includes/auth.php';
|
||||||
require_login();
|
require_login();
|
||||||
|
|
||||||
header('Content-Type: application/json');
|
header('Content-Type: application/json');
|
||||||
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') { http_response_code(200); exit; }
|
|
||||||
|
|
||||||
set_exception_handler(function(\Throwable $e) {
|
set_exception_handler(function(\Throwable $e) {
|
||||||
if (!headers_sent()) { header('Content-Type: application/json'); http_response_code(500); }
|
if (!headers_sent()) { header('Content-Type: application/json'); http_response_code(500); }
|
||||||
echo json_encode(['ok' => false, 'error' => $e->getMessage()]); exit;
|
echo json_encode(['ok'=>false,'error'=>$e->getMessage()]); exit;
|
||||||
});
|
});
|
||||||
|
require_once dirname(__DIR__, 2) . '/includes/db.php';
|
||||||
|
|
||||||
define('PV_BASE', 'http://192.168.1.29:9500');
|
define('PV_MODEL_DIR', '/uploads/printvault/models/');
|
||||||
|
define('PV_THUMB_DIR', '/uploads/printvault/thumbs/');
|
||||||
|
|
||||||
function pvOk($d) { echo json_encode(['ok' => true, 'data' => $d], JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_SUBSTITUTE); exit; }
|
function pvOk($d) { echo json_encode(['ok'=>true,'data'=>$d], JSON_UNESCAPED_UNICODE|JSON_INVALID_UTF8_SUBSTITUTE); exit; }
|
||||||
function pvErr($m, $c = 400) { http_response_code($c); echo json_encode(['ok' => false, 'error' => $m]); exit; }
|
function pvErr($m,$c=400){ http_response_code($c); echo json_encode(['ok'=>false,'error'=>$m]); exit; }
|
||||||
|
function pvBody() { return json_decode(file_get_contents('php://input'), true) ?? []; }
|
||||||
|
|
||||||
function pvProxy(string $url, string $method = 'GET', ?string $body = null, string $contentType = 'application/json'): array {
|
// ── STL parser ─────────────────────────────────────────────────────────────────
|
||||||
$ch = curl_init($url);
|
function parseSTL(string $path): array {
|
||||||
curl_setopt_array($ch, [
|
$data = file_get_contents($path, false, null, 0, 204);
|
||||||
CURLOPT_RETURNTRANSFER => true,
|
if ($data === false || strlen($data) < 6) return ['dim_x'=>0,'dim_y'=>0,'dim_z'=>0,'volume'=>0];
|
||||||
CURLOPT_TIMEOUT => 15,
|
$isAscii = strtolower(substr(ltrim($data), 0, 5)) === 'solid';
|
||||||
CURLOPT_CUSTOMREQUEST => $method,
|
|
||||||
]);
|
$size = filesize($path);
|
||||||
if ($body !== null) {
|
if ($size > 30 * 1024 * 1024) return ['dim_x'=>0,'dim_y'=>0,'dim_z'=>0,'volume'=>0]; // skip >30MB
|
||||||
curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
|
|
||||||
curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: ' . $contentType]);
|
$data = file_get_contents($path);
|
||||||
|
if ($isAscii) return parseSTLAscii($data);
|
||||||
|
return parseSTLBinary($data);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseSTLBinary(string $data): array {
|
||||||
|
if (strlen($data) < 84) return ['dim_x'=>0,'dim_y'=>0,'dim_z'=>0,'volume'=>0];
|
||||||
|
$n = unpack('V', substr($data, 80, 4))[1];
|
||||||
|
if (strlen($data) < 84 + $n * 50 || $n === 0) return ['dim_x'=>0,'dim_y'=>0,'dim_z'=>0,'volume'=>0];
|
||||||
|
|
||||||
|
$mins = [PHP_FLOAT_MAX, PHP_FLOAT_MAX, PHP_FLOAT_MAX];
|
||||||
|
$maxs = [-PHP_FLOAT_MAX, -PHP_FLOAT_MAX, -PHP_FLOAT_MAX];
|
||||||
|
$vol = 0.0;
|
||||||
|
|
||||||
|
for ($i = 0; $i < $n; $i++) {
|
||||||
|
$off = 84 + $i * 50;
|
||||||
|
$v1 = array_values(unpack('fff', substr($data, $off+12, 12)));
|
||||||
|
$v2 = array_values(unpack('fff', substr($data, $off+24, 12)));
|
||||||
|
$v3 = array_values(unpack('fff', substr($data, $off+36, 12)));
|
||||||
|
for ($j = 0; $j < 3; $j++) {
|
||||||
|
$vals = [$v1[$j], $v2[$j], $v3[$j]];
|
||||||
|
$mins[$j] = min($mins[$j], ...$vals);
|
||||||
|
$maxs[$j] = max($maxs[$j], ...$vals);
|
||||||
}
|
}
|
||||||
$resp = curl_exec($ch);
|
$vol += ($v1[0]*($v2[1]*$v3[2]-$v2[2]*$v3[1])
|
||||||
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
-$v1[1]*($v2[0]*$v3[2]-$v2[2]*$v3[0])
|
||||||
curl_close($ch);
|
+$v1[2]*($v2[0]*$v3[1]-$v2[1]*$v3[0])) / 6.0;
|
||||||
return ['body' => $resp, 'code' => $code];
|
}
|
||||||
|
return [
|
||||||
|
'dim_x' => round($maxs[0]-$mins[0], 2),
|
||||||
|
'dim_y' => round($maxs[1]-$mins[1], 2),
|
||||||
|
'dim_z' => round($maxs[2]-$mins[2], 2),
|
||||||
|
'volume'=> round(abs($vol)/1000.0, 3),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseSTLAscii(string $data): array {
|
||||||
|
preg_match_all('/vertex\s+([-\d.eE+]+)\s+([-\d.eE+]+)\s+([-\d.eE+]+)/', $data, $m);
|
||||||
|
if (empty($m[1])) return ['dim_x'=>0,'dim_y'=>0,'dim_z'=>0,'volume'=>0];
|
||||||
|
$xs = array_map('floatval', $m[1]);
|
||||||
|
$ys = array_map('floatval', $m[2]);
|
||||||
|
$zs = array_map('floatval', $m[3]);
|
||||||
|
return [
|
||||||
|
'dim_x' => round(max($xs)-min($xs), 2),
|
||||||
|
'dim_y' => round(max($ys)-min($ys), 2),
|
||||||
|
'dim_z' => round(max($zs)-min($zs), 2),
|
||||||
|
'volume'=> 0,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 3MF parser ─────────────────────────────────────────────────────────────────
|
||||||
|
function parse3MF(string $path): array {
|
||||||
|
if (!class_exists('ZipArchive')) return ['dim_x'=>0,'dim_y'=>0,'dim_z'=>0,'volume'=>0];
|
||||||
|
if (filesize($path) > 50 * 1024 * 1024) return ['dim_x'=>0,'dim_y'=>0,'dim_z'=>0,'volume'=>0];
|
||||||
|
$z = new ZipArchive();
|
||||||
|
if ($z->open($path) !== true) return ['dim_x'=>0,'dim_y'=>0,'dim_z'=>0,'volume'=>0];
|
||||||
|
$xml = null;
|
||||||
|
for ($i = 0; $i < $z->numFiles; $i++) {
|
||||||
|
if (str_ends_with($z->getNameIndex($i), '.model')) {
|
||||||
|
$xml = $z->getFromIndex($i); break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$z->close();
|
||||||
|
if (!$xml) return ['dim_x'=>0,'dim_y'=>0,'dim_z'=>0,'volume'=>0];
|
||||||
|
|
||||||
|
libxml_use_internal_errors(true);
|
||||||
|
$dom = simplexml_load_string($xml);
|
||||||
|
if (!$dom) return ['dim_x'=>0,'dim_y'=>0,'dim_z'=>0,'volume'=>0];
|
||||||
|
|
||||||
|
preg_match_all('/x="([-\d.eE+]+)"/', $xml, $mx);
|
||||||
|
preg_match_all('/y="([-\d.eE+]+)"/', $xml, $my);
|
||||||
|
preg_match_all('/z="([-\d.eE+]+)"/', $xml, $mz);
|
||||||
|
if (empty($mx[1])) return ['dim_x'=>0,'dim_y'=>0,'dim_z'=>0,'volume'=>0];
|
||||||
|
$xs = array_map('floatval', $mx[1]); $ys = array_map('floatval', $my[1]); $zs = array_map('floatval', $mz[1]);
|
||||||
|
return [
|
||||||
|
'dim_x' => round(max($xs)-min($xs), 2),
|
||||||
|
'dim_y' => round(max($ys)-min($ys), 2),
|
||||||
|
'dim_z' => round(max($zs)-min($zs), 2),
|
||||||
|
'volume'=> 0,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── GCode parser ───────────────────────────────────────────────────────────────
|
||||||
|
function parseGCode(string $path): array {
|
||||||
|
$time = $filament = $nozzle = $bed = '';
|
||||||
|
$handle = fopen($path, 'r');
|
||||||
|
$lines = 0;
|
||||||
|
while (!feof($handle) && $lines < 300) {
|
||||||
|
$line = fgets($handle); $lines++;
|
||||||
|
if (preg_match('/;TIME:(\d+)/', $line, $m)) {
|
||||||
|
$s = (int)$m[1]; $time = ($s>=3600 ? floor($s/3600).'h':'').sprintf('%02d', ($s%3600)/60).'m';
|
||||||
|
} elseif (preg_match('/;estimated printing time[^=]*=\s*(.+)/i', $line, $m)) {
|
||||||
|
$time = trim($m[1]);
|
||||||
|
} elseif (preg_match('/;Filament used:\s*([\d.]+)m/i', $line, $m)) {
|
||||||
|
$filament = $m[1].'m';
|
||||||
|
} elseif (preg_match('/;filament used \[mm\]\s*=\s*([\d.]+)/i', $line, $m)) {
|
||||||
|
$filament = round((float)$m[1]/1000, 2).'m';
|
||||||
|
} elseif (preg_match('/M104\s+S(\d+)/i', $line, $m) && !$nozzle) { $nozzle = $m[1].'°C'; }
|
||||||
|
elseif (preg_match('/M109\s+S(\d+)/i', $line, $m) && !$nozzle) { $nozzle = $m[1].'°C'; }
|
||||||
|
elseif (preg_match('/M140\s+S(\d+)/i', $line, $m) && !$bed) { $bed = $m[1].'°C'; }
|
||||||
|
elseif (preg_match('/M190\s+S(\d+)/i', $line, $m) && !$bed) { $bed = $m[1].'°C'; }
|
||||||
|
}
|
||||||
|
// Scan tail for slicer comments
|
||||||
|
fseek($handle, -min(8192, filesize($path)), SEEK_END);
|
||||||
|
while (!feof($handle)) {
|
||||||
|
$line = fgets($handle);
|
||||||
|
if (!$time && preg_match('/;TIME:(\d+)/', $line, $m)) { $s=(int)$m[1]; $time=($s>=3600?floor($s/3600).'h':'').sprintf('%02d',($s%3600)/60).'m'; }
|
||||||
|
if (!$filament && preg_match('/;Filament used:\s*([\d.]+)m/i', $line, $m)) $filament = $m[1].'m';
|
||||||
|
if (!$filament && preg_match('/;filament used \[mm\]\s*=\s*([\d.]+)/i', $line, $m)) $filament = round((float)$m[1]/1000,2).'m';
|
||||||
|
}
|
||||||
|
fclose($handle);
|
||||||
|
return compact('time', 'filament', 'nozzle', 'bed');
|
||||||
}
|
}
|
||||||
|
|
||||||
$method = $_SERVER['REQUEST_METHOD'];
|
$method = $_SERVER['REQUEST_METHOD'];
|
||||||
$action = $_GET['action'] ?? '';
|
$action = $_GET['action'] ?? '';
|
||||||
|
|
||||||
// ── MODELS ────────────────────────────────────────────────────────────────────
|
// ── CATEGORIES ─────────────────────────────────────────────────────────────────
|
||||||
if ($action === 'models') {
|
if ($action === 'categories') {
|
||||||
if ($method === 'GET') {
|
if ($method === 'GET') {
|
||||||
$qs = http_build_query(array_intersect_key($_GET, array_flip(['category','file_type','search','limit','offset'])));
|
$rows = $pdo->query("SELECT c.*, COUNT(m.id) as count FROM pf_pv_categories c LEFT JOIN pf_pv_models m ON m.category=c.name GROUP BY c.id ORDER BY c.name")->fetchAll();
|
||||||
$r = pvProxy(PV_BASE . '/api/models?' . $qs);
|
pvOk($rows);
|
||||||
if ($r['code'] !== 200) pvErr('PrintVault inaccessible', 502);
|
|
||||||
pvOk(json_decode($r['body'], true));
|
|
||||||
}
|
|
||||||
if ($method === 'DELETE') {
|
|
||||||
$id = $_GET['id'] ?? null; if (!$id) pvErr('ID manquant');
|
|
||||||
$r = pvProxy(PV_BASE . '/api/models/' . intval($id), 'DELETE');
|
|
||||||
if ($r['code'] === 200) pvOk(['deleted' => true]);
|
|
||||||
pvErr('Erreur suppression', 500);
|
|
||||||
}
|
}
|
||||||
if ($method === 'POST') {
|
if ($method === 'POST') {
|
||||||
// Upload: forward multipart to PrintVault
|
$d = pvBody();
|
||||||
if (empty($_FILES['file'])) pvErr('Fichier manquant');
|
$name = trim($d['name'] ?? ''); if (!$name) pvErr('Nom requis');
|
||||||
$f = $_FILES['file'];
|
$pdo->prepare("INSERT IGNORE INTO pf_pv_categories (name,color) VALUES (?,?)")->execute([$name, $d['color'] ?? '#8b5cf6']);
|
||||||
if ($f['error'] !== UPLOAD_ERR_OK) pvErr('Erreur upload: ' . $f['error']);
|
pvOk(['id' => (int)$pdo->lastInsertId()]);
|
||||||
|
|
||||||
$postFields = [
|
|
||||||
'file' => new CURLFile($f['tmp_name'], $f['type'] ?: 'application/octet-stream', $f['name']),
|
|
||||||
'name' => $_POST['name'] ?? pathinfo($f['name'], PATHINFO_FILENAME),
|
|
||||||
'description' => $_POST['description'] ?? '',
|
|
||||||
'category' => $_POST['category'] ?? 'Non classé',
|
|
||||||
'tags' => $_POST['tags'] ?? '',
|
|
||||||
];
|
|
||||||
|
|
||||||
$ch = curl_init(PV_BASE . '/api/models');
|
|
||||||
curl_setopt_array($ch, [
|
|
||||||
CURLOPT_POST => true,
|
|
||||||
CURLOPT_RETURNTRANSFER => true,
|
|
||||||
CURLOPT_TIMEOUT => 60,
|
|
||||||
CURLOPT_POSTFIELDS => $postFields,
|
|
||||||
]);
|
|
||||||
$resp = curl_exec($ch);
|
|
||||||
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
|
||||||
curl_close($ch);
|
|
||||||
|
|
||||||
if ($code === 200) pvOk(json_decode($resp, true));
|
|
||||||
pvErr('Upload échoué (' . $code . '): ' . substr($resp, 0, 200), 500);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── CATEGORIES ────────────────────────────────────────────────────────────────
|
// ── MODELS ─────────────────────────────────────────────────────────────────────
|
||||||
if ($action === 'categories') {
|
if ($action === 'models') {
|
||||||
$r = pvProxy(PV_BASE . '/api/categories');
|
if ($method === 'GET') {
|
||||||
if ($r['code'] !== 200) pvErr('PrintVault inaccessible', 502);
|
$id = $_GET['id'] ?? null;
|
||||||
pvOk(json_decode($r['body'], true));
|
if ($id) {
|
||||||
|
$s = $pdo->prepare("SELECT * FROM pf_pv_models WHERE id=?"); $s->execute([$id]);
|
||||||
|
$r = $s->fetch(); if (!$r) pvErr('Introuvable', 404);
|
||||||
|
pvOk($r);
|
||||||
|
}
|
||||||
|
$where = []; $params = [];
|
||||||
|
if ($cat = ($_GET['category'] ?? '')) { $where[] = 'category=?'; $params[] = $cat; }
|
||||||
|
if ($ft = ($_GET['file_type'] ?? '')) {
|
||||||
|
if ($ft === 'gcode') { $where[] = "file_type IN ('gcode','gco','g')"; }
|
||||||
|
else { $where[] = 'file_type=?'; $params[] = $ft; }
|
||||||
|
}
|
||||||
|
if ($q = ($_GET['search'] ?? '')) {
|
||||||
|
$like = '%'.$q.'%';
|
||||||
|
$where[] = '(name LIKE ? OR description LIKE ? OR tags LIKE ?)';
|
||||||
|
array_push($params, $like, $like, $like);
|
||||||
|
}
|
||||||
|
$sql = "SELECT * FROM pf_pv_models" . ($where ? ' WHERE '.implode(' AND ',$where) : '') . " ORDER BY created_at DESC";
|
||||||
|
$s = $pdo->prepare($sql); $s->execute($params);
|
||||||
|
pvOk($s->fetchAll());
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($method === 'POST') {
|
||||||
|
if (empty($_FILES['file']) || $_FILES['file']['error'] !== UPLOAD_ERR_OK) pvErr('Fichier manquant');
|
||||||
|
$f = $_FILES['file'];
|
||||||
|
$ext = strtolower(pathinfo($f['name'], PATHINFO_EXTENSION));
|
||||||
|
$allowed = ['stl','3mf','gcode','gco','g'];
|
||||||
|
if (!in_array($ext, $allowed)) pvErr('Format non supporté');
|
||||||
|
|
||||||
|
$fname = uniqid('pv', true) . '.' . $ext;
|
||||||
|
$dest = PV_MODEL_DIR . $fname;
|
||||||
|
if (!move_uploaded_file($f['tmp_name'], $dest)) pvErr('Erreur déplacement fichier');
|
||||||
|
|
||||||
|
// Parse geometry / metadata
|
||||||
|
$geo = ['dim_x'=>0,'dim_y'=>0,'dim_z'=>0,'volume'=>0];
|
||||||
|
$gcode = ['time'=>'','filament'=>'','nozzle'=>'','bed'=>''];
|
||||||
|
if ($ext === 'stl') $geo = parseSTL($dest);
|
||||||
|
elseif ($ext === '3mf') $geo = parse3MF($dest);
|
||||||
|
elseif (in_array($ext, ['gcode','gco','g'])) $gcode = parseGCode($dest);
|
||||||
|
|
||||||
|
$name = trim($_POST['name'] ?? pathinfo($f['name'], PATHINFO_FILENAME));
|
||||||
|
$pdo->prepare("INSERT INTO pf_pv_models (name,description,category,tags,file_type,filename,original_name,file_size,dim_x,dim_y,dim_z,volume,gcode_time,gcode_filament,gcode_nozzle,gcode_bed) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)")
|
||||||
|
->execute([
|
||||||
|
$name,
|
||||||
|
$_POST['description'] ?? '',
|
||||||
|
$_POST['category'] ?? 'Non classé',
|
||||||
|
$_POST['tags'] ?? '',
|
||||||
|
$ext,
|
||||||
|
$fname,
|
||||||
|
$f['name'],
|
||||||
|
filesize($dest),
|
||||||
|
$geo['dim_x'], $geo['dim_y'], $geo['dim_z'], $geo['volume'],
|
||||||
|
$gcode['time'], $gcode['filament'], $gcode['nozzle'], $gcode['bed'],
|
||||||
|
]);
|
||||||
|
$id = (int)$pdo->lastInsertId();
|
||||||
|
$row = $pdo->prepare("SELECT * FROM pf_pv_models WHERE id=?");
|
||||||
|
$row->execute([$id]); pvOk($row->fetch());
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($method === 'PUT') {
|
||||||
|
$id = (int)($_GET['id'] ?? 0); if (!$id) pvErr('ID manquant');
|
||||||
|
$d = pvBody();
|
||||||
|
$name = trim($d['name'] ?? ''); if (!$name) pvErr('Nom requis');
|
||||||
|
$pdo->prepare("UPDATE pf_pv_models SET name=?,description=?,category=?,tags=?,updated_at=NOW() WHERE id=?")
|
||||||
|
->execute([$name, $d['description']??'', $d['category']??'Non classé', $d['tags']??'', $id]);
|
||||||
|
pvOk(['updated'=>true]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($method === 'DELETE') {
|
||||||
|
$id = (int)($_GET['id'] ?? 0); if (!$id) pvErr('ID manquant');
|
||||||
|
$s = $pdo->prepare("SELECT filename, thumb FROM pf_pv_models WHERE id=?"); $s->execute([$id]);
|
||||||
|
$row = $s->fetch(); if (!$row) pvErr('Introuvable', 404);
|
||||||
|
@unlink(PV_MODEL_DIR . $row['filename']);
|
||||||
|
if ($row['thumb']) @unlink(PV_THUMB_DIR . $row['thumb']);
|
||||||
|
$pdo->prepare("DELETE FROM pf_pv_models WHERE id=?")->execute([$id]);
|
||||||
|
pvOk(['deleted'=>true]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── FILE DOWNLOAD (proxy) ─────────────────────────────────────────────────────
|
// ── FILE SERVE ─────────────────────────────────────────────────────────────────
|
||||||
if ($action === 'file') {
|
if ($action === 'file') {
|
||||||
$id = intval($_GET['id'] ?? 0); if (!$id) pvErr('ID manquant');
|
$id = (int)($_GET['id'] ?? 0);
|
||||||
$info = pvProxy(PV_BASE . '/api/models/' . $id);
|
$s = $pdo->prepare("SELECT * FROM pf_pv_models WHERE id=?"); $s->execute([$id]);
|
||||||
if ($info['code'] !== 200) pvErr('Modèle introuvable', 404);
|
$row = $s->fetch(); if (!$row) { http_response_code(404); exit; }
|
||||||
$m = json_decode($info['body'], true);
|
$path = PV_MODEL_DIR . $row['filename'];
|
||||||
|
if (!file_exists($path)) { http_response_code(404); exit; }
|
||||||
$ch = curl_init(PV_BASE . '/api/models/' . $id . '/file');
|
$ext = strtolower(pathinfo($path, PATHINFO_EXTENSION));
|
||||||
curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 30]);
|
$mime = ['stl'=>'model/stl','3mf'=>'model/3mf','gcode'=>'text/plain','gco'=>'text/plain','g'=>'text/plain'][$ext] ?? 'application/octet-stream';
|
||||||
$data = curl_exec($ch);
|
|
||||||
$mime = curl_getinfo($ch, CURLINFO_CONTENT_TYPE) ?: 'application/octet-stream';
|
|
||||||
curl_close($ch);
|
|
||||||
|
|
||||||
ob_end_clean();
|
ob_end_clean();
|
||||||
header('Content-Type: ' . $mime);
|
header('Content-Type: ' . $mime);
|
||||||
header('Content-Disposition: attachment; filename="' . rawurlencode($m['original_name'] ?? 'model') . '"');
|
header('Content-Disposition: attachment; filename="' . rawurlencode($row['original_name']) . '"');
|
||||||
|
header('Content-Length: ' . filesize($path));
|
||||||
header('Cache-Control: no-cache');
|
header('Cache-Control: no-cache');
|
||||||
echo $data;
|
readfile($path); exit;
|
||||||
exit;
|
}
|
||||||
|
|
||||||
|
// ── MODEL FILE FOR VIEWER (inline, no download) ────────────────────────────────
|
||||||
|
if ($action === 'model_data') {
|
||||||
|
$id = (int)($_GET['id'] ?? 0);
|
||||||
|
$s = $pdo->prepare("SELECT * FROM pf_pv_models WHERE id=?"); $s->execute([$id]);
|
||||||
|
$row = $s->fetch(); if (!$row) { http_response_code(404); exit; }
|
||||||
|
$path = PV_MODEL_DIR . $row['filename'];
|
||||||
|
if (!file_exists($path)) { http_response_code(404); exit; }
|
||||||
|
$ext = strtolower(pathinfo($path, PATHINFO_EXTENSION));
|
||||||
|
$mime = ['stl'=>'model/stl','3mf'=>'model/3mf'][$ext] ?? 'application/octet-stream';
|
||||||
|
ob_end_clean();
|
||||||
|
header('Content-Type: ' . $mime);
|
||||||
|
header('Content-Disposition: inline; filename="' . rawurlencode($row['filename']) . '"');
|
||||||
|
header('Cache-Control: private, max-age=3600');
|
||||||
|
readfile($path); exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── THUMBNAIL SAVE ──────────────────────────────────────────────────────────────
|
||||||
|
if ($action === 'thumb' && $method === 'POST') {
|
||||||
|
$id = (int)($_GET['id'] ?? 0);
|
||||||
|
$s = $pdo->prepare("SELECT id, thumb FROM pf_pv_models WHERE id=?"); $s->execute([$id]);
|
||||||
|
$row = $s->fetch(); if (!$row) pvErr('Introuvable', 404);
|
||||||
|
|
||||||
|
if (empty($_FILES['thumb']) || $_FILES['thumb']['error'] !== UPLOAD_ERR_OK) pvErr('Pas de fichier');
|
||||||
|
if ($row['thumb']) @unlink(PV_THUMB_DIR . $row['thumb']);
|
||||||
|
|
||||||
|
$thumbName = 'thumb_' . uniqid('', true) . '.png';
|
||||||
|
if (!move_uploaded_file($_FILES['thumb']['tmp_name'], PV_THUMB_DIR . $thumbName)) pvErr('Erreur sauvegarde');
|
||||||
|
$pdo->prepare("UPDATE pf_pv_models SET thumb=? WHERE id=?")->execute([$thumbName, $id]);
|
||||||
|
pvOk(['thumb' => $thumbName]);
|
||||||
}
|
}
|
||||||
|
|
||||||
pvErr('Action inconnue', 404);
|
pvErr('Action inconnue', 404);
|
||||||
|
|||||||
@@ -1,32 +1,25 @@
|
|||||||
// HouseHub — PrintVault module
|
// HouseHub — PrintVault module (local storage)
|
||||||
const API = '/modules/printvault/api.php';
|
const API = '/modules/printvault/api.php';
|
||||||
const PV_BASE = 'https://printvault.nas.percolouco.com';
|
|
||||||
|
|
||||||
function escHtml(s) { const d = document.createElement('div'); d.textContent = String(s ?? ''); return d.innerHTML; }
|
function escHtml(s) { const d = document.createElement('div'); d.textContent = String(s ?? ''); return d.innerHTML; }
|
||||||
function fmtSize(b) { if (!b) return '—'; if (b > 1048576) return (b / 1048576).toFixed(1) + ' Mo'; return Math.round(b / 1024) + ' Ko'; }
|
function fmtSize(b) { if (!b) return '—'; return b > 1048576 ? (b/1048576).toFixed(1)+' Mo' : Math.round(b/1024)+' Ko'; }
|
||||||
function fmtDate(s) { if (!s) return '—'; return new Date(s).toLocaleDateString('fr-FR', { day: '2-digit', month: '2-digit', year: 'numeric' }); }
|
function fmtDate(s) { if (!s) return '—'; return new Date(s).toLocaleDateString('fr-FR',{day:'2-digit',month:'2-digit',year:'numeric'}); }
|
||||||
|
|
||||||
function toast(msg, type = 'success') {
|
function toast(msg, type='success') {
|
||||||
const c = document.getElementById('pv-toasts');
|
const c = document.getElementById('pv-toasts');
|
||||||
const t = document.createElement('div');
|
const t = document.createElement('div');
|
||||||
t.className = 'pv-toast' + (type === 'error' ? ' error' : '');
|
t.className = 'pv-toast' + (type==='error' ? ' error' : '');
|
||||||
t.textContent = msg;
|
t.textContent = msg; c.appendChild(t);
|
||||||
c.appendChild(t);
|
|
||||||
setTimeout(() => t.remove(), 3500);
|
setTimeout(() => t.remove(), 3500);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function api(action, method = 'GET', data = null, extra = '') {
|
async function api(action, method='GET', data=null, extra='') {
|
||||||
const opts = { method, credentials: 'same-origin', headers: { 'Accept': 'application/json', 'X-Requested-With': 'XMLHttpRequest' } };
|
const opts = {method, credentials:'same-origin', headers:{'Accept':'application/json','X-Requested-With':'XMLHttpRequest'}};
|
||||||
if (data && !(data instanceof FormData)) {
|
if (data && !(data instanceof FormData)) { opts.headers['Content-Type']='application/json'; opts.body=JSON.stringify(data); }
|
||||||
opts.headers['Content-Type'] = 'application/json';
|
else if (data instanceof FormData) opts.body = data;
|
||||||
opts.body = JSON.stringify(data);
|
const r = await fetch(API+'?action='+action+extra, opts);
|
||||||
} else if (data instanceof FormData) {
|
|
||||||
opts.body = data;
|
|
||||||
}
|
|
||||||
const r = await fetch(API + '?action=' + action + extra, opts);
|
|
||||||
const text = await r.text();
|
const text = await r.text();
|
||||||
let j;
|
let j; try { j = JSON.parse(text); } catch(e) { console.error('Bad JSON:', text.slice(0,300)); throw new Error('Erreur serveur'); }
|
||||||
try { j = JSON.parse(text); } catch (e) { console.error('Bad JSON:', text.slice(0, 300)); throw new Error('Erreur serveur'); }
|
|
||||||
if (!j.ok) throw new Error(j.error || 'Erreur');
|
if (!j.ok) throw new Error(j.error || 'Erreur');
|
||||||
return j.data;
|
return j.data;
|
||||||
}
|
}
|
||||||
@@ -40,54 +33,39 @@ let categories = [];
|
|||||||
|
|
||||||
// ─── Init ─────────────────────────────────────────────────────────────────────
|
// ─── Init ─────────────────────────────────────────────────────────────────────
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
loadSidebar();
|
loadAll();
|
||||||
loadModels();
|
document.getElementById('pv-search')?.addEventListener('input', e => { searchQuery = e.target.value.trim(); renderModels(); });
|
||||||
|
document.querySelectorAll('.pv-modal-backdrop').forEach(m => m.addEventListener('click', e => { if (e.target===m) m.classList.remove('show'); }));
|
||||||
|
|
||||||
document.getElementById('pv-search')?.addEventListener('input', e => {
|
|
||||||
searchQuery = e.target.value.trim();
|
|
||||||
renderModels();
|
|
||||||
});
|
|
||||||
|
|
||||||
document.querySelectorAll('.pv-modal-backdrop').forEach(m =>
|
|
||||||
m.addEventListener('click', e => { if (e.target === m) m.classList.remove('show'); })
|
|
||||||
);
|
|
||||||
|
|
||||||
// Upload drop zone
|
|
||||||
const dz = document.getElementById('pv-drop-zone');
|
const dz = document.getElementById('pv-drop-zone');
|
||||||
if (dz) {
|
if (dz) {
|
||||||
dz.addEventListener('dragover', e => { e.preventDefault(); dz.classList.add('over'); });
|
dz.addEventListener('dragover', e => { e.preventDefault(); dz.classList.add('over'); });
|
||||||
dz.addEventListener('dragleave', () => dz.classList.remove('over'));
|
dz.addEventListener('dragleave', () => dz.classList.remove('over'));
|
||||||
dz.addEventListener('drop', e => {
|
dz.addEventListener('drop', e => { e.preventDefault(); dz.classList.remove('over'); if (e.dataTransfer.files[0]) setUploadFile(e.dataTransfer.files[0]); });
|
||||||
e.preventDefault(); dz.classList.remove('over');
|
|
||||||
const f = e.dataTransfer.files[0];
|
|
||||||
if (f) setUploadFile(f);
|
|
||||||
});
|
|
||||||
dz.addEventListener('click', () => document.getElementById('pv-file-input').click());
|
dz.addEventListener('click', () => document.getElementById('pv-file-input').click());
|
||||||
document.getElementById('pv-file-input').addEventListener('change', e => {
|
document.getElementById('pv-file-input').addEventListener('change', e => { if (e.target.files[0]) setUploadFile(e.target.files[0]); });
|
||||||
if (e.target.files[0]) setUploadFile(e.target.files[0]);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── Sidebar ──────────────────────────────────────────────────────────────────
|
async function loadAll() {
|
||||||
async function loadSidebar() {
|
|
||||||
try {
|
try {
|
||||||
const data = await api('categories');
|
const [cats] = await Promise.all([api('categories')]);
|
||||||
categories = data.categories || [];
|
categories = cats;
|
||||||
renderSidebar();
|
await loadModels();
|
||||||
} catch (e) { console.error(e); }
|
} catch(e) { toast(e.message, 'error'); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Sidebar ──────────────────────────────────────────────────────────────────
|
||||||
function renderSidebar() {
|
function renderSidebar() {
|
||||||
const el = document.getElementById('pv-sidebar-nav');
|
const el = document.getElementById('pv-sidebar-nav');
|
||||||
let html = `
|
let html = `
|
||||||
<div class="pv-nav-section">Catégories</div>
|
<div class="pv-nav-section">Catégories</div>
|
||||||
<div class="pv-nav-item${currentCategory === null ? ' active' : ''}" onclick="setCategory(null)">
|
<div class="pv-nav-item${currentCategory===null?' active':''}" onclick="setCategory(null)">
|
||||||
🗂 Toutes <span class="pv-nav-badge">${allModels.length}</span>
|
🗂 Toutes <span class="pv-nav-badge">${allModels.length}</span>
|
||||||
</div>`;
|
</div>`;
|
||||||
categories.forEach(c => {
|
categories.forEach(c => {
|
||||||
const count = allModels.filter(m => m.category === c.name).length;
|
const count = allModels.filter(m => m.category === c.name).length;
|
||||||
html += `<div class="pv-nav-item${currentCategory === c.name ? ' active' : ''}" onclick="setCategory('${escHtml(c.name)}')">
|
html += `<div class="pv-nav-item${currentCategory===c.name?' active':''}" onclick="setCategory('${escHtml(c.name).replace(/'/g,"\\'")}')">
|
||||||
<span class="pv-cat-dot" style="background:${escHtml(c.color)}"></span>
|
<span class="pv-cat-dot" style="background:${escHtml(c.color)}"></span>
|
||||||
<span style="flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${escHtml(c.name)}</span>
|
<span style="flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${escHtml(c.name)}</span>
|
||||||
<span class="pv-nav-badge">${count}</span>
|
<span class="pv-nav-badge">${count}</span>
|
||||||
@@ -96,29 +74,20 @@ function renderSidebar() {
|
|||||||
el.innerHTML = html;
|
el.innerHTML = html;
|
||||||
}
|
}
|
||||||
|
|
||||||
function setCategory(cat) {
|
function setCategory(cat) { currentCategory = cat; renderSidebar(); renderModels(); }
|
||||||
currentCategory = cat;
|
|
||||||
renderSidebar();
|
|
||||||
renderModels();
|
|
||||||
}
|
|
||||||
|
|
||||||
function setType(type) {
|
function setType(type) {
|
||||||
currentType = currentType === type ? null : type;
|
currentType = currentType === type ? null : type;
|
||||||
document.querySelectorAll('.pv-chip').forEach(c => {
|
document.querySelectorAll('.pv-chip').forEach(c => c.classList.toggle('active', c.dataset.type === currentType));
|
||||||
c.classList.toggle('active', c.dataset.type === currentType);
|
|
||||||
});
|
|
||||||
renderModels();
|
renderModels();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Models ───────────────────────────────────────────────────────────────────
|
// ─── Models ───────────────────────────────────────────────────────────────────
|
||||||
async function loadModels() {
|
async function loadModels() {
|
||||||
try {
|
try {
|
||||||
const data = await api('models');
|
allModels = await api('models');
|
||||||
allModels = data.models || [];
|
|
||||||
renderSidebar();
|
renderSidebar();
|
||||||
renderModels();
|
renderModels();
|
||||||
updateHeader();
|
} catch(e) { toast(e.message, 'error'); }
|
||||||
} catch (e) { toast(e.message, 'error'); }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function filterModels() {
|
function filterModels() {
|
||||||
@@ -131,9 +100,7 @@ function filterModels() {
|
|||||||
}
|
}
|
||||||
if (searchQuery) {
|
if (searchQuery) {
|
||||||
const q = searchQuery.toLowerCase();
|
const q = searchQuery.toLowerCase();
|
||||||
if (!m.name.toLowerCase().includes(q) &&
|
if (!m.name.toLowerCase().includes(q) && !(m.description||'').toLowerCase().includes(q) && !(m.tags||'').toLowerCase().includes(q)) return false;
|
||||||
!(m.description || '').toLowerCase().includes(q) &&
|
|
||||||
!(m.tags || '').toLowerCase().includes(q)) return false;
|
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
@@ -142,28 +109,25 @@ function filterModels() {
|
|||||||
function renderModels() {
|
function renderModels() {
|
||||||
const el = document.getElementById('pv-grid');
|
const el = document.getElementById('pv-grid');
|
||||||
const filtered = filterModels();
|
const filtered = filterModels();
|
||||||
|
document.getElementById('pv-subtitle').textContent = filtered.length + ' modèle' + (filtered.length > 1 ? 's' : '');
|
||||||
if (!filtered.length) {
|
if (!filtered.length) {
|
||||||
el.innerHTML = `<div class="pv-empty" style="grid-column:1/-1"><div class="icon">🖨️</div><p>Aucun modèle trouvé.</p></div>`;
|
el.innerHTML = `<div class="pv-empty" style="grid-column:1/-1"><div class="icon">🖨️</div><p>${allModels.length ? 'Aucun modèle dans ce filtre.' : 'Aucun modèle. Uploadez votre premier fichier !'}</p></div>`;
|
||||||
updateHeader(0);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
el.innerHTML = filtered.map(m => cardHtml(m)).join('');
|
||||||
el.innerHTML = filtered.map(m => modelCardHtml(m)).join('');
|
|
||||||
updateHeader(filtered.length);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function modelCardHtml(m) {
|
const typeIcon = {stl:'🟣','3mf':'🔵',gcode:'🟢',g:'🟢',gco:'🟢'};
|
||||||
|
|
||||||
|
function cardHtml(m) {
|
||||||
const ext = m.file_type.toLowerCase();
|
const ext = m.file_type.toLowerCase();
|
||||||
const typeIcon = { stl: '🟣', '3mf': '🔵', gcode: '🟢', g: '🟢', gco: '🟢' }[ext] || '📄';
|
const icon = typeIcon[ext] || '📄';
|
||||||
const thumbSrc = m.thumb_filename ? `${PV_BASE}/thumbs/${m.thumb_filename}` : null;
|
const thumbUrl = m.thumb ? `/uploads/printvault/thumbs/${encodeURIComponent(m.thumb)}` : null;
|
||||||
const thumbHtml = thumbSrc
|
const thumbHtml = thumbUrl
|
||||||
? `<img src="${escHtml(thumbSrc)}" alt="" loading="lazy" onerror="this.parentNode.innerHTML='${typeIcon}'">`
|
? `<img src="${escHtml(thumbUrl)}" alt="" loading="lazy" onerror="this.parentNode.innerHTML='${icon}'">`
|
||||||
: typeIcon;
|
: icon;
|
||||||
|
const dims = m.dim_x > 0 ? `${Math.round(m.dim_x)}×${Math.round(m.dim_y)}×${Math.round(m.dim_z)}mm` : '';
|
||||||
const dims = m.dim_x > 0 ? `${Math.round(m.dim_x)}×${Math.round(m.dim_y)}×${Math.round(m.dim_z)} mm` : '';
|
const gcInfo = m.gcode_time || '';
|
||||||
const gcodeInfo = m.gcode_print_time ? m.gcode_print_time : '';
|
|
||||||
|
|
||||||
return `<div class="pv-card" onclick="openDetail(${m.id})">
|
return `<div class="pv-card" onclick="openDetail(${m.id})">
|
||||||
<div class="pv-card-thumb">${thumbHtml}</div>
|
<div class="pv-card-thumb">${thumbHtml}</div>
|
||||||
<div class="pv-card-body">
|
<div class="pv-card-body">
|
||||||
@@ -171,69 +135,100 @@ function modelCardHtml(m) {
|
|||||||
<div class="pv-card-meta">
|
<div class="pv-card-meta">
|
||||||
<span class="pv-type-badge pv-type-${escHtml(ext)}">${escHtml(m.file_type.toUpperCase())}</span>
|
<span class="pv-type-badge pv-type-${escHtml(ext)}">${escHtml(m.file_type.toUpperCase())}</span>
|
||||||
${dims ? `<span>${escHtml(dims)}</span>` : ''}
|
${dims ? `<span>${escHtml(dims)}</span>` : ''}
|
||||||
${gcodeInfo ? `<span>⏱ ${escHtml(gcodeInfo)}</span>` : ''}
|
${gcInfo ? `<span>⏱ ${escHtml(gcInfo)}</span>` : ''}
|
||||||
<span style="margin-left:auto">${escHtml(fmtSize(m.file_size))}</span>
|
<span style="margin-left:auto">${escHtml(fmtSize(m.file_size))}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateHeader(count) {
|
// ─── Detail panel ─────────────────────────────────────────────────────────────
|
||||||
const el = document.getElementById('pv-subtitle');
|
let currentDetailId = null;
|
||||||
if (!el) return;
|
|
||||||
const total = count !== undefined ? count : filterModels().length;
|
|
||||||
el.textContent = total + ' modèle' + (total > 1 ? 's' : '');
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Detail modal ─────────────────────────────────────────────────────────────
|
|
||||||
function openDetail(id) {
|
function openDetail(id) {
|
||||||
const m = allModels.find(x => x.id === id);
|
const m = allModels.find(x => x.id === id);
|
||||||
if (!m) return;
|
if (!m) return;
|
||||||
|
currentDetailId = id;
|
||||||
const ext = m.file_type.toLowerCase();
|
const ext = m.file_type.toLowerCase();
|
||||||
const thumbSrc = m.thumb_filename ? `${PV_BASE}/thumbs/${m.thumb_filename}` : null;
|
const canView = ['stl','3mf'].includes(ext);
|
||||||
const typeIcon = { stl: '🟣', '3mf': '🔵', gcode: '🟢', g: '🟢', gco: '🟢' }[ext] || '📄';
|
|
||||||
const isGcode = ['gcode','gco','g'].includes(ext);
|
const isGcode = ['gcode','gco','g'].includes(ext);
|
||||||
|
const icon = typeIcon[ext] || '📄';
|
||||||
|
const thumbUrl = m.thumb ? `/uploads/printvault/thumbs/${encodeURIComponent(m.thumb)}` : null;
|
||||||
|
|
||||||
document.getElementById('pv-detail-body').innerHTML = `
|
document.getElementById('pv-detail-body').innerHTML = `
|
||||||
<div class="pv-detail-thumb">
|
<div class="pv-detail-thumb">
|
||||||
${thumbSrc ? `<img src="${escHtml(thumbSrc)}" alt="">` : typeIcon}
|
${thumbUrl ? `<img src="${escHtml(thumbUrl)}" alt="">` : icon}
|
||||||
</div>
|
</div>
|
||||||
<div style="font-size:1rem;font-weight:700;color:var(--text-main)">${escHtml(m.name)}</div>
|
<div style="font-size:1rem;font-weight:700;color:var(--text-main)">${escHtml(m.name)}</div>
|
||||||
${m.description ? `<div style="font-size:.85rem;color:var(--text-muted)">${escHtml(m.description)}</div>` : ''}
|
${m.description ? `<div style="font-size:.85rem;color:var(--text-muted)">${escHtml(m.description)}</div>` : ''}
|
||||||
${m.tags ? `<div style="display:flex;gap:.3rem;flex-wrap:wrap">${m.tags.split(',').filter(Boolean).map(t => `<span style="font-size:.72rem;background:var(--bg-page);border:1px solid var(--border-light);padding:.1rem .45rem;border-radius:999px;color:var(--text-muted)">${escHtml(t.trim())}</span>`).join('')}</div>` : ''}
|
${m.tags ? `<div style="display:flex;gap:.3rem;flex-wrap:wrap">${m.tags.split(',').filter(Boolean).map(t=>`<span style="font-size:.72rem;background:var(--bg-page);border:1px solid var(--border-light);padding:.1rem .45rem;border-radius:999px;color:var(--text-muted)">${escHtml(t.trim())}</span>`).join('')}</div>` : ''}
|
||||||
<div class="pv-detail-grid">
|
<div class="pv-detail-grid">
|
||||||
<div class="pv-detail-item"><span class="pv-detail-label">Type</span><span class="pv-detail-value"><span class="pv-type-badge pv-type-${escHtml(ext)}">${escHtml(m.file_type.toUpperCase())}</span></span></div>
|
<div class="pv-detail-item"><span class="pv-detail-label">Type</span><span class="pv-detail-value"><span class="pv-type-badge pv-type-${escHtml(ext)}">${escHtml(m.file_type.toUpperCase())}</span></span></div>
|
||||||
<div class="pv-detail-item"><span class="pv-detail-label">Catégorie</span><span class="pv-detail-value">${escHtml(m.category)}</span></div>
|
<div class="pv-detail-item"><span class="pv-detail-label">Catégorie</span><span class="pv-detail-value">${escHtml(m.category)}</span></div>
|
||||||
<div class="pv-detail-item"><span class="pv-detail-label">Taille</span><span class="pv-detail-value">${escHtml(fmtSize(m.file_size))}</span></div>
|
<div class="pv-detail-item"><span class="pv-detail-label">Taille</span><span class="pv-detail-value">${escHtml(fmtSize(m.file_size))}</span></div>
|
||||||
<div class="pv-detail-item"><span class="pv-detail-label">Ajouté le</span><span class="pv-detail-value">${escHtml(fmtDate(m.created_at))}</span></div>
|
<div class="pv-detail-item"><span class="pv-detail-label">Ajouté le</span><span class="pv-detail-value">${escHtml(fmtDate(m.created_at))}</span></div>
|
||||||
${m.dim_x > 0 ? `<div class="pv-detail-item"><span class="pv-detail-label">Dimensions</span><span class="pv-detail-value">${Math.round(m.dim_x)}×${Math.round(m.dim_y)}×${Math.round(m.dim_z)} mm</span></div>` : ''}
|
${m.dim_x > 0 ? `<div class="pv-detail-item" style="grid-column:1/-1"><span class="pv-detail-label">Dimensions</span><span class="pv-detail-value">${Math.round(m.dim_x)}×${Math.round(m.dim_y)}×${Math.round(m.dim_z)} mm${m.volume > 0 ? ' · '+m.volume+' cm³' : ''}</span></div>` : ''}
|
||||||
${m.volume > 0 ? `<div class="pv-detail-item"><span class="pv-detail-label">Volume</span><span class="pv-detail-value">${m.volume.toFixed(2)} cm³</span></div>` : ''}
|
${isGcode && m.gcode_time ? `<div class="pv-detail-item"><span class="pv-detail-label">Temps</span><span class="pv-detail-value">${escHtml(m.gcode_time)}</span></div>` : ''}
|
||||||
${isGcode && m.gcode_print_time ? `<div class="pv-detail-item"><span class="pv-detail-label">Temps impression</span><span class="pv-detail-value">${escHtml(m.gcode_print_time)}</span></div>` : ''}
|
|
||||||
${isGcode && m.gcode_filament ? `<div class="pv-detail-item"><span class="pv-detail-label">Filament</span><span class="pv-detail-value">${escHtml(m.gcode_filament)}</span></div>` : ''}
|
${isGcode && m.gcode_filament ? `<div class="pv-detail-item"><span class="pv-detail-label">Filament</span><span class="pv-detail-value">${escHtml(m.gcode_filament)}</span></div>` : ''}
|
||||||
${isGcode && m.gcode_nozzle_temp ? `<div class="pv-detail-item"><span class="pv-detail-label">Buse</span><span class="pv-detail-value">${escHtml(m.gcode_nozzle_temp)}</span></div>` : ''}
|
${isGcode && m.gcode_nozzle ? `<div class="pv-detail-item"><span class="pv-detail-label">Buse</span><span class="pv-detail-value">${escHtml(m.gcode_nozzle)}</span></div>` : ''}
|
||||||
${isGcode && m.gcode_bed_temp ? `<div class="pv-detail-item"><span class="pv-detail-label">Plateau</span><span class="pv-detail-value">${escHtml(m.gcode_bed_temp)}</span></div>` : ''}
|
${isGcode && m.gcode_bed ? `<div class="pv-detail-item"><span class="pv-detail-label">Plateau</span><span class="pv-detail-value">${escHtml(m.gcode_bed)}</span></div>` : ''}
|
||||||
</div>`;
|
</div>`;
|
||||||
|
|
||||||
document.getElementById('pv-detail-3d-btn').onclick = () => window.open(`${PV_BASE}/model/${m.id}`, '_blank');
|
const btn3d = document.getElementById('pv-detail-3d-btn');
|
||||||
document.getElementById('pv-detail-dl-btn').href = `${API}?action=file&id=${m.id}`;
|
if (canView) {
|
||||||
document.getElementById('pv-detail-del-btn').onclick = () => deleteModel(m.id, m.name);
|
btn3d.style.display = '';
|
||||||
|
btn3d.onclick = () => window.open(`/printvault-viewer.php?id=${id}`, '_blank', 'width=1200,height=800');
|
||||||
|
} else {
|
||||||
|
btn3d.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('pv-detail-dl-btn').href = `${API}?action=file&id=${id}`;
|
||||||
|
document.getElementById('pv-detail-edit-btn').onclick = () => openEdit(m);
|
||||||
|
document.getElementById('pv-detail-del-btn').onclick = () => deleteModel(id, m.name);
|
||||||
document.getElementById('pv-detail-modal').classList.add('show');
|
document.getElementById('pv-detail-modal').classList.add('show');
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deleteModel(id, name) {
|
async function deleteModel(id, name) {
|
||||||
if (!confirm(`Supprimer "${name}" ?`)) return;
|
if (!confirm(`Supprimer "${name}" ?`)) return;
|
||||||
try {
|
try {
|
||||||
await api('models', 'DELETE', null, '&id=' + id);
|
await api('models', 'DELETE', null, '&id='+id);
|
||||||
document.getElementById('pv-detail-modal').classList.remove('show');
|
document.getElementById('pv-detail-modal').classList.remove('show');
|
||||||
toast('Modèle supprimé');
|
toast('Modèle supprimé');
|
||||||
allModels = allModels.filter(m => m.id !== id);
|
allModels = allModels.filter(m => m.id !== id);
|
||||||
renderSidebar();
|
renderSidebar(); renderModels();
|
||||||
renderModels();
|
} catch(e) { toast(e.message, 'error'); }
|
||||||
} catch (e) { toast(e.message, 'error'); }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Upload modal ─────────────────────────────────────────────────────────────
|
// ─── Edit ─────────────────────────────────────────────────────────────────────
|
||||||
|
function openEdit(m) {
|
||||||
|
document.getElementById('pv-detail-modal').classList.remove('show');
|
||||||
|
document.getElementById('pv-edit-id').value = m.id;
|
||||||
|
document.getElementById('pv-edit-name').value = m.name;
|
||||||
|
document.getElementById('pv-edit-desc').value = m.description || '';
|
||||||
|
document.getElementById('pv-edit-tags').value = m.tags || '';
|
||||||
|
const sel = document.getElementById('pv-edit-cat');
|
||||||
|
sel.innerHTML = categories.map(c => `<option value="${escHtml(c.name)}"${c.name===m.category?' selected':''}>${escHtml(c.name)}</option>`).join('');
|
||||||
|
document.getElementById('pv-edit-modal').classList.add('show');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveEdit() {
|
||||||
|
const id = document.getElementById('pv-edit-id').value;
|
||||||
|
const name = document.getElementById('pv-edit-name').value.trim();
|
||||||
|
if (!name) { toast('Nom requis', 'error'); return; }
|
||||||
|
try {
|
||||||
|
await api('models', 'PUT', {
|
||||||
|
name,
|
||||||
|
description: document.getElementById('pv-edit-desc').value,
|
||||||
|
category: document.getElementById('pv-edit-cat').value,
|
||||||
|
tags: document.getElementById('pv-edit-tags').value,
|
||||||
|
}, '&id='+id);
|
||||||
|
toast('Mis à jour ✓');
|
||||||
|
document.getElementById('pv-edit-modal').classList.remove('show');
|
||||||
|
await loadModels();
|
||||||
|
} catch(e) { toast(e.message, 'error'); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Upload ───────────────────────────────────────────────────────────────────
|
||||||
let uploadFile = null;
|
let uploadFile = null;
|
||||||
|
|
||||||
function openUpload() {
|
function openUpload() {
|
||||||
@@ -243,21 +238,19 @@ function openUpload() {
|
|||||||
document.getElementById('pv-upload-name').value = '';
|
document.getElementById('pv-upload-name').value = '';
|
||||||
document.getElementById('pv-upload-desc').value = '';
|
document.getElementById('pv-upload-desc').value = '';
|
||||||
document.getElementById('pv-upload-tags').value = '';
|
document.getElementById('pv-upload-tags').value = '';
|
||||||
const catSel = document.getElementById('pv-upload-cat');
|
const sel = document.getElementById('pv-upload-cat');
|
||||||
catSel.innerHTML = categories.map(c => `<option value="${escHtml(c.name)}">${escHtml(c.name)}</option>`).join('');
|
sel.innerHTML = categories.map(c => `<option value="${escHtml(c.name)}">${escHtml(c.name)}</option>`).join('');
|
||||||
document.getElementById('pv-upload-progress').style.display = 'none';
|
document.getElementById('pv-upload-progress').style.display = 'none';
|
||||||
document.getElementById('pv-upload-modal').classList.add('show');
|
document.getElementById('pv-upload-modal').classList.add('show');
|
||||||
}
|
}
|
||||||
|
|
||||||
function setUploadFile(f) {
|
function setUploadFile(f) {
|
||||||
const allowed = ['stl', '3mf', 'gcode', 'gco', 'g'];
|
|
||||||
const ext = f.name.split('.').pop().toLowerCase();
|
const ext = f.name.split('.').pop().toLowerCase();
|
||||||
if (!allowed.includes(ext)) { toast('Format non supporté (STL, 3MF, GCode)', 'error'); return; }
|
if (!['stl','3mf','gcode','gco','g'].includes(ext)) { toast('Format non supporté', 'error'); return; }
|
||||||
uploadFile = f;
|
uploadFile = f;
|
||||||
document.getElementById('pv-upload-filename').textContent = f.name;
|
document.getElementById('pv-upload-filename').textContent = f.name;
|
||||||
if (!document.getElementById('pv-upload-name').value) {
|
if (!document.getElementById('pv-upload-name').value)
|
||||||
document.getElementById('pv-upload-name').value = f.name.replace(/\.[^.]+$/, '').replace(/[_-]/g, ' ');
|
document.getElementById('pv-upload-name').value = f.name.replace(/\.[^.]+$/,'').replace(/[_-]/g,' ');
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveUpload() {
|
async function saveUpload() {
|
||||||
@@ -274,8 +267,7 @@ async function saveUpload() {
|
|||||||
|
|
||||||
const prog = document.getElementById('pv-upload-progress');
|
const prog = document.getElementById('pv-upload-progress');
|
||||||
const bar = document.getElementById('pv-upload-bar');
|
const bar = document.getElementById('pv-upload-bar');
|
||||||
prog.style.display = '';
|
prog.style.display = ''; bar.style.width = '20%';
|
||||||
bar.style.width = '30%';
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await api('models', 'POST', fd);
|
await api('models', 'POST', fd);
|
||||||
@@ -285,8 +277,5 @@ async function saveUpload() {
|
|||||||
toast('Modèle ajouté ✓');
|
toast('Modèle ajouté ✓');
|
||||||
loadModels();
|
loadModels();
|
||||||
}, 300);
|
}, 300);
|
||||||
} catch (e) {
|
} catch(e) { prog.style.display = 'none'; toast(e.message, 'error'); }
|
||||||
prog.style.display = 'none';
|
|
||||||
toast(e.message, 'error');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,289 @@
|
|||||||
|
<?php
|
||||||
|
require __DIR__ . '/includes/auth.php';
|
||||||
|
require_login();
|
||||||
|
require_once __DIR__ . '/includes/db.php';
|
||||||
|
|
||||||
|
$id = (int)($_GET['id'] ?? 0);
|
||||||
|
if (!$id) { http_response_code(404); exit('Modèle introuvable'); }
|
||||||
|
|
||||||
|
$s = $pdo->prepare("SELECT * FROM pf_pv_models WHERE id=?"); $s->execute([$id]);
|
||||||
|
$model = $s->fetch();
|
||||||
|
if (!$model) { http_response_code(404); exit('Modèle introuvable'); }
|
||||||
|
|
||||||
|
$ext = strtolower($model['file_type']);
|
||||||
|
$canView = in_array($ext, ['stl','3mf']);
|
||||||
|
?><!DOCTYPE html>
|
||||||
|
<html lang="fr">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title><?= htmlspecialchars($model['name']) ?> — PrintVault</title>
|
||||||
|
<link rel="icon" href="/favicon.png">
|
||||||
|
<style>
|
||||||
|
* { margin:0; padding:0; box-sizing:border-box; }
|
||||||
|
body { background:#0f172a; color:#e2e8f0; font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif; overflow:hidden; }
|
||||||
|
#canvas-container { width:100vw; height:100vh; position:relative; }
|
||||||
|
canvas { display:block; }
|
||||||
|
#ui-overlay {
|
||||||
|
position:fixed; top:0; left:0; right:0;
|
||||||
|
display:flex; align-items:center; justify-content:space-between;
|
||||||
|
padding:.75rem 1.25rem; background:rgba(15,23,42,.85); backdrop-filter:blur(8px);
|
||||||
|
border-bottom:1px solid rgba(255,255,255,.08); z-index:10;
|
||||||
|
}
|
||||||
|
#model-name { font-size:.95rem; font-weight:600; color:#f1f5f9; }
|
||||||
|
#model-meta { font-size:.75rem; color:#94a3b8; margin-top:.15rem; }
|
||||||
|
.ui-btns { display:flex; gap:.5rem; }
|
||||||
|
.ui-btn {
|
||||||
|
padding:.35rem .8rem; border-radius:8px; border:1px solid rgba(255,255,255,.15);
|
||||||
|
background:rgba(255,255,255,.08); color:#e2e8f0; font-size:.8rem; cursor:pointer;
|
||||||
|
transition:background .15s; white-space:nowrap;
|
||||||
|
}
|
||||||
|
.ui-btn:hover { background:rgba(255,255,255,.15); }
|
||||||
|
.ui-btn.primary { background:#3b82f6; border-color:#3b82f6; color:#fff; }
|
||||||
|
.ui-btn.primary:hover { background:#2563eb; }
|
||||||
|
#loading {
|
||||||
|
position:fixed; inset:0; display:flex; flex-direction:column;
|
||||||
|
align-items:center; justify-content:center; background:#0f172a; z-index:20;
|
||||||
|
gap:1rem; color:#94a3b8; font-size:.9rem;
|
||||||
|
}
|
||||||
|
.spinner { width:40px; height:40px; border:3px solid #1e293b; border-top-color:#3b82f6; border-radius:50%; animation:spin .8s linear infinite; }
|
||||||
|
@keyframes spin { to { transform:rotate(360deg); } }
|
||||||
|
#error { display:none; text-align:center; }
|
||||||
|
#controls-hint {
|
||||||
|
position:fixed; bottom:1rem; left:50%; transform:translateX(-50%);
|
||||||
|
font-size:.72rem; color:#475569; background:rgba(15,23,42,.7);
|
||||||
|
padding:.35rem .75rem; border-radius:999px; pointer-events:none;
|
||||||
|
}
|
||||||
|
#gcode-info {
|
||||||
|
position:fixed; inset:0; display:flex; flex-direction:column;
|
||||||
|
align-items:center; justify-content:center; gap:1.5rem; padding:2rem;
|
||||||
|
}
|
||||||
|
.info-card {
|
||||||
|
background:#1e293b; border:1px solid #334155; border-radius:14px;
|
||||||
|
padding:1.5rem 2rem; max-width:420px; width:100%;
|
||||||
|
}
|
||||||
|
.info-card h2 { font-size:1.1rem; margin-bottom:1rem; color:#f1f5f9; }
|
||||||
|
.info-row { display:flex; justify-content:space-between; padding:.5rem 0; border-bottom:1px solid #1e293b; font-size:.875rem; }
|
||||||
|
.info-row:last-child { border:none; }
|
||||||
|
.info-label { color:#94a3b8; }
|
||||||
|
.info-value { color:#e2e8f0; font-weight:500; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<div id="ui-overlay">
|
||||||
|
<div>
|
||||||
|
<div id="model-name"><?= htmlspecialchars($model['name']) ?></div>
|
||||||
|
<div id="model-meta">
|
||||||
|
<?= htmlspecialchars(strtoupper($model['file_type'])) ?>
|
||||||
|
<?php if ($model['dim_x'] > 0): ?>
|
||||||
|
· <?= round($model['dim_x']) ?>×<?= round($model['dim_y']) ?>×<?= round($model['dim_z']) ?> mm
|
||||||
|
<?php endif; ?>
|
||||||
|
· <?= round(($model['file_size']??0)/1024) ?> Ko
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="ui-btns">
|
||||||
|
<button class="ui-btn" onclick="resetCamera()">⟳ Centrer</button>
|
||||||
|
<?php if ($canView): ?>
|
||||||
|
<button class="ui-btn" onclick="takeScreenshot()" id="thumb-btn">📸 Aperçu</button>
|
||||||
|
<?php endif; ?>
|
||||||
|
<button class="ui-btn" onclick="window.close()">✕ Fermer</button>
|
||||||
|
<a href="/modules/printvault/api.php?action=file&id=<?= $id ?>" class="ui-btn primary" download>⬇ Télécharger</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php if ($canView): ?>
|
||||||
|
|
||||||
|
<div id="loading">
|
||||||
|
<div class="spinner"></div>
|
||||||
|
<div>Chargement du modèle…</div>
|
||||||
|
<div id="error" style="color:#ef4444"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="canvas-container"></div>
|
||||||
|
<div id="controls-hint">Clic+glisser : rotation · Scroll : zoom · Clic droit : déplacer</div>
|
||||||
|
|
||||||
|
<script type="importmap">
|
||||||
|
{
|
||||||
|
"imports": {
|
||||||
|
"three": "https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.module.js",
|
||||||
|
"three/addons/": "https://cdn.jsdelivr.net/npm/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';
|
||||||
|
<?php if ($ext === '3mf'): ?>
|
||||||
|
import { ThreeMFLoader } from 'three/addons/loaders/3MFLoader.js';
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
const MODEL_URL = '/modules/printvault/api.php?action=model_data&id=<?= $id ?>';
|
||||||
|
const MODEL_ID = <?= $id ?>;
|
||||||
|
const API = '/modules/printvault/api.php';
|
||||||
|
|
||||||
|
// Scene
|
||||||
|
const scene = new THREE.Scene();
|
||||||
|
scene.background = new THREE.Color(0x0f172a);
|
||||||
|
scene.add(new THREE.AmbientLight(0xffffff, 0.6));
|
||||||
|
|
||||||
|
const keyLight = new THREE.DirectionalLight(0xffffff, 1.2);
|
||||||
|
keyLight.position.set(5, 10, 7.5);
|
||||||
|
scene.add(keyLight);
|
||||||
|
|
||||||
|
const fillLight = new THREE.DirectionalLight(0x8ab4f8, 0.4);
|
||||||
|
fillLight.position.set(-5, -3, -5);
|
||||||
|
scene.add(fillLight);
|
||||||
|
|
||||||
|
const rimLight = new THREE.DirectionalLight(0xffffff, 0.3);
|
||||||
|
rimLight.position.set(0, 5, -10);
|
||||||
|
scene.add(rimLight);
|
||||||
|
|
||||||
|
// Grid
|
||||||
|
const grid = new THREE.GridHelper(200, 40, 0x1e293b, 0x1e293b);
|
||||||
|
grid.material.opacity = 0.5; grid.material.transparent = true;
|
||||||
|
scene.add(grid);
|
||||||
|
|
||||||
|
// Camera & renderer
|
||||||
|
const camera = new THREE.PerspectiveCamera(45, window.innerWidth/window.innerHeight, 0.1, 10000);
|
||||||
|
camera.position.set(100, 100, 100);
|
||||||
|
|
||||||
|
const renderer = new THREE.WebGLRenderer({ antialias: true, preserveDrawingBuffer: true });
|
||||||
|
renderer.setPixelRatio(window.devicePixelRatio);
|
||||||
|
renderer.setSize(window.innerWidth, window.innerHeight);
|
||||||
|
renderer.shadowMap.enabled = true;
|
||||||
|
document.getElementById('canvas-container').appendChild(renderer.domElement);
|
||||||
|
|
||||||
|
const controls = new OrbitControls(camera, renderer.domElement);
|
||||||
|
controls.enableDamping = true; controls.dampingFactor = 0.05;
|
||||||
|
|
||||||
|
let meshGroup = null;
|
||||||
|
|
||||||
|
function fitCamera(object) {
|
||||||
|
const box = new THREE.Box3().setFromObject(object);
|
||||||
|
const size = box.getSize(new THREE.Vector3());
|
||||||
|
const center = box.getCenter(new THREE.Vector3());
|
||||||
|
const maxDim = Math.max(size.x, size.y, size.z);
|
||||||
|
const fov = camera.fov * (Math.PI / 180);
|
||||||
|
const dist = Math.abs(maxDim / Math.sin(fov / 2)) * 0.75;
|
||||||
|
camera.position.set(center.x + dist * 0.7, center.y + dist * 0.5, center.z + dist * 0.7);
|
||||||
|
controls.target.copy(center);
|
||||||
|
// Align grid to bottom of object
|
||||||
|
grid.position.y = box.min.y;
|
||||||
|
controls.update();
|
||||||
|
}
|
||||||
|
|
||||||
|
window.resetCamera = () => { if (meshGroup) fitCamera(meshGroup); };
|
||||||
|
|
||||||
|
const material = new THREE.MeshPhysicalMaterial({
|
||||||
|
color: 0x4cc9f0, metalness: 0.3, roughness: 0.4,
|
||||||
|
side: THREE.DoubleSide,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Load model
|
||||||
|
<?php if ($ext === 'stl'): ?>
|
||||||
|
new STLLoader().load(MODEL_URL,
|
||||||
|
(geometry) => {
|
||||||
|
geometry.computeVertexNormals();
|
||||||
|
const mesh = new THREE.Mesh(geometry, material);
|
||||||
|
meshGroup = new THREE.Group(); meshGroup.add(mesh);
|
||||||
|
scene.add(meshGroup);
|
||||||
|
fitCamera(meshGroup);
|
||||||
|
document.getElementById('loading').style.display = 'none';
|
||||||
|
},
|
||||||
|
(xhr) => {
|
||||||
|
if (xhr.total) document.querySelector('#loading div:last-child').textContent =
|
||||||
|
'Chargement… ' + Math.round(xhr.loaded/xhr.total*100) + '%';
|
||||||
|
},
|
||||||
|
(err) => {
|
||||||
|
document.getElementById('error').style.display = '';
|
||||||
|
document.getElementById('error').textContent = 'Erreur chargement: ' + err.message;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
<?php elseif ($ext === '3mf'): ?>
|
||||||
|
new ThreeMFLoader().load(MODEL_URL,
|
||||||
|
(obj) => {
|
||||||
|
meshGroup = obj;
|
||||||
|
// Apply material to all meshes
|
||||||
|
obj.traverse(child => {
|
||||||
|
if (child.isMesh) child.material = material;
|
||||||
|
});
|
||||||
|
scene.add(meshGroup);
|
||||||
|
fitCamera(meshGroup);
|
||||||
|
document.getElementById('loading').style.display = 'none';
|
||||||
|
},
|
||||||
|
(xhr) => {
|
||||||
|
if (xhr.total) document.querySelector('#loading div:last-child').textContent =
|
||||||
|
'Chargement… ' + Math.round(xhr.loaded/xhr.total*100) + '%';
|
||||||
|
},
|
||||||
|
(err) => {
|
||||||
|
document.getElementById('error').style.display = '';
|
||||||
|
document.getElementById('error').textContent = 'Erreur: ' + err.message;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
// Animation loop
|
||||||
|
function animate() {
|
||||||
|
requestAnimationFrame(animate);
|
||||||
|
controls.update();
|
||||||
|
renderer.render(scene, camera);
|
||||||
|
}
|
||||||
|
animate();
|
||||||
|
|
||||||
|
window.addEventListener('resize', () => {
|
||||||
|
camera.aspect = window.innerWidth / window.innerHeight;
|
||||||
|
camera.updateProjectionMatrix();
|
||||||
|
renderer.setSize(window.innerWidth, window.innerHeight);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Screenshot → save as thumbnail
|
||||||
|
window.takeScreenshot = async () => {
|
||||||
|
renderer.render(scene, camera);
|
||||||
|
const canvas = renderer.domElement;
|
||||||
|
canvas.toBlob(async (blob) => {
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append('thumb', blob, 'thumb.png');
|
||||||
|
try {
|
||||||
|
const r = await fetch(API + '?action=thumb&id=' + MODEL_ID, {
|
||||||
|
method: 'POST', credentials: 'same-origin', body: fd,
|
||||||
|
headers: { 'X-Requested-With': 'XMLHttpRequest' }
|
||||||
|
});
|
||||||
|
const j = await r.json();
|
||||||
|
if (j.ok) {
|
||||||
|
document.getElementById('thumb-btn').textContent = '✅ Sauvegardé';
|
||||||
|
setTimeout(() => { document.getElementById('thumb-btn').textContent = '📸 Aperçu'; }, 2000);
|
||||||
|
}
|
||||||
|
} catch(e) { console.error(e); }
|
||||||
|
}, 'image/png', 0.9);
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<?php else: ?>
|
||||||
|
<!-- GCode / non-viewable file: show metadata only -->
|
||||||
|
<div id="gcode-info">
|
||||||
|
<div class="info-card">
|
||||||
|
<h2>🟢 <?= htmlspecialchars($model['name']) ?></h2>
|
||||||
|
<?php $rows = [
|
||||||
|
['Fichier', $model['original_name']],
|
||||||
|
['Taille', round($model['file_size']/1024).' Ko'],
|
||||||
|
['Catégorie', $model['category']],
|
||||||
|
$model['gcode_time'] ? ['Temps impression', $model['gcode_time']] : null,
|
||||||
|
$model['gcode_filament'] ? ['Filament', $model['gcode_filament']]: null,
|
||||||
|
$model['gcode_nozzle'] ? ['Buse', $model['gcode_nozzle']] : null,
|
||||||
|
$model['gcode_bed'] ? ['Plateau', $model['gcode_bed']] : null,
|
||||||
|
['Ajouté le', date('d/m/Y', strtotime($model['created_at']))],
|
||||||
|
]; ?>
|
||||||
|
<?php foreach (array_filter($rows) as $r): ?>
|
||||||
|
<div class="info-row">
|
||||||
|
<span class="info-label"><?= htmlspecialchars($r[0]) ?></span>
|
||||||
|
<span class="info-value"><?= htmlspecialchars($r[1]) ?></span>
|
||||||
|
</div>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
+41
-20
@@ -22,15 +22,11 @@ require __DIR__ . '/header.php';
|
|||||||
<input type="text" id="pv-search" placeholder="Rechercher…">
|
<input type="text" id="pv-search" placeholder="Rechercher…">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Type filter chips -->
|
|
||||||
<div class="pv-type-chips">
|
<div class="pv-type-chips">
|
||||||
<span class="pv-chip stl" data-type="stl" onclick="setType('stl')">STL</span>
|
<span class="pv-chip stl" data-type="stl" onclick="setType('stl')">STL</span>
|
||||||
<span class="pv-chip 3mf" data-type="3mf" onclick="setType('3mf')">3MF</span>
|
<span class="pv-chip 3mf" data-type="3mf" onclick="setType('3mf')">3MF</span>
|
||||||
<span class="pv-chip gcode" data-type="gcode" onclick="setType('gcode')">GCode</span>
|
<span class="pv-chip gcode" data-type="gcode" onclick="setType('gcode')">GCode</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Categories nav -->
|
|
||||||
<div id="pv-sidebar-nav"></div>
|
<div id="pv-sidebar-nav"></div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
@@ -41,17 +37,10 @@ require __DIR__ . '/header.php';
|
|||||||
<h2>🖨️ PrintVault</h2>
|
<h2>🖨️ PrintVault</h2>
|
||||||
<div class="pv-subtitle" id="pv-subtitle"></div>
|
<div class="pv-subtitle" id="pv-subtitle"></div>
|
||||||
</div>
|
</div>
|
||||||
<div style="display:flex;gap:.5rem">
|
|
||||||
<a href="https://printvault.nas.percolouco.com" target="_blank" class="btn btn-secondary btn-sm">Ouvrir PrintVault ↗</a>
|
|
||||||
<button class="btn btn-primary btn-sm" onclick="openUpload()">+ Upload</button>
|
<button class="btn btn-primary btn-sm" onclick="openUpload()">+ Upload</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="pv-grid" id="pv-grid">
|
<div class="pv-grid" id="pv-grid">
|
||||||
<div class="pv-empty" style="grid-column:1/-1">
|
<div class="pv-empty" style="grid-column:1/-1"><div class="icon">🖨️</div><p>Chargement…</p></div>
|
||||||
<div class="icon">🖨️</div>
|
|
||||||
<p>Chargement…</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -66,8 +55,44 @@ require __DIR__ . '/header.php';
|
|||||||
<div class="pv-modal-body" id="pv-detail-body"></div>
|
<div class="pv-modal-body" id="pv-detail-body"></div>
|
||||||
<div class="pv-modal-footer">
|
<div class="pv-modal-footer">
|
||||||
<button class="btn btn-danger btn-sm" id="pv-detail-del-btn" style="margin-right:auto">🗑 Supprimer</button>
|
<button class="btn btn-danger btn-sm" id="pv-detail-del-btn" style="margin-right:auto">🗑 Supprimer</button>
|
||||||
|
<button class="btn btn-secondary btn-sm" id="pv-detail-edit-btn">✏️ Modifier</button>
|
||||||
<a class="btn btn-secondary btn-sm" id="pv-detail-dl-btn" download>⬇ Télécharger</a>
|
<a class="btn btn-secondary btn-sm" id="pv-detail-dl-btn" download>⬇ Télécharger</a>
|
||||||
<button class="btn btn-primary btn-sm" id="pv-detail-3d-btn">🎮 Voir en 3D</button>
|
<button class="btn btn-primary btn-sm" id="pv-detail-3d-btn">🎮 Vue 3D</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── MODAL : ÉDITION ─────────────────────────────────────────────────────── -->
|
||||||
|
<div class="pv-modal-backdrop" id="pv-edit-modal">
|
||||||
|
<div class="pv-modal">
|
||||||
|
<div class="pv-modal-header">
|
||||||
|
<h3>Modifier le modèle</h3>
|
||||||
|
<button class="pv-modal-close" onclick="document.getElementById('pv-edit-modal').classList.remove('show')">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="pv-modal-body">
|
||||||
|
<input type="hidden" id="pv-edit-id">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Nom *</label>
|
||||||
|
<input type="text" id="pv-edit-name" class="form-control">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Description</label>
|
||||||
|
<input type="text" id="pv-edit-desc" class="form-control">
|
||||||
|
</div>
|
||||||
|
<div style="display:grid;grid-template-columns:1fr 1fr;gap:.75rem">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Catégorie</label>
|
||||||
|
<select id="pv-edit-cat" class="form-control"></select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Tags</label>
|
||||||
|
<input type="text" id="pv-edit-tags" class="form-control" placeholder="tag1, tag2">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="pv-modal-footer">
|
||||||
|
<button class="btn btn-secondary" onclick="document.getElementById('pv-edit-modal').classList.remove('show')">Annuler</button>
|
||||||
|
<button class="btn btn-primary" onclick="saveEdit()">Enregistrer</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -80,25 +105,21 @@ require __DIR__ . '/header.php';
|
|||||||
<button class="pv-modal-close" onclick="document.getElementById('pv-upload-modal').classList.remove('show')">×</button>
|
<button class="pv-modal-close" onclick="document.getElementById('pv-upload-modal').classList.remove('show')">×</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="pv-modal-body">
|
<div class="pv-modal-body">
|
||||||
|
|
||||||
<div class="pv-drop-zone" id="pv-drop-zone">
|
<div class="pv-drop-zone" id="pv-drop-zone">
|
||||||
<div class="icon">📁</div>
|
<div class="icon">📁</div>
|
||||||
<div>Glissez un fichier ici ou cliquez pour choisir</div>
|
<div>Glissez un fichier ici ou cliquez</div>
|
||||||
<div style="font-size:.75rem;margin-top:.25rem">STL · 3MF · GCode</div>
|
<div style="font-size:.75rem;margin-top:.25rem;color:var(--text-muted)">STL · 3MF · GCode</div>
|
||||||
<div class="pv-file-chosen" id="pv-upload-filename"></div>
|
<div class="pv-file-chosen" id="pv-upload-filename"></div>
|
||||||
<input type="file" id="pv-file-input" accept=".stl,.3mf,.gcode,.gco,.g" style="display:none">
|
<input type="file" id="pv-file-input" accept=".stl,.3mf,.gcode,.gco,.g" style="display:none">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label">Nom *</label>
|
<label class="form-label">Nom *</label>
|
||||||
<input type="text" id="pv-upload-name" class="form-control" placeholder="Nom du modèle">
|
<input type="text" id="pv-upload-name" class="form-control" placeholder="Nom du modèle">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label">Description</label>
|
<label class="form-label">Description</label>
|
||||||
<input type="text" id="pv-upload-desc" class="form-control" placeholder="Optionnel">
|
<input type="text" id="pv-upload-desc" class="form-control" placeholder="Optionnel">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:.75rem">
|
<div style="display:grid;grid-template-columns:1fr 1fr;gap:.75rem">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label">Catégorie</label>
|
<label class="form-label">Catégorie</label>
|
||||||
@@ -109,9 +130,9 @@ require __DIR__ . '/header.php';
|
|||||||
<input type="text" id="pv-upload-tags" class="form-control" placeholder="tag1, tag2">
|
<input type="text" id="pv-upload-tags" class="form-control" placeholder="tag1, tag2">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="pv-upload-progress" style="display:none">
|
<div id="pv-upload-progress" style="display:none">
|
||||||
<div class="pv-progress"><div class="pv-progress-bar" id="pv-upload-bar" style="width:0%"></div></div>
|
<div class="pv-progress"><div class="pv-progress-bar" id="pv-upload-bar" style="width:0%"></div></div>
|
||||||
|
<div style="font-size:.75rem;color:var(--text-muted);margin-top:.3rem">Upload en cours… (parsing géométrie)</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="pv-modal-footer">
|
<div class="pv-modal-footer">
|
||||||
|
|||||||
Reference in New Issue
Block a user