Add project listing selector, confirmed revision and private 3MF renders
This commit is contained in:
@@ -0,0 +1,131 @@
|
||||
<?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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user