132 lines
6.1 KiB
PHP
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);
|
|
}
|
|
}
|