feat: integrate Draco compression for 3D models in backoffice

- Added support for uploading and compressing GLB models using Draco in the backoffice.
- Implemented commands for importing and re-compressing 3D models.
- Updated model resource to handle 3D model uploads and validations.
- Introduced a new script for compressing GLB files with Draco.
- Modified Vite configuration to remove GLB files from the build output.
- Updated package dependencies to include GLTF-transform libraries for handling 3D models.
- Added tests to ensure proper functionality of 3D model handling and compression.
This commit is contained in:
2026-11-01 10:35:50 +01:00
parent 24f9e59654
commit 1208187a17
32 changed files with 895 additions and 52 deletions
@@ -0,0 +1,158 @@
<?php
namespace App\Support\Configuratore;
use App\Models\Modello;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
/**
* Modelli 3D dei prodotti, caricati dal backoffice e associati al codice del modello.
*
* Di ogni .glb si tengono due copie:
* - l'originale non compresso, sul disco privato (`local`), conservato per sviluppi futuri
* (es. ricompressione con altre opzioni, altri formati);
* - la copia compressa con Draco, sul disco `public`, l'unica servita al configuratore
* (rotta API media, con CORS). Il nome contiene un hash del contenuto, così il browser può
* tenerla in cache a lungo e ogni nuovo caricamento cambia URL.
*/
class Modelli3d
{
public const CARTELLA_ORIGINALI = 'modelli-3d/originali';
public const CARTELLA_DRACO = 'modelli-3d/draco';
public function __construct(private CompressoreGlb $compressore) {}
/**
* Associa al modello il .glb in $sorgente: lo comprime e salva le due copie. Se il file non è
* valido lancia Modello3dNonValido senza toccare il modello né i file precedenti.
*/
public function carica(Modello $modello, string $sorgente, ?string $nomeOriginale = null): void
{
$this->verificaGlb($sorgente);
$compresso = $this->comprimi($sorgente);
try {
$base = $this->nomeBase($modello);
$hash = substr(hash_file('sha256', $compresso), 0, 12);
$originale = self::CARTELLA_ORIGINALI."/{$base}-{$hash}.glb";
$draco = self::CARTELLA_DRACO."/{$base}-{$hash}.glb";
$this->scrivi('local', $originale, $sorgente);
$this->scrivi('public', $draco, $compresso);
$this->aggiorna($modello, [
'modello_3d' => $originale,
'modello_3d_draco' => $draco,
'modello_3d_nome' => $nomeOriginale ?: basename($sorgente),
'modello_3d_dimensione' => filesize($sorgente),
'modello_3d_draco_dimensione' => filesize($compresso),
]);
} finally {
@unlink($compresso);
}
}
/** Rigenera la copia compressa dall'originale (es. dopo un cambio delle opzioni Draco). */
public function ricomprimi(Modello $modello): void
{
if (! $modello->modello_3d || ! Storage::disk('local')->exists($modello->modello_3d)) {
throw new Modello3dNonValido("Il modello {$modello->codice} non ha un originale da ricomprimere.");
}
$compresso = $this->comprimi(Storage::disk('local')->path($modello->modello_3d));
try {
$hash = substr(hash_file('sha256', $compresso), 0, 12);
$draco = self::CARTELLA_DRACO."/{$this->nomeBase($modello)}-{$hash}.glb";
$this->scrivi('public', $draco, $compresso);
$this->aggiorna($modello, [
'modello_3d_draco' => $draco,
'modello_3d_draco_dimensione' => filesize($compresso),
]);
} finally {
@unlink($compresso);
}
}
/** Elimina i file del modello 3D (chiamato quando il modello viene cancellato). */
public function elimina(Modello $modello): void
{
$this->eliminaFile($modello->modello_3d, $modello->modello_3d_draco);
}
/** Controlla l'intestazione GLB (magic "glTF", versione 2) prima di avviare la compressione. */
public function verificaGlb(string $percorso): void
{
$intestazione = is_file($percorso) ? (string) file_get_contents($percorso, length: 12) : '';
if (strlen($intestazione) < 12 || substr($intestazione, 0, 4) !== 'glTF') {
throw new Modello3dNonValido('Il file non è un modello glTF binario (.glb).');
}
if (unpack('V', substr($intestazione, 4, 4))[1] !== 2) {
throw new Modello3dNonValido('Il file .glb deve essere in formato glTF 2.0.');
}
}
private function comprimi(string $sorgente): string
{
// tempnam crea il file: lo si sostituisce con uno con estensione .glb.
$temporaneo = tempnam(sys_get_temp_dir(), 'draco');
@unlink($temporaneo);
$destinazione = $temporaneo.'.glb';
try {
$this->compressore->comprimi($sorgente, $destinazione);
} catch (\Throwable $e) {
@unlink($destinazione);
throw $e;
}
return $destinazione;
}
/** Salva i nuovi percorsi ed elimina i file sostituiti. */
private function aggiorna(Modello $modello, array $valori): void
{
$vecchi = [$modello->modello_3d, $modello->modello_3d_draco];
$modello->forceFill($valori + ['modello_3d_aggiornato_at' => now()])->save();
$this->eliminaFile(...array_diff(array_filter($vecchi), [$modello->modello_3d, $modello->modello_3d_draco]));
}
private function eliminaFile(?string ...$percorsi): void
{
foreach (array_filter($percorsi) as $percorso) {
$disco = str_starts_with($percorso, self::CARTELLA_ORIGINALI) ? 'local' : 'public';
Storage::disk($disco)->delete($percorso);
}
}
private function scrivi(string $disco, string $percorso, string $file): void
{
$stream = fopen($file, 'rb');
try {
if (! Storage::disk($disco)->writeStream($percorso, $stream)) {
throw new Modello3dNonValido("Impossibile salvare il file {$percorso}.");
}
} finally {
if (is_resource($stream)) {
fclose($stream);
}
}
}
/** Es. "pvc/finestra_2_ante": sistema e codice del modello, per riconoscere i file a colpo d'occhio. */
private function nomeBase(Modello $modello): string
{
$sistema = Str::slug($modello->sistema?->codice ?? 'sistema');
return $sistema.'/'.Str::slug($modello->codice, '_');
}
}