Files
HickLab3D_Dashboard/app/Http/Controllers/ProjectFileRenderController.php
T

132 lines
6.1 KiB
PHP

<?php
namespace App\Http\Controllers;
use App\Models\Project;
use App\Models\ProjectEvent;
use App\Models\ProjectFile;
use App\Models\ProjectFileRender;
use App\Services\ProductImageRenderer;
use App\Services\ThreeMfGeometryExtractor;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\StreamedResponse;
use Throwable;
/**
* Generates a real, numeric product preview image from an uploaded 3MF
* mesh's actual geometry — never a placeholder thumbnail or a fabricated
* photo. Generation is explicit (an operator-triggered POST) and never
* happens as a side effect of viewing a project page. The result is cached
* per immutable source file: once a render exists for a ProjectFile, calling
* store() again is a no-op (idempotent) rather than re-rendering, and
* concurrent requests for the same file are serialised by locking the
* project row so only one render row is ever created.
*/
class ProjectFileRenderController extends Controller
{
public function store(Request $request, Project $project, ProjectFile $file): RedirectResponse
{
abort_unless($file->revision->project_id === $project->id, 404);
abort_unless($request->user()->can('render', [$file, $project]), 403);
if ($file->extension !== '3mf') {
return back()->withErrors(['file' => 'Product image generation is only supported for 3MF files.'])->setStatusCode(422);
}
if ($file->render !== null) {
return redirect()->route('projects.show', $project)->with('status', 'Product image already generated for this file.');
}
$sourceDisk = Storage::disk('project_files');
if (! $sourceDisk->exists($file->storage_key)) {
return back()->withErrors(['file' => 'The original file could not be found.'])->setStatusCode(404);
}
$renderDisk = Storage::disk('project_renders');
$storedKey = null;
try {
return DB::transaction(function () use ($project, $file, $request, $sourceDisk, $renderDisk, &$storedKey): RedirectResponse {
Project::query()->whereKey($project->id)->lockForUpdate()->firstOrFail();
if (ProjectFileRender::where('project_file_id', $file->id)->exists()) {
return redirect()->route('projects.show', $project)->with('status', 'Product image already generated for this file.');
}
$scene = app(ThreeMfGeometryExtractor::class)->extract($sourceDisk->path($file->storage_key));
$rendered = app(ProductImageRenderer::class)->render($scene['triangles']);
$extension = $rendered['renderer'] === ProductImageRenderer::RENDERER_GD ? 'png' : 'svg';
$key = bin2hex(random_bytes(20)).'.'.$extension;
$storedKey = $key;
if (! $renderDisk->put($key, $rendered['bytes'])) {
throw new \RuntimeException('Unable to store the generated product image.');
}
$altText = sprintf(
'Numeric isometric render of %s: %d triangle%s across %d instance%s, rendered as %s.',
$file->original_filename,
$scene['triangle_count'], $scene['triangle_count'] === 1 ? '' : 's',
$scene['instance_count'], $scene['instance_count'] === 1 ? '' : 's',
strtoupper($extension),
);
$render = ProjectFileRender::create([
'project_file_id' => $file->id,
'renderer' => $rendered['renderer'],
'mime_type' => $rendered['mime'],
'storage_key' => $key,
'width' => $rendered['width'],
'height' => $rendered['height'],
'triangle_count' => $scene['triangle_count'],
'instance_count' => $scene['instance_count'],
'alt_text' => $altText,
'byte_size' => strlen($rendered['bytes']),
'sha256' => hash('sha256', $rendered['bytes']),
'generated_by' => $request->user()->id,
'generated_at' => now(),
]);
ProjectEvent::record($project->id, 'product_image_generated', $request->user()->id, ProjectFile::class, $file->id, [
'renderer' => $render->renderer,
'triangle_count' => $render->triangle_count,
'instance_count' => $render->instance_count,
]);
return redirect()->route('projects.show', $project)->with('status', 'Product image generated.');
});
} catch (Throwable $exception) {
if ($storedKey !== null) {
$renderDisk->delete($storedKey);
}
report($exception);
return back()->withErrors(['file' => 'Unable to generate a product image: '.$exception->getMessage()])->setStatusCode(422);
}
}
public function preview(Request $request, Project $project, ProjectFile $file): Response
{
abort_unless($file->revision->project_id === $project->id, 404);
abort_unless($request->user()->can('render', [$file, $project]), 403);
$render = $file->render;
abort_if($render === null, 404);
return response(Storage::disk('project_renders')->get($render->storage_key))
->header('Content-Type', $render->mime_type)
->header('Content-Disposition', 'inline');
}
public function download(Request $request, Project $project, ProjectFile $file): StreamedResponse
{
abort_unless($file->revision->project_id === $project->id, 404);
abort_unless($request->user()->can('render', [$file, $project]), 403);
$render = $file->render;
abort_if($render === null, 404);
$extension = $render->mime_type === 'image/png' ? 'png' : 'svg';
return Storage::disk('project_renders')->download($render->storage_key, $file->original_filename.'.'.$extension);
}
}