From 1208187a17d8d343f3e8116785ea00c55037290d Mon Sep 17 00:00:00 2001 From: Carmine Savino Date: Sun, 1 Nov 2026 10:35:50 +0100 Subject: [PATCH] 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. --- CLAUDE.md | 16 +- backoffice/.env.example | 4 + .../app/Console/Commands/ImportaModelli3d.php | 70 +++++++ .../Console/Commands/RicomprimiModelli3d.php | 38 ++++ .../Filament/Resources/ModelloResource.php | 69 ++++++- .../Pages/Concerns/CaricaModello3d.php | 60 ++++++ .../ModelloResource/Pages/CreateModello.php | 3 + .../ModelloResource/Pages/EditModello.php | 3 + .../Controllers/Api/CatalogoController.php | 3 +- .../Http/Controllers/Api/MediaController.php | 10 +- backoffice/app/Models/Modello.php | 19 +- .../Support/Configuratore/CompressoreGlb.php | 37 ++++ .../app/Support/Configuratore/Media.php | 2 +- .../app/Support/Configuratore/Modelli3d.php | 158 +++++++++++++++ .../Configuratore/Modello3dNonValido.php | 8 + backoffice/config/configuratore.php | 19 ++ backoffice/config/livewire.php | 21 ++ ...100000_add_modello_3d_to_modelli_table.php | 34 ++++ .../tests/Feature/CatalogoAdminTest.php | 4 +- backoffice/tests/Feature/CatalogoApiTest.php | 8 +- .../tests/Feature/ConfiguratoreAdminTest.php | 2 +- .../tests/Feature/ConfiguratorePrezzoTest.php | 1 + .../tests/Feature/ImportazioneListiniTest.php | 3 +- backoffice/tests/Feature/Modelli3dTest.php | 186 ++++++++++++++++++ backoffice/tests/TestCase.php | 7 +- docs/PREVENTIVI_BACKOFFICE.md | 18 ++ package.json | 8 +- pnpm-lock.yaml | 24 +-- scripts/comprimi-glb.mjs | 54 +++++ src/js/starter.js | 21 +- vite-plugins/encrypt-models.js | 4 +- vite.config.js | 33 +++- 32 files changed, 895 insertions(+), 52 deletions(-) create mode 100644 backoffice/app/Console/Commands/ImportaModelli3d.php create mode 100644 backoffice/app/Console/Commands/RicomprimiModelli3d.php create mode 100644 backoffice/app/Filament/Resources/ModelloResource/Pages/Concerns/CaricaModello3d.php create mode 100644 backoffice/app/Support/Configuratore/CompressoreGlb.php create mode 100644 backoffice/app/Support/Configuratore/Modelli3d.php create mode 100644 backoffice/app/Support/Configuratore/Modello3dNonValido.php create mode 100644 backoffice/config/configuratore.php create mode 100644 backoffice/config/livewire.php create mode 100644 backoffice/database/migrations/2026_11_01_100000_add_modello_3d_to_modelli_table.php create mode 100644 backoffice/tests/Feature/Modelli3dTest.php create mode 100644 scripts/comprimi-glb.mjs diff --git a/CLAUDE.md b/CLAUDE.md index 76a13ce..9a10bff 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -62,8 +62,8 @@ Entry point is `src/js/starter.js`, loaded from `src/index.html`. Flow on load ( updates the DOM. "Add to cart" sends the same codes (`scelte`): the backend recomputes the price and ignores any price coming from the client. 5. **`APP.Player`** (`src/js/app.js`) wraps a Three.js renderer/scene/camera (scene graph loaded from - `src/assets/app.json`, a three.js-editor-style export) plus the actual product GLB - (`src/assets/models//`). Two players are created: `player` (visible canvas) and + `src/assets/app.json`, a three.js-editor-style export) plus the actual product GLB, loaded from + `modello.modello_url` (see "3D models" below) with a `DRACOLoader`. Two players are created: `player` (visible canvas) and `playerOff` (offscreen, used only to render the PNG snapshot sent to the cart). Key methods: `swapModel`/ `hideModel` (toggle between pre-loaded variants in the same scene), `changeTexture`/`changeColor` (applied per `userData.categoria` mesh tag — categories like `interno`, `esterno`, `maniglia`, `vetro`, `cerniere`), @@ -85,6 +85,18 @@ Entry point is `src/js/starter.js`, loaded from `src/index.html`. Flow on load ( (spec: "Specifica tecnica vetri"). All of it is edited in Filament under "Configuratore". `src/js/price_calculator.js` only wraps the API call and the grid sizes. +### 3D models + +GLB files are managed by the backoffice, one per `Modello` (uploaded in Filament, "Configurazione 3D" section). +On save, `App\Support\Configuratore\Modelli3d` runs `scripts/comprimi-glb.mjs` (Node, `@gltf-transform` + +`draco3dgltf` from the root `node_modules`) and keeps two copies: the uncompressed original on the private +`local` disk (`modelli-3d/originali/...`, never served) and the Draco copy on the `public` disk +(`modelli-3d/draco//-.glb`), served via `GET /api/media/...` with a 1-year immutable cache. +`/api/sistemi` exposes it as `modello_url`; models without one are not shown in the configurator. +`php artisan modelli3d:importa` imports the legacy `src/assets/models` files (one-off), `modelli3d:ricomprimi` +regenerates the Draco copies from the originals. The Vite build never ships `.glb` files. In production set +`NODE_BINARY` if PHP-FPM has no `node` on its PATH. + ### Data conventions - Every 3D-visible part is tagged with `userData.categoria` in the GLB/scene so JS can show/hide/retexture it diff --git a/backoffice/.env.example b/backoffice/.env.example index c0660ea..1bc3b66 100644 --- a/backoffice/.env.example +++ b/backoffice/.env.example @@ -63,3 +63,7 @@ AWS_BUCKET= AWS_USE_PATH_STYLE_ENDPOINT=false VITE_APP_NAME="${APP_NAME}" + +# Modelli 3D: Node per la compressione Draco (percorso completo se PHP-FPM non lo trova nel PATH). +# NODE_BINARY=/opt/plesk/node/22/bin/node +# MODELLI_3D_MAX_MB=200 diff --git a/backoffice/app/Console/Commands/ImportaModelli3d.php b/backoffice/app/Console/Commands/ImportaModelli3d.php new file mode 100644 index 0000000..4326e24 --- /dev/null +++ b/backoffice/app/Console/Commands/ImportaModelli3d.php @@ -0,0 +1,70 @@ +/), comprimendoli come un caricamento dal backoffice. + * I modelli che hanno già un modello 3D sono saltati (salvo --forza). + */ +class ImportaModelli3d extends Command +{ + protected $signature = 'modelli3d:importa + {cartella? : Cartella con le sottocartelle per sistema (default: ../src/assets/models)} + {--forza : Sostituisce anche i modelli 3D già caricati}'; + + protected $description = 'Importa i modelli 3D (.glb) dalla cartella del configuratore e li comprime con Draco'; + + public function handle(Modelli3d $modelli3d): int + { + $cartella = rtrim($this->argument('cartella') ?? base_path('../src/assets/models'), '/'); + if (! is_dir($cartella)) { + $this->error("Cartella non trovata: {$cartella}"); + + return self::FAILURE; + } + + $importati = 0; + $errori = 0; + + $modelli = Modello::with('sistema')->whereNotNull('file_modello')->orderBy('sistema_id')->orderBy('ordine')->get(); + foreach ($modelli as $modello) { + $etichetta = "{$modello->sistema->codice}/{$modello->codice}"; + + if ($modello->modello_3d_draco && ! $this->option('forza')) { + $this->line(" {$etichetta}: già presente, saltato"); + + continue; + } + + $file = "{$cartella}/{$modello->sistema->codice}/{$modello->file_modello}"; + if (! is_file($file)) { + $this->warn(" {$etichetta}: {$modello->file_modello} non trovato"); + + continue; + } + + try { + $modelli3d->carica($modello, $file, $modello->file_modello); + $importati++; + $this->info(sprintf(' %s: %s → %s', $etichetta, + Number::fileSize($modello->modello_3d_dimensione, precision: 1), + Number::fileSize($modello->modello_3d_draco_dimensione, precision: 1))); + } catch (Modello3dNonValido $e) { + $errori++; + $this->error(" {$etichetta}: {$e->getMessage()}"); + } + } + + $this->newLine(); + $this->info("Importati {$importati} modelli 3D".($errori ? ", {$errori} errori" : '').'.'); + + return $errori ? self::FAILURE : self::SUCCESS; + } +} diff --git a/backoffice/app/Console/Commands/RicomprimiModelli3d.php b/backoffice/app/Console/Commands/RicomprimiModelli3d.php new file mode 100644 index 0000000..0c3f17b --- /dev/null +++ b/backoffice/app/Console/Commands/RicomprimiModelli3d.php @@ -0,0 +1,38 @@ +whereNotNull('modello_3d') + ->when($this->argument('codice'), fn ($q, $codici) => $q->whereIn('codice', $codici)) + ->get(); + + foreach ($modelli as $modello) { + try { + $modelli3d->ricomprimi($modello); + $this->info(" {$modello->sistema->codice}/{$modello->codice}: ricompresso"); + } catch (Modello3dNonValido $e) { + $errori++; + $this->error(" {$modello->sistema->codice}/{$modello->codice}: {$e->getMessage()}"); + } + } + + return $errori ? self::FAILURE : self::SUCCESS; + } +} diff --git a/backoffice/app/Filament/Resources/ModelloResource.php b/backoffice/app/Filament/Resources/ModelloResource.php index 98773b6..da62790 100644 --- a/backoffice/app/Filament/Resources/ModelloResource.php +++ b/backoffice/app/Filament/Resources/ModelloResource.php @@ -8,6 +8,9 @@ use App\Filament\Resources\ModelloResource\RelationManagers\FinitureRelationMana use App\Filament\Resources\ModelloResource\RelationManagers\ListinoRelationManager; use App\Filament\Resources\ModelloResource\RelationManagers\OpzioniRelationManager; use App\Models\Modello; +use App\Support\Configuratore\Modelli3d; +use App\Support\Configuratore\Modello3dNonValido; +use Closure; use Filament\Forms; use Filament\Forms\Form; use Filament\Forms\Get; @@ -15,6 +18,8 @@ use Filament\Resources\Resource; use Filament\Tables; use Filament\Tables\Table; use Illuminate\Database\Eloquent\Builder; +use Illuminate\Support\Facades\Storage; +use Illuminate\Support\Number; use Illuminate\Validation\Rules\Unique; @@ -89,7 +94,7 @@ class ModelloResource extends Resource ->default(0), Forms\Components\Toggle::make('attivo') ->label('Visibile nel configuratore') - ->helperText('Richiede la configurazione 3D.') + ->helperText('Richiede la configurazione 3D (con il modello .glb).') ->default(true) ->live(), ]), @@ -103,10 +108,41 @@ class ModelloResource extends Resource ->label('Cartella dati') ->helperText('src/assets/data/modelli///') ->required(fn (Get $get) => (bool) $get('attivo')), - Forms\Components\TextInput::make('file_modello') - ->label('File modello 3D') - ->helperText('src/assets/models//.glb') - ->required(fn (Get $get) => (bool) $get('attivo')), + Forms\Components\Placeholder::make('modello_3d_attuale') + ->key('modello_3d_attuale') + ->label('Modello 3D attuale') + ->visible(fn (?Modello $record) => (bool) $record?->modello_3d_draco) + ->content(fn (Modello $record) => self::descriviModello3d($record)) + ->hintActions([ + Forms\Components\Actions\Action::make('scarica_originale') + ->label('Originale') + ->icon('heroicon-m-arrow-down-tray') + ->visible(fn (?Modello $record) => (bool) $record?->modello_3d) + ->action(fn (Modello $record) => Storage::disk('local')->download( + $record->modello_3d, + $record->modello_3d_nome ?: basename($record->modello_3d), + )), + Forms\Components\Actions\Action::make('scarica_draco') + ->label('Compresso') + ->icon('heroicon-m-arrow-down-tray') + ->url(fn (Modello $record) => $record->modello_3d_url, shouldOpenInNewTab: true), + ]) + ->columnSpanFull(), + Forms\Components\FileUpload::make('modello_3d_upload') + ->label(fn (?Modello $record) => $record?->modello_3d_draco ? 'Sostituisci modello 3D' : 'Modello 3D (.glb)') + ->helperText('File .glb non compresso. Al salvataggio viene compresso con Draco per il configuratore; l\'originale resta archiviato nel backoffice.') + // Il file lo gestisce Modelli3d (vedi Pages\Concerns\CaricaModello3d), non Filament. + ->storeFiles(false) + ->maxSize(fn () => config('configuratore.modelli_3d.max_mb') * 1024) + ->required(fn (Get $get, ?Modello $record) => (bool) $get('attivo') && ! $record?->modello_3d_draco) + ->rule(fn () => function (string $attribute, mixed $file, Closure $fail) { + try { + app(Modelli3d::class)->verificaGlb($file->getRealPath()); + } catch (Modello3dNonValido $e) { + $fail($e->getMessage()); + } + }) + ->columnSpanFull(), Forms\Components\Select::make('categoria') ->label('Categoria') ->options(Modello::CATEGORIE) @@ -161,6 +197,14 @@ class ModelloResource extends Resource ->badge() ->formatStateUsing(fn (?string $state) => Modello::CATEGORIE[$state] ?? $state) ->placeholder('—'), + Tables\Columns\IconColumn::make('modello_3d_draco') + ->label('3D') + ->boolean() + ->getStateUsing(fn (Modello $record) => (bool) $record->modello_3d_draco) + ->falseColor('danger') + ->tooltip(fn (Modello $record) => $record->modello_3d_draco + ? $record->modello_3d_nome + : 'Senza modello 3D: non compare nel configuratore'), Tables\Columns\TextColumn::make('listino_count') ->label('Listino') ->counts('listino') @@ -174,7 +218,7 @@ class ModelloResource extends Resource ->sortable(), Tables\Columns\ToggleColumn::make('attivo') ->label('Attivo') - ->disabled(fn (Modello $record) => ! $record->attivo && (! $record->cartella || ! $record->file_modello)), + ->disabled(fn (Modello $record) => ! $record->attivo && (! $record->cartella || ! $record->modello_3d_draco)), ]) ->filters([ Tables\Filters\SelectFilter::make('sistema')->label('Sistema')->relationship('sistema', 'nome'), @@ -201,6 +245,19 @@ class ModelloResource extends Resource ]); } + /** Es. "finestra_2_ante.glb — 10,8 MB → 0,3 MB con Draco (-97%), caricato il 01/11/2026 10:00". */ + private static function descriviModello3d(Modello $record): string + { + $mb = fn (?int $byte) => Number::fileSize((int) $byte, precision: 1); + $riduzione = $record->modello_3d_dimensione + ? ' (-'.round(100 - $record->modello_3d_draco_dimensione * 100 / $record->modello_3d_dimensione).'%)' + : ''; + + return ($record->modello_3d_nome ?: basename((string) $record->modello_3d_draco)) + .' — '.$mb($record->modello_3d_dimensione).' → '.$mb($record->modello_3d_draco_dimensione).' con Draco'.$riduzione + .($record->modello_3d_aggiornato_at ? ', caricato il '.$record->modello_3d_aggiornato_at->format('d/m/Y H:i') : ''); + } + public static function getRelations(): array { return [ diff --git a/backoffice/app/Filament/Resources/ModelloResource/Pages/Concerns/CaricaModello3d.php b/backoffice/app/Filament/Resources/ModelloResource/Pages/Concerns/CaricaModello3d.php new file mode 100644 index 0000000..b29e68e --- /dev/null +++ b/backoffice/app/Filament/Resources/ModelloResource/Pages/Concerns/CaricaModello3d.php @@ -0,0 +1,60 @@ +caricaModello3d($record, $file); + + return $record; + }); + } + + protected function handleRecordUpdate(Model $record, array $data): Model + { + $file = Arr::pull($data, 'modello_3d_upload'); + + return DB::transaction(function () use ($record, $data, $file) { + $record = parent::handleRecordUpdate($record, $data); + $this->caricaModello3d($record, $file); + + return $record; + }); + } + + private function caricaModello3d(Model $record, mixed $file): void + { + $file = is_array($file) ? Arr::first($file) : $file; + if (! $file instanceof TemporaryUploadedFile || ! $record instanceof Modello) { + return; + } + + try { + app(Modelli3d::class)->carica($record, $file->getRealPath(), $file->getClientOriginalName()); + } catch (Modello3dNonValido $e) { + throw ValidationException::withMessages(['data.modello_3d_upload' => $e->getMessage()]); + } + + $file->delete(); + } +} diff --git a/backoffice/app/Filament/Resources/ModelloResource/Pages/CreateModello.php b/backoffice/app/Filament/Resources/ModelloResource/Pages/CreateModello.php index b55444a..212b29e 100644 --- a/backoffice/app/Filament/Resources/ModelloResource/Pages/CreateModello.php +++ b/backoffice/app/Filament/Resources/ModelloResource/Pages/CreateModello.php @@ -3,10 +3,13 @@ namespace App\Filament\Resources\ModelloResource\Pages; use App\Filament\Resources\ModelloResource; +use App\Filament\Resources\ModelloResource\Pages\Concerns\CaricaModello3d; use Filament\Resources\Pages\CreateRecord; class CreateModello extends CreateRecord { + use CaricaModello3d; + protected static string $resource = ModelloResource::class; protected function getRedirectUrl(): string diff --git a/backoffice/app/Filament/Resources/ModelloResource/Pages/EditModello.php b/backoffice/app/Filament/Resources/ModelloResource/Pages/EditModello.php index 47ebbf2..a6c02a5 100644 --- a/backoffice/app/Filament/Resources/ModelloResource/Pages/EditModello.php +++ b/backoffice/app/Filament/Resources/ModelloResource/Pages/EditModello.php @@ -3,11 +3,14 @@ namespace App\Filament\Resources\ModelloResource\Pages; use App\Filament\Resources\ModelloResource; +use App\Filament\Resources\ModelloResource\Pages\Concerns\CaricaModello3d; use Filament\Actions; use Filament\Resources\Pages\EditRecord; class EditModello extends EditRecord { + use CaricaModello3d; + protected static string $resource = ModelloResource::class; protected function getHeaderActions(): array diff --git a/backoffice/app/Http/Controllers/Api/CatalogoController.php b/backoffice/app/Http/Controllers/Api/CatalogoController.php index 05be291..dd5d19c 100644 --- a/backoffice/app/Http/Controllers/Api/CatalogoController.php +++ b/backoffice/app/Http/Controllers/Api/CatalogoController.php @@ -122,7 +122,8 @@ class CatalogoController extends Controller 'descrizione' => $modello->descrizione, 'icona' => $modello->icona_url, 'cartella' => $modello->cartella, - 'modello' => $modello->file_modello, + // Modello 3D compresso con Draco, caricato dal backoffice. + 'modello_url' => $modello->modello_3d_url, 'categoria' => $modello->categoria, 'cartella_texture' => $modello->cartella_texture, 'moduli' => $modello->moduli ?? [], diff --git a/backoffice/app/Http/Controllers/Api/MediaController.php b/backoffice/app/Http/Controllers/Api/MediaController.php index 620319e..605df6d 100644 --- a/backoffice/app/Http/Controllers/Api/MediaController.php +++ b/backoffice/app/Http/Controllers/Api/MediaController.php @@ -4,6 +4,7 @@ namespace App\Http\Controllers\Api; use App\Http\Controllers\Controller; use App\Support\Configuratore\Media; +use App\Support\Configuratore\Modelli3d; use Illuminate\Support\Facades\Storage; use Illuminate\Support\Str; use Symfony\Component\HttpFoundation\BinaryFileResponse; @@ -12,7 +13,7 @@ class MediaController extends Controller { /** * GET /api/media/{percorso} - * Immagini del configuratore dal disco public, con gli header CORS dell'API (necessari + * Immagini e modelli 3D (compressi) del configuratore dal disco public, con gli header CORS dell'API (necessari * alle texture WebGL caricate da un'altra origine). */ public function __invoke(string $percorso): BinaryFileResponse @@ -26,8 +27,11 @@ class MediaController extends Controller 404 ); + // I modelli 3D hanno l'hash del contenuto nel nome: un nuovo caricamento cambia URL. + $modello3d = Str::startsWith($percorso, Modelli3d::CARTELLA_DRACO.'/'); + return response()->file($disco->path($percorso), [ - 'Cache-Control' => 'public, max-age=86400', - ]); + 'Cache-Control' => $modello3d ? 'public, max-age=31536000, immutable' : 'public, max-age=86400', + ] + ($modello3d ? ['Content-Type' => 'model/gltf-binary'] : [])); } } diff --git a/backoffice/app/Models/Modello.php b/backoffice/app/Models/Modello.php index c42e9cf..34c1aaf 100644 --- a/backoffice/app/Models/Modello.php +++ b/backoffice/app/Models/Modello.php @@ -2,6 +2,8 @@ namespace App\Models; +use App\Support\Configuratore\Media; +use App\Support\Configuratore\Modelli3d; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; @@ -46,6 +48,9 @@ class Modello extends Model 'attivo' => 'boolean', 'moduli' => 'array', 'listino_aggiornato_at' => 'datetime', + 'modello_3d_dimensione' => 'integer', + 'modello_3d_draco_dimensione' => 'integer', + 'modello_3d_aggiornato_at' => 'datetime', ]; public function sistema(): BelongsTo @@ -97,10 +102,19 @@ class Modello extends Model return $query->where('attivo', true)->orderBy('ordine')->orderBy('id'); } - /** Modelli mostrati nel configuratore: attivi e con un listino (senza prezzi non si possono vendere). */ + /** + * Modelli mostrati nel configuratore: attivi, con un listino (senza prezzi non si possono + * vendere) e con il modello 3D caricato. + */ public function scopeNelConfiguratore(Builder $query): Builder { - return $query->attivi()->whereHas('listino'); + return $query->attivi()->whereNotNull('modello_3d_draco')->whereHas('listino'); + } + + /** URL del modello 3D compresso (Draco) letto dal configuratore. */ + public function getModello3dUrlAttribute(): ?string + { + return Media::url($this->modello_3d_draco); } public function getIconaUrlAttribute(): ?string @@ -150,6 +164,7 @@ class Modello extends Model if ($modello->icona) { Storage::disk('public')->delete($modello->icona); } + app(Modelli3d::class)->elimina($modello); }); } } diff --git a/backoffice/app/Support/Configuratore/CompressoreGlb.php b/backoffice/app/Support/Configuratore/CompressoreGlb.php new file mode 100644 index 0000000..ce30df3 --- /dev/null +++ b/backoffice/app/Support/Configuratore/CompressoreGlb.php @@ -0,0 +1,37 @@ +run([$config['node'], $config['script'], $sorgente, $destinazione]); + } catch (Throwable $e) { + // Es. Node non trovato (NODE_BINARY) o timeout. + throw new Modello3dNonValido('Compressione Draco non avviata: '.$e->getMessage(), previous: $e); + } + + if ($risultato->failed() || ! is_file($destinazione) || filesize($destinazione) === 0) { + $errore = trim($risultato->errorOutput()) ?: trim($risultato->output()); + + throw new Modello3dNonValido('Compressione Draco non riuscita: '.(Str::limit($errore, 300) ?: 'errore sconosciuto').'.'); + } + } +} diff --git a/backoffice/app/Support/Configuratore/Media.php b/backoffice/app/Support/Configuratore/Media.php index c4e0fec..39e776f 100644 --- a/backoffice/app/Support/Configuratore/Media.php +++ b/backoffice/app/Support/Configuratore/Media.php @@ -9,7 +9,7 @@ namespace App\Support\Configuratore; class Media { /** Cartelle del disco public servite dalla rotta media. */ - public const CARTELLE = ['configuratore/', 'modelli/']; + public const CARTELLE = ['configuratore/', 'modelli/', Modelli3d::CARTELLA_DRACO.'/']; public static function url(?string $percorso): ?string { diff --git a/backoffice/app/Support/Configuratore/Modelli3d.php b/backoffice/app/Support/Configuratore/Modelli3d.php new file mode 100644 index 0000000..06144f4 --- /dev/null +++ b/backoffice/app/Support/Configuratore/Modelli3d.php @@ -0,0 +1,158 @@ +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, '_'); + } +} diff --git a/backoffice/app/Support/Configuratore/Modello3dNonValido.php b/backoffice/app/Support/Configuratore/Modello3dNonValido.php new file mode 100644 index 0000000..6cfd387 --- /dev/null +++ b/backoffice/app/Support/Configuratore/Modello3dNonValido.php @@ -0,0 +1,8 @@ + [ + // Dimensione massima del .glb non compresso caricabile (MB). + 'max_mb' => (int) env('MODELLI_3D_MAX_MB', 200), + + // La compressione Draco la fa uno script Node (le librerie Draco esistono solo in JS/C++). + // In produzione PHP-FPM spesso non ha Node nel PATH: indicare il percorso completo, + // es. NODE_BINARY=/opt/plesk/node/22/bin/node + 'node' => env('NODE_BINARY', 'node'), + + // Script di compressione; usa le dipendenze installate da build.sh nella root del repository. + 'script' => env('MODELLI_3D_SCRIPT', base_path('../scripts/comprimi-glb.mjs')), + + 'timeout' => (int) env('MODELLI_3D_TIMEOUT', 300), + ], +]; diff --git a/backoffice/config/livewire.php b/backoffice/config/livewire.php new file mode 100644 index 0000000..3152c62 --- /dev/null +++ b/backoffice/config/livewire.php @@ -0,0 +1,21 @@ + [ + 'disk' => null, + // I modelli 3D (.glb) non compressi superano il limite predefinito di 12 MB: il limite + // del singolo campo resta quello impostato in Filament (maxSize). + 'rules' => ['required', 'file', 'max:'.((int) env('MODELLI_3D_MAX_MB', 200) * 1024)], + 'directory' => null, + 'middleware' => null, + 'preview_mimes' => [ + 'png', 'gif', 'bmp', 'svg', 'wav', 'mp4', + 'mov', 'avi', 'wmv', 'mp3', 'm4a', + 'jpg', 'jpeg', 'mpga', 'webp', 'wma', + ], + 'max_upload_time' => 15, + 'cleanup' => true, + ], +]; diff --git a/backoffice/database/migrations/2026_11_01_100000_add_modello_3d_to_modelli_table.php b/backoffice/database/migrations/2026_11_01_100000_add_modello_3d_to_modelli_table.php new file mode 100644 index 0000000..8150dd9 --- /dev/null +++ b/backoffice/database/migrations/2026_11_01_100000_add_modello_3d_to_modelli_table.php @@ -0,0 +1,34 @@ +string('modello_3d')->nullable()->after('file_modello'); + $table->string('modello_3d_draco')->nullable()->after('modello_3d'); + $table->string('modello_3d_nome')->nullable()->after('modello_3d_draco'); + $table->unsignedBigInteger('modello_3d_dimensione')->nullable()->after('modello_3d_nome'); + $table->unsignedBigInteger('modello_3d_draco_dimensione')->nullable()->after('modello_3d_dimensione'); + $table->timestamp('modello_3d_aggiornato_at')->nullable()->after('modello_3d_draco_dimensione'); + }); + } + + public function down(): void + { + Schema::table('modelli', function (Blueprint $table) { + $table->dropColumn([ + 'modello_3d', 'modello_3d_draco', 'modello_3d_nome', + 'modello_3d_dimensione', 'modello_3d_draco_dimensione', 'modello_3d_aggiornato_at', + ]); + }); + } +}; diff --git a/backoffice/tests/Feature/CatalogoAdminTest.php b/backoffice/tests/Feature/CatalogoAdminTest.php index 374953a..152eb2c 100644 --- a/backoffice/tests/Feature/CatalogoAdminTest.php +++ b/backoffice/tests/Feature/CatalogoAdminTest.php @@ -26,7 +26,7 @@ class CatalogoAdminTest extends TestCase Storage::fake('public'); $this->seed(CatalogoSeeder::class); - $this->actingAs(User::factory()->create()); + $this->actingAs(User::factory()->create(['is_superadmin' => true])); } public function test_le_pagine_admin_del_catalogo_si_aprono(): void @@ -94,7 +94,7 @@ class CatalogoAdminTest extends TestCase Livewire::test(CreateModello::class) ->fillForm(['sistema_id' => $sistema, 'codice' => 'nuovo', 'nome' => 'Nuovo', 'attivo' => true]) ->call('create') - ->assertHasFormErrors(['cartella' => 'required', 'file_modello' => 'required', 'categoria' => 'required']); + ->assertHasFormErrors(['cartella' => 'required', 'modello_3d_upload' => 'required', 'categoria' => 'required']); Livewire::test(CreateModello::class) ->fillForm(['sistema_id' => $sistema, 'codice' => 'finestra_1_anta', 'nome' => 'Doppione', 'attivo' => false]) diff --git a/backoffice/tests/Feature/CatalogoApiTest.php b/backoffice/tests/Feature/CatalogoApiTest.php index 0e58d36..063edb7 100644 --- a/backoffice/tests/Feature/CatalogoApiTest.php +++ b/backoffice/tests/Feature/CatalogoApiTest.php @@ -53,6 +53,9 @@ class CatalogoApiTest extends TestCase // Senza listino il bilico non compare nel configuratore. Modello::where('codice', '!=', 'bilico')->each(fn (Modello $m) => ListinoPrezzo::create(['modello_id' => $m->id, 'larghezza' => 1000, 'altezza' => 1000, 'prezzo' => 100])); Sistema::create(['codice' => 'legno', 'nome' => 'Legno', 'attivo' => false]); + $this->conModelli3d(); + // Senza modello 3D la finestra a 2 ante non compare nel configuratore. + Modello::where('codice', 'finestra_2_ante')->update(['modello_3d_draco' => null]); $response = $this->getJson('/api/sistemi')->assertOk(); @@ -60,12 +63,13 @@ class CatalogoApiTest extends TestCase $response->assertJsonPath('0.codice', 'pvc'); $modelli = collect($response->json('0.modelli')); - $this->assertSame(25, $modelli->count()); + $this->assertSame(24, $modelli->count()); $this->assertNotContains('bilico', $modelli->pluck('id')); + $this->assertNotContains('finestra_2_ante', $modelli->pluck('id')); $this->assertSame('finestra_1_anta', $modelli->first()['id']); $this->assertNotContains('scorrevole_hst_4_ante', $modelli->pluck('id')); $this->assertStringEndsWith('/storage/modelli/icone/pvc/finestra_1_anta.jpg', $modelli->first()['icona']); - $this->assertSame('finestra_1_anta.glb', $modelli->first()['modello']); + $this->assertStringEndsWith('/api/media/modelli-3d/draco/test.glb', $modelli->first()['modello_url']); } public function test_configurazione_di_un_modello_senza_listino_restituisce_404(): void diff --git a/backoffice/tests/Feature/ConfiguratoreAdminTest.php b/backoffice/tests/Feature/ConfiguratoreAdminTest.php index 5eab279..519f716 100644 --- a/backoffice/tests/Feature/ConfiguratoreAdminTest.php +++ b/backoffice/tests/Feature/ConfiguratoreAdminTest.php @@ -37,7 +37,7 @@ class ConfiguratoreAdminTest extends TestCase Storage::fake('public'); $this->seed([CatalogoSeeder::class, ConfiguratoreSeeder::class, ProfiliExtraSeeder::class, NuoveOpzioniSeeder::class]); - $this->actingAs(User::factory()->create()); + $this->actingAs(User::factory()->create(['is_superadmin' => true])); } public function test_le_pagine_del_configuratore_si_aprono(): void diff --git a/backoffice/tests/Feature/ConfiguratorePrezzoTest.php b/backoffice/tests/Feature/ConfiguratorePrezzoTest.php index 93f0665..29582d5 100644 --- a/backoffice/tests/Feature/ConfiguratorePrezzoTest.php +++ b/backoffice/tests/Feature/ConfiguratorePrezzoTest.php @@ -30,6 +30,7 @@ class ConfiguratorePrezzoTest extends TestCase Storage::fake('public'); $this->seed([CatalogoSeeder::class, ConfiguratoreSeeder::class, ProfiliExtraSeeder::class, NuoveOpzioniSeeder::class]); + $this->conModelli3d(); } /** Listino 1000-1400 x 1200-1400 mm: la cella 1200 x 1400 vale BASE. */ diff --git a/backoffice/tests/Feature/ImportazioneListiniTest.php b/backoffice/tests/Feature/ImportazioneListiniTest.php index 4213e4d..a51a3d3 100644 --- a/backoffice/tests/Feature/ImportazioneListiniTest.php +++ b/backoffice/tests/Feature/ImportazioneListiniTest.php @@ -33,7 +33,7 @@ class ImportazioneListiniTest extends TestCase Storage::fake('public'); $this->seed(CatalogoSeeder::class); $this->pvc = Sistema::where('codice', 'pvc')->firstOrFail(); - $this->actingAs(User::factory()->create()); + $this->actingAs(User::factory()->create(['is_superadmin' => true])); } /** Due tabelle nel formato del listino, separate da una riga vuota. */ @@ -220,6 +220,7 @@ class ImportazioneListiniTest extends TestCase { $finestra = Modello::where('codice', 'finestra_1_anta')->firstOrFail(); ListinoImporter::importa($this->pvc, ListinoExcelParser::parseRighe($this->righe()), ['finestra_1_anta' => $finestra->id]); + $this->conModelli3d(); $this->getJson('/api/sistemi/pvc/modelli/finestra_1_anta/listino') ->assertOk() diff --git a/backoffice/tests/Feature/Modelli3dTest.php b/backoffice/tests/Feature/Modelli3dTest.php new file mode 100644 index 0000000..e6c6bb6 --- /dev/null +++ b/backoffice/tests/Feature/Modelli3dTest.php @@ -0,0 +1,186 @@ +seed(CatalogoSeeder::class); + $this->actingAs(User::factory()->create(['is_superadmin' => true])); + + $this->finestra = Modello::where('codice', 'finestra_1_anta')->firstOrFail(); + ListinoPrezzo::create(['modello_id' => $this->finestra->id, 'larghezza' => 1000, 'altezza' => 1000, 'prezzo' => 100]); + + // Niente Node nei test: la "compressione" tiene i primi 16 byte del file. + $this->app->instance(CompressoreGlb::class, new class extends CompressoreGlb + { + public function comprimi(string $sorgente, string $destinazione): void + { + file_put_contents($destinazione, substr(file_get_contents($sorgente), 0, 16)); + } + }); + } + + /** Intestazione GLB valida (magic, versione 2, lunghezza) seguita da $corpo. */ + private function glb(string $corpo = 'geometria di prova'): string + { + return 'glTF'.pack('V', 2).pack('V', 12 + strlen($corpo)).$corpo; + } + + private function carica(UploadedFile $file) + { + return Livewire::test(EditModello::class, ['record' => $this->finestra->getRouteKey()]) + ->fillForm(['modello_3d_upload' => $file]) + ->call('save'); + } + + public function test_il_caricamento_salva_originale_e_copia_draco(): void + { + $this->carica(UploadedFile::fake()->createWithContent('finestra.glb', $this->glb())) + ->assertHasNoFormErrors(); + + $this->finestra->refresh(); + $this->assertSame('finestra.glb', $this->finestra->modello_3d_nome); + $this->assertStringStartsWith('modelli-3d/originali/pvc/finestra_1_anta-', $this->finestra->modello_3d); + $this->assertStringStartsWith('modelli-3d/draco/pvc/finestra_1_anta-', $this->finestra->modello_3d_draco); + $this->assertSame($this->glb(), Storage::disk('local')->get($this->finestra->modello_3d)); + $this->assertSame(substr($this->glb(), 0, 16), Storage::disk('public')->get($this->finestra->modello_3d_draco)); + $this->assertSame(strlen($this->glb()), $this->finestra->modello_3d_dimensione); + $this->assertSame(16, $this->finestra->modello_3d_draco_dimensione); + + // La scheda mostra il modello attuale con le dimensioni prima/dopo. + Livewire::test(EditModello::class, ['record' => $this->finestra->getRouteKey()]) + ->assertOk() + ->assertSee('finestra.glb') + ->assertSee('con Draco') + ->assertSee('Sostituisci modello 3D') + // callFormComponentAction() cerca la chiave sotto "data.", che un Placeholder non ha. + // Senza modale, il mount esegue subito l'azione. + ->call('mountFormComponentAction', 'modello_3d_attuale', 'scarica_originale') + ->assertFileDownloaded('finestra.glb'); + + // L'originale non è sul disco pubblico. + Storage::disk('public')->assertMissing($this->finestra->modello_3d); + + // Il configuratore riceve l'URL della copia Draco, servita con CORS e cache lunga. + $modello = collect($this->getJson('/api/sistemi')->json('0.modelli'))->firstWhere('id', 'finestra_1_anta'); + $this->assertSame($this->finestra->modello_3d_url, $modello['modello_url']); + + $this->get($modello['modello_url']) + ->assertOk() + ->assertHeader('Content-Type', 'model/gltf-binary') + ->assertHeader('Cache-Control', 'immutable, max-age=31536000, public'); + } + + public function test_un_file_non_glb_e_rifiutato_senza_toccare_il_modello(): void + { + $this->carica(UploadedFile::fake()->createWithContent('finestra.glb', 'non sono un glb')) + ->assertHasFormErrors(['modello_3d_upload']); + + $this->assertNull($this->finestra->refresh()->modello_3d); + $this->assertEmpty(Storage::disk('local')->allFiles('modelli-3d')); + } + + public function test_una_compressione_fallita_annulla_il_salvataggio(): void + { + $this->app->instance(CompressoreGlb::class, new class extends CompressoreGlb + { + public function comprimi(string $sorgente, string $destinazione): void + { + throw new \App\Support\Configuratore\Modello3dNonValido('Compressione Draco non riuscita: test.'); + } + }); + + Livewire::test(EditModello::class, ['record' => $this->finestra->getRouteKey()]) + ->fillForm(['nome' => 'Nome cambiato', 'modello_3d_upload' => UploadedFile::fake()->createWithContent('f.glb', $this->glb())]) + ->call('save') + ->assertHasFormErrors(['modello_3d_upload']); + + $this->finestra->refresh(); + $this->assertNull($this->finestra->modello_3d); + $this->assertNotSame('Nome cambiato', $this->finestra->nome); + } + + public function test_sostituzione_ed_eliminazione_rimuovono_i_file_precedenti(): void + { + $servizio = app(Modelli3d::class); + $sorgente = tempnam(sys_get_temp_dir(), 'glb'); + + file_put_contents($sorgente, $this->glb('primo')); + $servizio->carica($this->finestra, $sorgente); + [$originale, $draco] = [$this->finestra->modello_3d, $this->finestra->modello_3d_draco]; + + file_put_contents($sorgente, $this->glb('secondo, diverso nei primi byte')); + $servizio->carica($this->finestra, $sorgente); + unlink($sorgente); + + Storage::disk('local')->assertMissing($originale); + Storage::disk('public')->assertMissing($draco); + Storage::disk('local')->assertExists($this->finestra->modello_3d); + + $this->finestra->delete(); + Storage::disk('local')->assertMissing($this->finestra->modello_3d); + Storage::disk('public')->assertMissing($this->finestra->modello_3d_draco); + } + + public function test_senza_modello_3d_il_modello_non_e_nel_configuratore(): void + { + $this->getJson('/api/sistemi/pvc/modelli/finestra_1_anta/configurazione')->assertNotFound(); + } + + public function test_il_comando_importa_i_glb_del_configuratore(): void + { + $cartella = sys_get_temp_dir().'/modelli-'.uniqid(); + File::ensureDirectoryExists("{$cartella}/pvc"); + file_put_contents("{$cartella}/pvc/finestra_1_anta.glb", $this->glb()); + + $this->artisan('modelli3d:importa', ['cartella' => $cartella])->assertSuccessful(); + File::deleteDirectory($cartella); + + $this->finestra->refresh(); + $this->assertSame('finestra_1_anta.glb', $this->finestra->modello_3d_nome); + Storage::disk('public')->assertExists($this->finestra->modello_3d_draco); + } + + public function test_compressione_draco_reale_con_node(): void + { + $script = config('configuratore.modelli_3d.script'); + $celino = base_path('../src/assets/models/pvc/celino.glb'); + if (! is_dir(dirname($script, 2).'/node_modules/@gltf-transform/core') || ! is_file($celino)) { + $this->markTestSkipped('Dipendenze Node o modello di prova non installati.'); + } + + $this->app->forgetInstance(CompressoreGlb::class); + $this->app->offsetUnset(CompressoreGlb::class); + + app(Modelli3d::class)->carica($this->finestra, $celino); + + $draco = Storage::disk('public')->get($this->finestra->modello_3d_draco); + $this->assertStringStartsWith('glTF', $draco); + $this->assertStringContainsString('KHR_draco_mesh_compression', $draco); + $this->assertLessThan(filesize($celino), strlen($draco)); + } +} diff --git a/backoffice/tests/TestCase.php b/backoffice/tests/TestCase.php index fe1ffc2..ad7b6d5 100644 --- a/backoffice/tests/TestCase.php +++ b/backoffice/tests/TestCase.php @@ -2,9 +2,14 @@ namespace Tests; +use App\Models\Modello; use Illuminate\Foundation\Testing\TestCase as BaseTestCase; abstract class TestCase extends BaseTestCase { - // + /** Il configuratore mostra solo i modelli con il 3D caricato: ne assegna uno finto a tutti. */ + protected function conModelli3d(): void + { + Modello::query()->whereNull('modello_3d_draco')->update(['modello_3d_draco' => 'modelli-3d/draco/test.glb']); + } } diff --git a/docs/PREVENTIVI_BACKOFFICE.md b/docs/PREVENTIVI_BACKOFFICE.md index 1dd5b1d..2d8a52b 100644 --- a/docs/PREVENTIVI_BACKOFFICE.md +++ b/docs/PREVENTIVI_BACKOFFICE.md @@ -70,6 +70,8 @@ Dettagli in [AVVIO_LOCALE.md](AVVIO_LOCALE.md). | `APP_CONFIGURATORE_URL` | Link "Configuratore" nel menu admin, solo se servito altrove | `APP_URL/configuratore/` | | `ADMIN_NOTIFY_EMAIL` | Email che riceve la notifica di nuovo preventivo | — | | `CORS_ALLOWED_ORIGINS` | Origini autorizzate all'API da domini **esterni** (i frontend serviti dal backend non ne hanno bisogno) | `*` | +| `NODE_BINARY` | Node usato per comprimere con Draco i modelli 3D caricati (es. `/opt/plesk/node/22/bin/node`): PHP-FPM di solito non ha Node nel PATH | `node` | +| `MODELLI_3D_MAX_MB` | Dimensione massima del `.glb` caricabile dal backoffice (anche il limite di PHP `upload_max_filesize`/`post_max_size` deve permetterla) | `200` | | `DB_*`, `MAIL_*` | Database e posta | — | ### Frontend @@ -113,6 +115,22 @@ committato anche il `pnpm-lock.yaml` aggiornato (generato con pnpm 10), altrimen `public/configuratore` e `public/preventivo` vengono svuotate e riscritte: per qualche secondo le due pagine non sono disponibili. +### Modelli 3D + +I `.glb` si caricano dal backoffice (Modelli → scheda del modello → Configurazione 3D). Al salvataggio il +file viene compresso con Draco: l'originale resta archiviato in `storage/app/private/modelli-3d/originali` +(non pubblico), il configuratore scarica solo la copia compressa. Un modello senza 3D non è attivabile e non +compare nel configuratore. + +Una tantum, dopo il deploy che introduce questa gestione, importare i modelli che prima stavano nel frontend: + +```bash +(cd backoffice && $PHP artisan modelli3d:importa) +``` + +Il comando salta i modelli che hanno già un 3D (si può rilanciare). `modelli3d:ricomprimi` rigenera le copie +compresse dagli originali (es. dopo una modifica di `scripts/comprimi-glb.mjs`). + ### Dopo ogni deploy - Nel sito `kreiosinfissi.it` l'iframe punta a `https:///configuratore/?sistema=…&type=…`. diff --git a/package.json b/package.json index 0c9666a..8ec01cb 100644 --- a/package.json +++ b/package.json @@ -14,18 +14,18 @@ "description": "", "dependencies": { "@fortawesome/fontawesome-free": "^6.7.2", + "@gltf-transform/core": "^4.5.1", + "@gltf-transform/extensions": "^4.5.1", + "@gltf-transform/functions": "^4.5.1", "bootstrap": "^5.3.5", "bs5-lightbox": "^1.8.5", + "draco3dgltf": "^1.5.9", "gsap": "^3.13.0", "jquery": "^3.7.1", "three": "^0.185.1", "vite-plugin-static-copy": "^2.3.0" }, "devDependencies": { - "@gltf-transform/core": "^4.5.1", - "@gltf-transform/extensions": "^4.5.1", - "@gltf-transform/functions": "^4.5.1", - "draco3dgltf": "^1.5.9", "vite": "^6.2.4", "vite-plugin-minify": "^2.1.0" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2891c7b..e1c13cf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,12 +11,24 @@ importers: '@fortawesome/fontawesome-free': specifier: ^6.7.2 version: 6.7.2 + '@gltf-transform/core': + specifier: ^4.5.1 + version: 4.5.1 + '@gltf-transform/extensions': + specifier: ^4.5.1 + version: 4.5.1 + '@gltf-transform/functions': + specifier: ^4.5.1 + version: 4.5.1 bootstrap: specifier: ^5.3.5 version: 5.3.8(@popperjs/core@2.11.8) bs5-lightbox: specifier: ^1.8.5 version: 1.8.5(bootstrap@5.3.8(@popperjs/core@2.11.8)) + draco3dgltf: + specifier: ^1.5.9 + version: 1.5.9 gsap: specifier: ^3.13.0 version: 3.15.0 @@ -30,18 +42,6 @@ importers: specifier: ^2.3.0 version: 2.3.2(vite@6.4.5(terser@5.51.2)) devDependencies: - '@gltf-transform/core': - specifier: ^4.5.1 - version: 4.5.1 - '@gltf-transform/extensions': - specifier: ^4.5.1 - version: 4.5.1 - '@gltf-transform/functions': - specifier: ^4.5.1 - version: 4.5.1 - draco3dgltf: - specifier: ^1.5.9 - version: 1.5.9 vite: specifier: ^6.2.4 version: 6.4.5(terser@5.51.2) diff --git a/scripts/comprimi-glb.mjs b/scripts/comprimi-glb.mjs new file mode 100644 index 0000000..fc3dac1 --- /dev/null +++ b/scripts/comprimi-glb.mjs @@ -0,0 +1,54 @@ +#!/usr/bin/env node +// Comprime con Draco un modello GLB: node scripts/comprimi-glb.mjs +// +// Usato dal backoffice al caricamento di un modello 3D (App\Support\Configuratore\CompressoreGlb): +// l'originale resta salvato non compresso, nel configuratore va solo questa copia. Nel browser la +// decomprime il DRACOLoader di three.js (vedi src/js/starter.js). +// +// In caso di successo stampa su stdout una riga JSON con le dimensioni prima/dopo. +import fs from 'node:fs/promises'; +import { NodeIO } from '@gltf-transform/core'; +import { ALL_EXTENSIONS } from '@gltf-transform/extensions'; +import { draco, weld } from '@gltf-transform/functions'; +import draco3d from 'draco3dgltf'; + +// Bit di quantizzazione: valori più alti = più fedeltà e file più grandi. +// Le posizioni a 14 bit sono sotto il decimo di millimetro sulle dimensioni di un infisso. +export const DRACO_OPTIONS = { + method: 'edgebreaker', + encodeSpeed: 5, + decodeSpeed: 5, + quantizePosition: 14, + quantizeNormal: 10, + quantizeTexcoord: 12, + quantizeColor: 8, + quantizeGeneric: 12, +}; + +const [src, dest] = process.argv.slice(2); +if (!src || !dest) { + console.error('Uso: comprimi-glb.mjs '); + process.exit(2); +} + +try { + const io = new NodeIO().registerExtensions(ALL_EXTENSIONS).registerDependencies({ + 'draco3d.encoder': await draco3d.createEncoderModule(), + 'draco3d.decoder': await draco3d.createDecoderModule(), + }); + + const input = await fs.readFile(src); + const doc = await io.readBinary(new Uint8Array(input)); + if (!doc.getRoot().listMeshes().length) throw new Error('Il file non contiene geometrie.'); + + // Draco lavora su primitive indicizzate: weld unisce solo i vertici identici in tutti gli + // attributi (posizione, normale, uv...), quindi non cambia l'aspetto del modello. + await doc.transform(weld(), draco(DRACO_OPTIONS)); + const output = await io.writeBinary(doc); + await fs.writeFile(dest, output); + + console.log(JSON.stringify({ originale: input.length, compresso: output.length })); +} catch (err) { + console.error(err?.message ?? String(err)); + process.exit(1); +} diff --git a/src/js/starter.js b/src/js/starter.js index 9da232d..10740c2 100644 --- a/src/js/starter.js +++ b/src/js/starter.js @@ -22,8 +22,8 @@ window.$ = $; window.THREE = THREE; // Used by APP Scripts. window.OrbitControls = OrbitControls; // Used by APP Scripts. -// I modelli sono compressi con Draco in build (vite-plugins/draco-models.js). I decoder (versione -// ridotta per glTF) li risolve three via import.meta.url e Vite li include nella build. +// I modelli 3D arrivano dal backoffice già compressi con Draco al caricamento (scripts/comprimi-glb.mjs). +// I decoder (versione ridotta per glTF) li risolve three via import.meta.url e Vite li include nella build. const dracoLoader = new DRACOLoader().setDecoderPath(DRACO_GLTF_CONFIG); function createGltfLoader() { return new GLTFLoader().setDRACOLoader(dracoLoader); @@ -248,12 +248,12 @@ const start = async () => { var gltfLoader = createGltfLoader(); - gltfLoader.load(`./assets/models/${params.sistema}/${modello.modello}`, function (gltf) { + gltfLoader.load(modello.modello_url, function (gltf) { document.getElementById('loadingBarContainer').style.display = 'none'; //gltf.scene.rotation.y = THREE.MathUtils.degToRad(80); gltf.scene.name = 'infisso'; - window.current_model = modello.modello; - gltf.scene.userData.name = modello.modello; + window.current_model = modello.id; + gltf.scene.userData.name = modello.id; //$("input[type=radio]").first().trigger("click"); @@ -418,8 +418,15 @@ const start = async () => { window.rotate = true; + // modello: codice di un altro modello dello stesso sistema (data-model delle opzioni), di cui si + // carica il .glb gestito dal backoffice. Le varianti già caricate restano in scena (swapModel). window.caricaModello = (modello) => { - + const variante = modelli.find(m => m.id === modello); + if (!variante?.modello_url) { + console.warn(`Modello 3D non disponibile per ${modello}`); + return; + } + let textureButtons = $(".texture-button:checked"); window.rotate = false; @@ -455,7 +462,7 @@ const start = async () => { var gltfLoader = createGltfLoader(); - gltfLoader.load(`./assets/models/${modello}`, function (gltf) { + gltfLoader.load(variante.modello_url, function (gltf) { document.getElementById('loadingBarContainerAlt').style.display = 'none'; $('#canvas-offscreen').removeClass('d-none'); //gltf.scene.rotation.y = THREE.MathUtils.degToRad(80); diff --git a/vite-plugins/encrypt-models.js b/vite-plugins/encrypt-models.js index 2ca7538..63563fe 100644 --- a/vite-plugins/encrypt-models.js +++ b/vite-plugins/encrypt-models.js @@ -1,4 +1,6 @@ -// NON ATTIVO: non è registrato in vite.config.js. Per usarlo va aggiunto ai plugin dopo dracoModels +// NON ATTIVO e da rivedere: i modelli 3D ora sono caricati dal backoffice (App\Support\Configuratore\Modelli3d) +// e non passano più dalla build; per attivare la cifratura va portata lì, sulle copie Draco. +// Nota storica: non è registrato in vite.config.js. Per usarlo va aggiunto ai plugin dopo dracoModels // (facendogli cifrare i GLB già compressi in /assets/models) e i caricamenti in starter.js vanno // fatti con loadModello() di src/js/model_loader.js, impostando sul GLTFLoader anche il DRACOLoader. // diff --git a/vite.config.js b/vite.config.js index a590886..5ef9879 100644 --- a/vite.config.js +++ b/vite.config.js @@ -2,10 +2,33 @@ import { defineConfig } from 'vite'; import path from 'path'; import { viteStaticCopy } from 'vite-plugin-static-copy'; import { ViteMinifyPlugin } from 'vite-plugin-minify' -import dracoModels from './vite-plugins/draco-models.js'; +import fs from 'node:fs/promises'; // Cifratura dei modelli, pronta ma non attiva: vedi vite-plugins/encrypt-models.js. // import encryptModels from './vite-plugins/encrypt-models.js'; +// Nessun .glb va online dalla build (es. i file archiviati in assets/data): i modelli 3D li serve il +// backoffice, già compressi con Draco. Parte dopo il writeBundle di vite-plugin-static-copy. +function rimuoviGlb() { + let outDir; + return { + name: 'kreios-rimuovi-glb', + apply: 'build', + configResolved(config) { + outDir = config.build.outDir; + }, + writeBundle: { + sequential: true, + order: 'post', + async handler() { + const files = await fs.readdir(path.join(outDir, 'assets'), { recursive: true }).catch(() => []); + await Promise.all(files + .filter((file) => file.toLowerCase().endsWith('.glb')) + .map((file) => fs.rm(path.join(outDir, 'assets', file)))); + }, + }, + }; +} + export default defineConfig({ // Il configuratore è servito da Laravel sotto /configuratore/ (stessa origine dell'API): // la build finisce direttamente in backoffice/public/configuratore. @@ -29,16 +52,14 @@ export default defineConfig({ viteStaticCopy({ targets: [ { - // I modelli 3D non vengono copiati così come sono: li comprime dracoModels. + // I modelli 3D sono gestiti dal backoffice (caricati e compressi con Draco): + // quelli in src/assets/models servono solo a `php artisan modelli3d:importa`. src: ['assets/*', '!assets/models'], dest: 'assets' } ] }), - dracoModels({ - modelsDir: path.resolve(__dirname, 'src/assets/models'), - cacheDir: path.resolve(__dirname, 'node_modules/.cache/draco-models'), - }), + rimuoviGlb(), ViteMinifyPlugin({ minify: { removeComments: true,