Add project listing selector, confirmed revision and private 3MF renders
This commit is contained in:
@@ -2,17 +2,30 @@
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\EtsyConnection;
|
||||
use App\Models\Project;
|
||||
use App\Models\ProjectEvent;
|
||||
use App\Services\Etsy;
|
||||
use App\Services\ProjectCosting;
|
||||
use App\Support\ProjectLifecycle;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\View\View;
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
|
||||
class ProjectController extends Controller
|
||||
{
|
||||
/** @var list<string> */
|
||||
private const LISTING_OPTION_STATES = ['active', 'draft'];
|
||||
|
||||
/** Etsy's own maximum page size for the listings endpoint. */
|
||||
private const LISTING_OPTIONS_PAGE_SIZE = 100;
|
||||
|
||||
/** Bounds each state to at most 2000 listings (20 pages * 100); exceeding this fails explicitly rather than silently truncating. */
|
||||
private const LISTING_OPTIONS_MAX_PAGES_PER_STATE = 20;
|
||||
|
||||
public function index(Request $request): View
|
||||
{
|
||||
abort_unless($request->user()->can('viewAny', Project::class), 403);
|
||||
@@ -55,12 +68,13 @@ class ProjectController extends Controller
|
||||
return redirect()->route('projects.show', $project)->with('status', 'Project created.');
|
||||
}
|
||||
|
||||
public function show(Request $request, Project $project): View
|
||||
public function show(Request $request, Project $project, Etsy $etsy): View
|
||||
{
|
||||
abort_unless($request->user()->can('view', $project), 403);
|
||||
$project->load([
|
||||
'revisions' => fn ($q) => $q->orderByDesc('revision_number'),
|
||||
'revisions.files', 'revisions.creator', 'revisions.approver',
|
||||
'revisions.files', 'revisions.files.render', 'revisions.creator', 'revisions.approver',
|
||||
'currentRevision',
|
||||
'listingLinks' => fn ($q) => $q->orderByDesc('created_at'),
|
||||
'listingLinks.liveRelease.revision', 'listingLinks.liveRelease.costSnapshot',
|
||||
'costSnapshots' => fn ($q) => $q->orderByDesc('recorded_at'),
|
||||
@@ -68,7 +82,89 @@ class ProjectController extends Controller
|
||||
'events.actor',
|
||||
]);
|
||||
|
||||
return view('projects.show', ['project' => $project]);
|
||||
return view('projects.show', ['project' => $project] + $this->listingOptions($etsy));
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for the "link an Etsy listing" dropdown: the complete set of
|
||||
* this shop's Live AND Draft listings together (not one state, not one
|
||||
* page), read-only via the existing Etsy service — which already caches
|
||||
* each page per shop/state/generation, so a warm cache typically serves
|
||||
* this without any new network call. Each state is bounded to at most
|
||||
* LISTING_OPTIONS_MAX_PAGES_PER_STATE pages; if the shop has more
|
||||
* listings than that bound, or any page fetch fails, the whole dropdown
|
||||
* fails explicitly (listingOptionsError) rather than silently showing a
|
||||
* truncated or partial list.
|
||||
*
|
||||
* @return array{
|
||||
* listingOptionsByState: array<string, list<array<string, mixed>>>,
|
||||
* listingOptionsError: ?string,
|
||||
* listingOptionsConnected: bool,
|
||||
* }
|
||||
*/
|
||||
private function listingOptions(Etsy $etsy): array
|
||||
{
|
||||
$connection = EtsyConnection::first();
|
||||
$connected = $connection !== null && $etsy->configured();
|
||||
|
||||
$optionsByState = [];
|
||||
$error = null;
|
||||
|
||||
if ($connected) {
|
||||
try {
|
||||
foreach (self::LISTING_OPTION_STATES as $state) {
|
||||
$optionsByState[$state] = $this->fetchAllListingsForState($etsy, $connection, $state);
|
||||
}
|
||||
} catch (Throwable $exception) {
|
||||
report($exception);
|
||||
$error = 'Listing data is temporarily unavailable or incomplete. Reconnect the shop if this continues.';
|
||||
$optionsByState = [];
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'listingOptionsByState' => $optionsByState,
|
||||
'listingOptionsError' => $error,
|
||||
'listingOptionsConnected' => $connected,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Pages through every listing in one state up to the bound, stopping
|
||||
* early once the API's own reported count is reached (the common case
|
||||
* for a shop with under a page's worth of listings). Throws rather than
|
||||
* returning a partial list if the bound is exceeded or a page comes
|
||||
* back short of the reported total.
|
||||
*
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
private function fetchAllListingsForState(Etsy $etsy, EtsyConnection $connection, string $state): array
|
||||
{
|
||||
$results = [];
|
||||
$page = 0;
|
||||
$reportedCount = null;
|
||||
|
||||
do {
|
||||
if ($page >= self::LISTING_OPTIONS_MAX_PAGES_PER_STATE) {
|
||||
$max = self::LISTING_OPTIONS_MAX_PAGES_PER_STATE * self::LISTING_OPTIONS_PAGE_SIZE;
|
||||
throw new RuntimeException("This shop has more than {$max} {$state} listings, which exceeds what can be safely listed here.");
|
||||
}
|
||||
|
||||
$listingPage = $etsy->listListings($connection, $state, self::LISTING_OPTIONS_PAGE_SIZE, $page * self::LISTING_OPTIONS_PAGE_SIZE);
|
||||
$reportedCount = $listingPage['count'];
|
||||
$results = array_merge($results, $listingPage['results']);
|
||||
$page++;
|
||||
} while (count($results) < $reportedCount && $listingPage['results'] !== []);
|
||||
|
||||
if (count($results) < $reportedCount) {
|
||||
throw new RuntimeException("Could not load the complete list of {$state} listings (received ".count($results)." of {$reportedCount}).");
|
||||
}
|
||||
|
||||
if (count($results) !== $reportedCount || count(array_unique(array_column($results, 'listing_id'))) !== count($results)) {
|
||||
throw new RuntimeException('Listing pages changed or overlapped while loading; retry to load a complete selector.');
|
||||
}
|
||||
|
||||
return $results;
|
||||
}
|
||||
|
||||
public function edit(Request $request, Project $project): View
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Exceptions\OptimisticLockConflictException;
|
||||
use App\Models\Project;
|
||||
use App\Models\ProjectEvent;
|
||||
use App\Models\ProjectRevision;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
/**
|
||||
* The project's "confirmed current revision" is a separate pointer from the
|
||||
* per-listing liveRelease pointers on project_listing_links: it marks which
|
||||
* revision is the project's current source of truth independent of what is
|
||||
* released to any particular Etsy listing. Uploading a new revision never
|
||||
* moves this pointer; only an explicit confirm or clear does, under an
|
||||
* optimistic concurrency check on the project's own lock_version. Nothing
|
||||
* here ever mutates a revision row — it is purely a pointer update, so an
|
||||
* older revision can always be re-confirmed ("rollback") without overwriting
|
||||
* any immutable history.
|
||||
*/
|
||||
class ProjectCurrentRevisionController extends Controller
|
||||
{
|
||||
public function update(Request $request, Project $project, ProjectRevision $revision): RedirectResponse
|
||||
{
|
||||
abort_unless($revision->project_id === $project->id, 404);
|
||||
abort_unless($request->user()->can('confirmCurrentRevision', $project), 403);
|
||||
|
||||
$data = $request->validate([
|
||||
'lock_version' => ['required', 'integer', 'min:0'],
|
||||
]);
|
||||
|
||||
try {
|
||||
DB::transaction(function () use ($request, $project, $revision, $data): void {
|
||||
$locked = Project::query()->whereKey($project->id)->lockForUpdate()->firstOrFail();
|
||||
if ((int) $locked->lock_version !== (int) $data['lock_version']) {
|
||||
throw new OptimisticLockConflictException('The project changed since you loaded it.');
|
||||
}
|
||||
|
||||
$lockedRevision = ProjectRevision::query()->whereKey($revision->id)->firstOrFail();
|
||||
if ($lockedRevision->project_id !== $locked->id) {
|
||||
abort(404);
|
||||
}
|
||||
if (! $lockedRevision->isApproved()) {
|
||||
throw ValidationException::withMessages(['revision_id' => 'Only an approved revision can be confirmed as current.']);
|
||||
}
|
||||
if ($lockedRevision->files()->count() === 0) {
|
||||
throw ValidationException::withMessages(['revision_id' => 'An approved revision must have at least one file before it can be confirmed as current.']);
|
||||
}
|
||||
|
||||
$previousId = $locked->current_revision_id;
|
||||
$locked->update([
|
||||
'current_revision_id' => $lockedRevision->id,
|
||||
'lock_version' => $locked->lock_version + 1,
|
||||
]);
|
||||
|
||||
ProjectEvent::record($locked->id, 'current_revision_confirmed', $request->user()->id, ProjectRevision::class, $lockedRevision->id, [
|
||||
'previous_current_revision_id' => $previousId,
|
||||
'new_current_revision_id' => $lockedRevision->id,
|
||||
]);
|
||||
});
|
||||
} catch (OptimisticLockConflictException $exception) {
|
||||
return back()->withErrors(['lock_version' => $exception->getMessage()])->setStatusCode(409);
|
||||
}
|
||||
|
||||
return redirect()->route('projects.show', $project)->with('status', 'Current revision confirmed.');
|
||||
}
|
||||
|
||||
public function destroy(Request $request, Project $project): RedirectResponse
|
||||
{
|
||||
abort_unless($request->user()->can('confirmCurrentRevision', $project), 403);
|
||||
|
||||
$data = $request->validate([
|
||||
'lock_version' => ['required', 'integer', 'min:0'],
|
||||
]);
|
||||
|
||||
try {
|
||||
DB::transaction(function () use ($request, $project, $data): void {
|
||||
$locked = Project::query()->whereKey($project->id)->lockForUpdate()->firstOrFail();
|
||||
if ((int) $locked->lock_version !== (int) $data['lock_version']) {
|
||||
throw new OptimisticLockConflictException('The project changed since you loaded it.');
|
||||
}
|
||||
|
||||
$previousId = $locked->current_revision_id;
|
||||
$locked->update(['current_revision_id' => null, 'lock_version' => $locked->lock_version + 1]);
|
||||
|
||||
ProjectEvent::record($locked->id, 'current_revision_cleared', $request->user()->id, metadata: [
|
||||
'previous_current_revision_id' => $previousId,
|
||||
'new_current_revision_id' => null,
|
||||
]);
|
||||
});
|
||||
} catch (OptimisticLockConflictException $exception) {
|
||||
return back()->withErrors(['lock_version' => $exception->getMessage()])->setStatusCode(409);
|
||||
}
|
||||
|
||||
return redirect()->route('projects.show', $project)->with('status', 'Current revision cleared.');
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -36,6 +36,13 @@ class ProjectListingLinkController extends Controller
|
||||
return back()->withErrors(['listing_id' => $exception->getMessage()]);
|
||||
}
|
||||
|
||||
// Re-checked against a fresh (non-cached) fetch above, so this also catches
|
||||
// a listing that was active/draft when the dropdown was rendered but has
|
||||
// since gone inactive, expired or sold out.
|
||||
if (! in_array($listing['state'] ?? null, ['active', 'draft'], true)) {
|
||||
return back()->withErrors(['listing_id' => 'That listing is no longer Live or Draft and cannot be linked.']);
|
||||
}
|
||||
|
||||
try {
|
||||
$link = DB::transaction(function () use ($request, $project, $connection, $data, $listing, $variationKey): ProjectListingLink {
|
||||
$locked = Project::query()->whereKey($project->id)->lockForUpdate()->firstOrFail();
|
||||
|
||||
@@ -57,7 +57,7 @@ class ProjectReleaseController extends Controller
|
||||
$release = DB::transaction(function () use ($request, $project, $listingLink, $revision, $costSnapshot, $data): ProjectRelease {
|
||||
Project::query()->whereKey($project->id)->lockForUpdate()->firstOrFail();
|
||||
$locked = ProjectListingLink::query()->whereKey($listingLink->id)->lockForUpdate()->firstOrFail();
|
||||
if ($locked->lock_version !== $data['lock_version']) {
|
||||
if ((int) $locked->lock_version !== (int) $data['lock_version']) {
|
||||
throw new OptimisticLockConflictException('The listing mapping changed since you loaded it.');
|
||||
}
|
||||
|
||||
@@ -113,7 +113,7 @@ class ProjectReleaseController extends Controller
|
||||
$release = DB::transaction(function () use ($request, $project, $listingLink, $target, $data): ProjectRelease {
|
||||
Project::query()->whereKey($project->id)->lockForUpdate()->firstOrFail();
|
||||
$locked = ProjectListingLink::query()->whereKey($listingLink->id)->lockForUpdate()->firstOrFail();
|
||||
if ($locked->lock_version !== $data['lock_version']) {
|
||||
if ((int) $locked->lock_version !== (int) $data['lock_version']) {
|
||||
throw new OptimisticLockConflictException('The listing mapping changed since you loaded it.');
|
||||
}
|
||||
|
||||
|
||||
@@ -16,7 +16,10 @@ class Project extends Model
|
||||
/** @var list<string> */
|
||||
public const STAGES = ['design', 'ready', 'live', 'retired'];
|
||||
|
||||
protected $fillable = ['reference', 'title', 'notes', 'stage', 'currency', 'created_by', 'archived_at'];
|
||||
protected $fillable = [
|
||||
'reference', 'title', 'notes', 'stage', 'currency', 'created_by', 'archived_at',
|
||||
'current_revision_id', 'lock_version',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
@@ -30,6 +33,11 @@ class Project extends Model
|
||||
return $this->belongsTo(User::class, 'created_by');
|
||||
}
|
||||
|
||||
public function currentRevision(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(ProjectRevision::class, 'current_revision_id');
|
||||
}
|
||||
|
||||
public function revisions(): HasMany
|
||||
{
|
||||
return $this->hasMany(ProjectRevision::class);
|
||||
|
||||
@@ -6,6 +6,7 @@ use Database\Factories\ProjectFileFactory;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasOne;
|
||||
|
||||
class ProjectFile extends Model
|
||||
{
|
||||
@@ -29,4 +30,9 @@ class ProjectFile extends Model
|
||||
{
|
||||
return $this->belongsTo(User::class, 'uploaded_by');
|
||||
}
|
||||
|
||||
public function render(): HasOne
|
||||
{
|
||||
return $this->hasOne(ProjectFileRender::class);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Database\Factories\ProjectFileRenderFactory;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class ProjectFileRender extends Model
|
||||
{
|
||||
/** @use HasFactory<ProjectFileRenderFactory> */
|
||||
use HasFactory;
|
||||
|
||||
public const RENDERER_GD = 'gd';
|
||||
|
||||
public const RENDERER_SVG = 'svg';
|
||||
|
||||
protected $fillable = [
|
||||
'project_file_id', 'renderer', 'mime_type', 'storage_key', 'width', 'height',
|
||||
'triangle_count', 'instance_count', 'alt_text', 'byte_size', 'sha256',
|
||||
'generated_by', 'generated_at',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'generated_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
public function projectFile(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(ProjectFile::class);
|
||||
}
|
||||
|
||||
public function generator(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'generated_by');
|
||||
}
|
||||
}
|
||||
@@ -15,4 +15,13 @@ class ProjectFilePolicy
|
||||
{
|
||||
return $user->role === 'admin' && $file->revision->project_id === $project->id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Same IDOR guard as download(): generating or viewing a rendered preview
|
||||
* must only ever be reachable through the project the file actually belongs to.
|
||||
*/
|
||||
public function render(User $user, ProjectFile $file, Project $project): bool
|
||||
{
|
||||
return $user->role === 'admin' && $file->revision->project_id === $project->id;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,4 +55,9 @@ class ProjectPolicy
|
||||
{
|
||||
return $user->role === 'admin';
|
||||
}
|
||||
|
||||
public function confirmCurrentRevision(User $user, Project $project): bool
|
||||
{
|
||||
return $user->role === 'admin';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,371 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* Renders already-extracted, bounded 3MF geometry (see ThreeMfGeometryExtractor)
|
||||
* into a flat-shaded isometric product preview — a real numeric projection and
|
||||
* rasterisation of the actual mesh, never a placeholder thumbnail or a
|
||||
* fabricated photo.
|
||||
*
|
||||
* Z is treated as the "up" axis (the 3D-printing convention: the build plate
|
||||
* is the XY plane). Projection uses the standard isometric screen-space
|
||||
* formulas screenX = (x - y) * cos(30°), screenY = (x + y) * sin(30°) - z —
|
||||
* these already produce a screen-down-positive Y coordinate directly, so no
|
||||
* further vertical flip is applied; adding one (as an earlier version of
|
||||
* this class did) inverts Z and renders the model upside down.
|
||||
*
|
||||
* The 3MF Core Specification requires mesh triangles to be consistently
|
||||
* wound counter-clockwise as seen from outside a manifold solid. This class
|
||||
* trusts that requirement (the same way ManufacturingFileValidator trusts
|
||||
* well-formed input beyond its own structural checks) to do real backface
|
||||
* culling: a triangle whose outward normal points away from a camera at the
|
||||
* (1,1,1) direction is skipped entirely, rather than drawn and left to an
|
||||
* approximate depth sort to hide. The surviving front-facing triangles are
|
||||
* shaded by the *signed* angle between that same normal and a fixed,
|
||||
* asymmetric light direction, so faces at different angles (e.g. a cube's
|
||||
* top vs. its left vs. its right face) render as visibly different
|
||||
* brightnesses rather than one flat fill.
|
||||
*
|
||||
* Renders to PNG via GD when available. The PNG path rasterises every
|
||||
* surviving triangle against a real per-pixel depth (z-)buffer — barycentric
|
||||
* interpolation decides, pixel by pixel, which triangle is actually nearest
|
||||
* the camera — so overlapping or interleaved geometry is resolved correctly
|
||||
* regardless of triangle draw order, unlike a whole-triangle centroid
|
||||
* painter's-algorithm sort. When GD is not available this falls back to a
|
||||
* fixed-content, numeric-only SVG (polygon coordinates and fill colours
|
||||
* only — no text, scripts, strokes or external references); since SVG has
|
||||
* no per-pixel compositing, that path keeps a depth-sorted painter's
|
||||
* algorithm over the same backface-culled triangle set as its best
|
||||
* achievable approximation.
|
||||
*
|
||||
* Every phase (culling/shading, projection, and PNG/SVG rasterisation) is
|
||||
* bounded by one shared wall-clock render budget, checked throughout — not
|
||||
* only inside the final draw loop — so a pathological but in-bounds triangle
|
||||
* count can't quietly run unbounded before that loop is ever reached.
|
||||
*
|
||||
* @phpstan-import-type Triangle from ThreeMfGeometryExtractor
|
||||
*/
|
||||
class ProductImageRenderer
|
||||
{
|
||||
public const CANVAS_SIZE = 512;
|
||||
|
||||
public const RENDERER_GD = 'gd';
|
||||
|
||||
public const RENDERER_SVG = 'svg';
|
||||
|
||||
private const PADDING = 32;
|
||||
|
||||
private const RENDER_TIME_BUDGET_SECONDS = 5.0;
|
||||
|
||||
/** Camera looks toward the origin from the (1,1,1) direction — matches the isometric projection below. */
|
||||
private const CAMERA_DIRECTION = [0.5773502691896258, 0.5773502691896258, 0.5773502691896258];
|
||||
|
||||
/** Asymmetric so a cube's top/left/right faces render as visibly different brightnesses. */
|
||||
private const LIGHT_DIRECTION = [0.4082482904638631, -0.4082482904638631, 0.8164965809277261];
|
||||
|
||||
private readonly bool $gdAvailable;
|
||||
|
||||
public function __construct(?bool $gdAvailable = null)
|
||||
{
|
||||
$this->gdAvailable = $gdAvailable ?? extension_loaded('gd');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<Triangle> $triangles
|
||||
* @return array{bytes: string, mime: string, renderer: string, width: int, height: int}
|
||||
*/
|
||||
public function render(array $triangles): array
|
||||
{
|
||||
if ($triangles === []) {
|
||||
throw new RuntimeException('Nothing to render: no triangles were provided.');
|
||||
}
|
||||
|
||||
foreach ($triangles as $triangle) {
|
||||
if ($triangle['color'][3] !== 255) {
|
||||
throw new RuntimeException('Transparent materials are not supported by this renderer.');
|
||||
}
|
||||
}
|
||||
|
||||
$start = microtime(true);
|
||||
|
||||
$visible = $this->cullAndShade($triangles, $start);
|
||||
if ($visible === []) {
|
||||
throw new RuntimeException('Nothing to render: every triangle faces away from the camera.');
|
||||
}
|
||||
|
||||
$projected = $this->project($visible, $start);
|
||||
|
||||
return $this->gdAvailable ? $this->renderPng($projected, $start) : $this->renderSvg($projected, $start);
|
||||
}
|
||||
|
||||
public function rendererName(): string
|
||||
{
|
||||
return $this->gdAvailable ? self::RENDERER_GD : self::RENDERER_SVG;
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes each triangle's face normal once, uses it to cull back-facing
|
||||
* triangles (normal pointing away from the camera) and to shade the
|
||||
* survivors by the *signed* angle to the light — trusting 3MF's
|
||||
* mandated outward CCW winding, rather than folding both orientations
|
||||
* into the same brightness via an absolute value as an earlier version
|
||||
* of this class did.
|
||||
*
|
||||
* @param list<Triangle> $triangles
|
||||
* @return list<Triangle>
|
||||
*/
|
||||
private function cullAndShade(array $triangles, float $start): array
|
||||
{
|
||||
$visible = [];
|
||||
foreach ($triangles as $index => $triangle) {
|
||||
if ($index % 2000 === 0) {
|
||||
$this->assertWithinBudget($start);
|
||||
}
|
||||
|
||||
[$ax, $ay, $az] = $triangle['v'][0];
|
||||
[$bx, $by, $bz] = $triangle['v'][1];
|
||||
[$cx, $cy, $cz] = $triangle['v'][2];
|
||||
|
||||
$e1 = [$bx - $ax, $by - $ay, $bz - $az];
|
||||
$e2 = [$cx - $ax, $cy - $ay, $cz - $az];
|
||||
$normal = [
|
||||
$e1[1] * $e2[2] - $e1[2] * $e2[1],
|
||||
$e1[2] * $e2[0] - $e1[0] * $e2[2],
|
||||
$e1[0] * $e2[1] - $e1[1] * $e2[0],
|
||||
];
|
||||
$length = sqrt($normal[0] ** 2 + $normal[1] ** 2 + $normal[2] ** 2);
|
||||
if ($length < 1e-9) {
|
||||
continue; // degenerate (zero-area) triangle
|
||||
}
|
||||
$normal = [$normal[0] / $length, $normal[1] / $length, $normal[2] / $length];
|
||||
|
||||
$cameraDot = $normal[0] * self::CAMERA_DIRECTION[0] + $normal[1] * self::CAMERA_DIRECTION[1] + $normal[2] * self::CAMERA_DIRECTION[2];
|
||||
if ($cameraDot <= 1e-6) {
|
||||
continue; // back-facing (or edge-on): occluded on a manifold solid, cull it
|
||||
}
|
||||
|
||||
$lightDot = $normal[0] * self::LIGHT_DIRECTION[0] + $normal[1] * self::LIGHT_DIRECTION[1] + $normal[2] * self::LIGHT_DIRECTION[2];
|
||||
$brightness = max(0.15, min(1.0, 0.55 + 0.45 * $lightDot));
|
||||
$color = $triangle['color'];
|
||||
|
||||
$visible[] = [
|
||||
'v' => $triangle['v'],
|
||||
'color' => [
|
||||
min(255, (int) round($color[0] * $brightness)),
|
||||
min(255, (int) round($color[1] * $brightness)),
|
||||
min(255, (int) round($color[2] * $brightness)),
|
||||
$color[3],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
return $visible;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<Triangle> $visible
|
||||
* @return list<array{points: array{0:float,1:float,2:float,3:float,4:float,5:float}, depths: array{0:float,1:float,2:float}, avgDepth: float, color: array{0:int,1:int,2:int,3:int}}>
|
||||
*/
|
||||
private function project(array $visible, float $start): array
|
||||
{
|
||||
$minX = $minY = INF;
|
||||
$maxX = $maxY = -INF;
|
||||
$withScreen = [];
|
||||
|
||||
foreach ($visible as $index => $triangle) {
|
||||
if ($index % 2000 === 0) {
|
||||
$this->assertWithinBudget($start);
|
||||
}
|
||||
|
||||
$screen = [];
|
||||
$depths = [];
|
||||
foreach ($triangle['v'] as $vertex) {
|
||||
[$x, $y, $z] = $vertex;
|
||||
// Already screen-down-positive: no further vertical flip.
|
||||
$screenX = ($x - $y) * M_SQRT3 / 2;
|
||||
$screenY = ($x + $y) / 2 - $z;
|
||||
$screen[] = [$screenX, $screenY];
|
||||
$depths[] = $x + $y + $z;
|
||||
$minX = min($minX, $screenX);
|
||||
$maxX = max($maxX, $screenX);
|
||||
$minY = min($minY, $screenY);
|
||||
$maxY = max($maxY, $screenY);
|
||||
}
|
||||
$withScreen[] = ['screen' => $screen, 'depths' => $depths, 'color' => $triangle['color']];
|
||||
}
|
||||
|
||||
$spanX = max($maxX - $minX, 1e-6);
|
||||
$spanY = max($maxY - $minY, 1e-6);
|
||||
$available = self::CANVAS_SIZE - 2 * self::PADDING;
|
||||
$scale = min($available / $spanX, $available / $spanY);
|
||||
|
||||
$result = [];
|
||||
foreach ($withScreen as $triangle) {
|
||||
$points = [];
|
||||
foreach ($triangle['screen'] as [$screenX, $screenY]) {
|
||||
$points[] = self::PADDING + ($screenX - $minX) * $scale;
|
||||
$points[] = self::PADDING + ($screenY - $minY) * $scale;
|
||||
}
|
||||
$result[] = [
|
||||
'points' => $points,
|
||||
'depths' => $triangle['depths'],
|
||||
'avgDepth' => array_sum($triangle['depths']) / 3,
|
||||
'color' => $triangle['color'],
|
||||
];
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array{points: array{0:float,1:float,2:float,3:float,4:float,5:float}, depths: array{0:float,1:float,2:float}, avgDepth: float, color: array{0:int,1:int,2:int,3:int}}> $projected
|
||||
* @return array{bytes: string, mime: string, renderer: string, width: int, height: int}
|
||||
*/
|
||||
private function renderPng(array $projected, float $start): array
|
||||
{
|
||||
$size = self::CANVAS_SIZE;
|
||||
$image = imagecreatetruecolor($size, $size);
|
||||
if ($image === false) {
|
||||
throw new RuntimeException('Unable to create render canvas.');
|
||||
}
|
||||
|
||||
imagesavealpha($image, true);
|
||||
$background = imagecolorallocatealpha($image, 15, 23, 42, 0);
|
||||
imagefill($image, 0, 0, $background);
|
||||
|
||||
/** @var array<int, float|null> $depthBuffer null = untouched background pixel */
|
||||
$depthBuffer = array_fill(0, $size * $size, null);
|
||||
/** @var array<string, int> $colorCache */
|
||||
$colorCache = [];
|
||||
|
||||
foreach ($projected as $index => $triangle) {
|
||||
if ($index % 200 === 0) {
|
||||
$this->assertWithinBudget($start);
|
||||
}
|
||||
$this->rasterizeTriangle($image, $depthBuffer, $colorCache, $size, $triangle, $start);
|
||||
}
|
||||
|
||||
ob_start();
|
||||
imagepng($image);
|
||||
$bytes = ob_get_clean();
|
||||
imagedestroy($image);
|
||||
|
||||
if ($bytes === false || $bytes === '') {
|
||||
throw new RuntimeException('Unable to encode the rendered PNG.');
|
||||
}
|
||||
|
||||
return ['bytes' => $bytes, 'mime' => 'image/png', 'renderer' => self::RENDERER_GD, 'width' => $size, 'height' => $size];
|
||||
}
|
||||
|
||||
/**
|
||||
* Real per-pixel hidden-surface removal: for every candidate pixel in
|
||||
* the triangle's screen-space bounding box, barycentric weights decide
|
||||
* whether the pixel centre lies inside the triangle and, if so,
|
||||
* interpolate its depth; the pixel is only painted when that depth is
|
||||
* nearer the camera than whatever already occupies it. This is what
|
||||
* correctly resolves overlapping/interleaved front-facing geometry that
|
||||
* a single whole-triangle centroid sort cannot.
|
||||
*
|
||||
* @param array<int, float|null> $depthBuffer
|
||||
* @param array<string, int> $colorCache
|
||||
* @param array{points: array{0:float,1:float,2:float,3:float,4:float,5:float}, depths: array{0:float,1:float,2:float}, avgDepth: float, color: array{0:int,1:int,2:int,3:int}} $triangle
|
||||
*/
|
||||
private function rasterizeTriangle(\GdImage $image, array &$depthBuffer, array &$colorCache, int $size, array $triangle, float $start): void
|
||||
{
|
||||
[$x0, $y0, $x1, $y1, $x2, $y2] = $triangle['points'];
|
||||
[$z0, $z1, $z2] = $triangle['depths'];
|
||||
|
||||
$denom = ($y1 - $y2) * ($x0 - $x2) + ($x2 - $x1) * ($y0 - $y2);
|
||||
if (abs($denom) < 1e-9) {
|
||||
return; // degenerate in screen space
|
||||
}
|
||||
|
||||
$minPX = max(0, (int) floor(min($x0, $x1, $x2)));
|
||||
$maxPX = min($size - 1, (int) ceil(max($x0, $x1, $x2)));
|
||||
$minPY = max(0, (int) floor(min($y0, $y1, $y2)));
|
||||
$maxPY = min($size - 1, (int) ceil(max($y0, $y1, $y2)));
|
||||
if ($minPX > $maxPX || $minPY > $maxPY) {
|
||||
return;
|
||||
}
|
||||
|
||||
$colorKey = implode(',', $triangle['color']);
|
||||
if (! isset($colorCache[$colorKey])) {
|
||||
[$r, $g, $b] = $triangle['color'];
|
||||
$colorCache[$colorKey] = imagecolorallocate($image, $r, $g, $b);
|
||||
}
|
||||
$color = $colorCache[$colorKey];
|
||||
|
||||
for ($py = $minPY; $py <= $maxPY; $py++) {
|
||||
if (microtime(true) - $start > self::RENDER_TIME_BUDGET_SECONDS) {
|
||||
throw new RuntimeException('Rendering exceeded its time budget.');
|
||||
}
|
||||
$rowOffset = $py * $size;
|
||||
$cy = $py + 0.5;
|
||||
|
||||
for ($px = $minPX; $px <= $maxPX; $px++) {
|
||||
$cx = $px + 0.5;
|
||||
$w0 = (($y1 - $y2) * ($cx - $x2) + ($x2 - $x1) * ($cy - $y2)) / $denom;
|
||||
$w1 = (($y2 - $y0) * ($cx - $x2) + ($x0 - $x2) * ($cy - $y2)) / $denom;
|
||||
$w2 = 1.0 - $w0 - $w1;
|
||||
if ($w0 < -1e-4 || $w1 < -1e-4 || $w2 < -1e-4) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$depth = $w0 * $z0 + $w1 * $z1 + $w2 * $z2;
|
||||
$offset = $rowOffset + $px;
|
||||
if ($depthBuffer[$offset] === null || $depth > $depthBuffer[$offset]) {
|
||||
$depthBuffer[$offset] = $depth;
|
||||
imagesetpixel($image, $px, $py, $color);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array{points: array{0:float,1:float,2:float,3:float,4:float,5:float}, depths: array{0:float,1:float,2:float}, avgDepth: float, color: array{0:int,1:int,2:int,3:int}}> $projected
|
||||
* @return array{bytes: string, mime: string, renderer: string, width: int, height: int}
|
||||
*/
|
||||
private function renderSvg(array $projected, float $start): array
|
||||
{
|
||||
// No per-pixel compositing is possible in a vector format, so this
|
||||
// is the best achievable approximation over the already
|
||||
// backface-culled set: draw the (now consistently front-facing)
|
||||
// triangles farthest-from-camera first.
|
||||
usort($projected, fn (array $a, array $b): int => $a['avgDepth'] <=> $b['avgDepth']);
|
||||
|
||||
$size = self::CANVAS_SIZE;
|
||||
$parts = [
|
||||
'<?xml version="1.0" encoding="UTF-8"?>',
|
||||
sprintf('<svg xmlns="http://www.w3.org/2000/svg" width="%d" height="%d" viewBox="0 0 %d %d">', $size, $size, $size, $size),
|
||||
sprintf('<rect width="%d" height="%d" fill="#0f172a"/>', $size, $size),
|
||||
];
|
||||
|
||||
foreach ($projected as $index => $triangle) {
|
||||
if ($index % 2000 === 0) {
|
||||
$this->assertWithinBudget($start);
|
||||
}
|
||||
|
||||
[$r, $g, $b] = $triangle['color'];
|
||||
[$x1, $y1, $x2, $y2, $x3, $y3] = $triangle['points'];
|
||||
$parts[] = sprintf(
|
||||
'<polygon points="%.2f,%.2f %.2f,%.2f %.2f,%.2f" fill="#%02x%02x%02x"/>',
|
||||
$x1, $y1, $x2, $y2, $x3, $y3, $r, $g, $b,
|
||||
);
|
||||
}
|
||||
$parts[] = '</svg>';
|
||||
|
||||
$bytes = implode('', $parts);
|
||||
|
||||
return ['bytes' => $bytes, 'mime' => 'image/svg+xml', 'renderer' => self::RENDERER_SVG, 'width' => $size, 'height' => $size];
|
||||
}
|
||||
|
||||
private function assertWithinBudget(float $start): void
|
||||
{
|
||||
if (microtime(true) - $start > self::RENDER_TIME_BUDGET_SECONDS) {
|
||||
throw new RuntimeException('Rendering exceeded its time budget.');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,725 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use DOMDocument;
|
||||
use DOMElement;
|
||||
use RuntimeException;
|
||||
use ZipArchive;
|
||||
|
||||
/**
|
||||
* Extracts real, numeric build-plate geometry from an already-upload-validated
|
||||
* 3MF package, for rendering a product preview image. This is treated as its
|
||||
* own independent security boundary (not merely trusting that
|
||||
* ManufacturingFileValidator ran earlier): it re-applies bounded zip reads
|
||||
* (entry count, per-entry/total uncompressed size, compression ratio,
|
||||
* encrypted/symlink/duplicate/unsafe-path entries) and an XXE-safe,
|
||||
* DOCTYPE-rejecting DOMDocument parse of the model part, exactly as that
|
||||
* validator does, before resolving any scene graph.
|
||||
*
|
||||
* Beyond structural safety, this also resolves the actual numeric geometry:
|
||||
* <object> meshes, nested <components> with their own transforms, and
|
||||
* <build> items placing one or more instances of an object on the plate —
|
||||
* composing 3MF affine transforms via ThreeMfTransform. Flat colours are
|
||||
* resolved where an object or triangle references a <basematerials> or
|
||||
* <colorgroup> group. Anything this cannot safely and faithfully resolve —
|
||||
* a <texture2d*> resource, an <item>/<component> referencing another
|
||||
* package part via a `path` attribute, a pid referencing an unrecognised or
|
||||
* nonexistent property group, an out-of-range pindex, or a triangle with
|
||||
* genuinely interpolated (differing p1/p2/p3) per-vertex colours — is
|
||||
* rejected with a clear message rather than silently rendered wrong (e.g.
|
||||
* defaulting to grey).
|
||||
*
|
||||
* Geometry is bounded on every axis that drives time or memory: entry/part
|
||||
* byte budgets, object count, vertices/triangles per mesh, an aggregate cap
|
||||
* on total vertices/triangles across every object (independent of the
|
||||
* per-mesh cap, closing the "500 objects x 100k vertices each" worst case),
|
||||
* build item count, component nesting depth (with cycle detection), and the
|
||||
* total number of triangles resolved after component/instance expansion —
|
||||
* checked incrementally during expansion, not only at the end. A single
|
||||
* wall-clock parse budget (injectable via the constructor for deterministic
|
||||
* tests) is checked throughout every bounded-read and parsing loop — zip
|
||||
* entry iteration, chunked entry reads, resource/mesh/build-item iteration,
|
||||
* and scene-graph expansion — not only inside the final expansion step; the
|
||||
* DOMDocument parse itself cannot be preempted mid-call, so it is bounded by
|
||||
* the byte caps feeding it and checked immediately before and after.
|
||||
*
|
||||
* @phpstan-type Point array{0:float,1:float,2:float}
|
||||
* @phpstan-type Color array{0:int,1:int,2:int,3:int}
|
||||
* @phpstan-type Triangle array{v: array{0:Point,1:Point,2:Point}, color: Color}
|
||||
*/
|
||||
class ThreeMfGeometryExtractor
|
||||
{
|
||||
private const MAX_ENTRIES = 2000;
|
||||
|
||||
private const MAX_TOTAL_UNCOMPRESSED = 128 * 1024 * 1024;
|
||||
|
||||
private const MAX_RATIO = 100;
|
||||
|
||||
private const MAX_MODEL_PART_BYTES = 64 * 1024 * 1024;
|
||||
|
||||
private const MAX_TOTAL_PARSED_BYTES = 128 * 1024 * 1024;
|
||||
|
||||
private const MAX_OBJECTS = 500;
|
||||
|
||||
private const MAX_VERTICES_PER_MESH = 100_000;
|
||||
|
||||
private const MAX_TRIANGLES_PER_MESH = 100_000;
|
||||
|
||||
private const MAX_BUILD_ITEMS = 200;
|
||||
|
||||
private const MAX_COMPONENT_DEPTH = 8;
|
||||
|
||||
private const MAX_TOTAL_RESOLVED_TRIANGLES = 150_000;
|
||||
|
||||
/** Aggregate cap across every <object>'s parsed mesh, independent of the per-mesh cap — closes the "500 objects x 100k vertices each" worst case. */
|
||||
private const MAX_TOTAL_PARSED_VERTICES = 500_000;
|
||||
|
||||
private const MAX_TOTAL_PARSED_TRIANGLES = 500_000;
|
||||
|
||||
private const PARSE_TIME_BUDGET_SECONDS = 5.0;
|
||||
|
||||
private const MODEL_NAMESPACE_PREFIX = 'http://schemas.microsoft.com/3dmanufacturing/core/';
|
||||
|
||||
/** @var array<int, Color> default grey used when no base material resolves */
|
||||
private const DEFAULT_COLOR = [148, 163, 184, 255];
|
||||
|
||||
private readonly float $timeBudgetSeconds;
|
||||
|
||||
private float $deadline = 0.0;
|
||||
|
||||
private int $totalParsedVertices = 0;
|
||||
|
||||
private int $totalParsedTriangles = 0;
|
||||
|
||||
/**
|
||||
* @param ?float $timeBudgetSeconds Injectable for tests so a deadline-exceeded path can
|
||||
* be exercised deterministically (an already-tiny or already-past budget) rather than
|
||||
* with a real sleep. Defaults to PARSE_TIME_BUDGET_SECONDS in production.
|
||||
*/
|
||||
public function __construct(?float $timeBudgetSeconds = null)
|
||||
{
|
||||
$this->timeBudgetSeconds = $timeBudgetSeconds ?? self::PARSE_TIME_BUDGET_SECONDS;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{triangles: list<Triangle>, triangle_count: int, instance_count: int}
|
||||
*/
|
||||
public function extract(string $path): array
|
||||
{
|
||||
$this->deadline = microtime(true) + $this->timeBudgetSeconds;
|
||||
$this->totalParsedVertices = 0;
|
||||
$this->totalParsedTriangles = 0;
|
||||
|
||||
$zip = new ZipArchive;
|
||||
if ($zip->open($path) !== true) {
|
||||
throw new RuntimeException('Unable to read 3MF archive.');
|
||||
}
|
||||
|
||||
try {
|
||||
$modelXml = $this->readModelPart($zip);
|
||||
$this->assertWithinDeadline();
|
||||
$document = $this->parseSafeXml($modelXml);
|
||||
$this->assertWithinDeadline();
|
||||
[$objects, $baseMaterials] = $this->parseResources($document);
|
||||
$buildItems = $this->parseBuildItems($document);
|
||||
|
||||
$triangles = [];
|
||||
foreach ($buildItems as $item) {
|
||||
$this->resolveObjectInstance(
|
||||
$item['objectid'], $item['transform'], $objects, $baseMaterials, [], 0, $triangles,
|
||||
);
|
||||
}
|
||||
|
||||
if ($triangles === []) {
|
||||
throw new RuntimeException('3MF model contains no renderable geometry.');
|
||||
}
|
||||
|
||||
return [
|
||||
'triangles' => $triangles,
|
||||
'triangle_count' => count($triangles),
|
||||
'instance_count' => count($buildItems),
|
||||
];
|
||||
} finally {
|
||||
$zip->close();
|
||||
}
|
||||
}
|
||||
|
||||
private function readModelPart(ZipArchive $zip): string
|
||||
{
|
||||
$count = $zip->count();
|
||||
if ($count > self::MAX_ENTRIES) {
|
||||
throw new RuntimeException('3MF archive has too many entries.');
|
||||
}
|
||||
|
||||
$totalUncompressed = 0;
|
||||
$seenNames = [];
|
||||
$hasModelPart = false;
|
||||
|
||||
for ($i = 0; $i < $count; $i++) {
|
||||
$this->assertWithinDeadline();
|
||||
$stat = $zip->statIndex($i);
|
||||
if ($stat === false) {
|
||||
throw new RuntimeException('Unable to read 3MF archive entry.');
|
||||
}
|
||||
$name = (string) $stat['name'];
|
||||
|
||||
if (isset($seenNames[$name])) {
|
||||
throw new RuntimeException("3MF archive contains a duplicate entry path: {$name}.");
|
||||
}
|
||||
$seenNames[$name] = true;
|
||||
|
||||
$this->assertSafeZipEntryName($name);
|
||||
$this->assertNotSymlinkEntry($zip, $i, $name);
|
||||
|
||||
if (($stat['encryption_method'] ?? ZipArchive::EM_NONE) !== ZipArchive::EM_NONE) {
|
||||
throw new RuntimeException("3MF archive contains an encrypted entry: {$name}.");
|
||||
}
|
||||
|
||||
$uncompressed = (int) $stat['size'];
|
||||
$compressed = (int) $stat['comp_size'];
|
||||
$totalUncompressed += $uncompressed;
|
||||
|
||||
if ($compressed > 0 && ($uncompressed / $compressed) > self::MAX_RATIO) {
|
||||
throw new RuntimeException('3MF entry has a suspicious compression ratio.');
|
||||
}
|
||||
if ($compressed === 0 && $uncompressed > 10 * 1024 * 1024) {
|
||||
throw new RuntimeException('3MF entry has a suspicious compression ratio.');
|
||||
}
|
||||
|
||||
if ($name === '3D/3dmodel.model') {
|
||||
$hasModelPart = true;
|
||||
}
|
||||
}
|
||||
|
||||
if ($totalUncompressed > self::MAX_TOTAL_UNCOMPRESSED) {
|
||||
throw new RuntimeException('3MF archive exceeds the maximum uncompressed size.');
|
||||
}
|
||||
if (! $hasModelPart) {
|
||||
throw new RuntimeException('3MF archive is missing the required 3D model part.');
|
||||
}
|
||||
|
||||
$budget = self::MAX_TOTAL_PARSED_BYTES;
|
||||
|
||||
return $this->readBoundedZipEntry($zip, '3D/3dmodel.model', self::MAX_MODEL_PART_BYTES, $budget);
|
||||
}
|
||||
|
||||
private function readBoundedZipEntry(ZipArchive $zip, string $name, int $perEntryCap, int &$remainingBudget): string
|
||||
{
|
||||
$stream = $zip->getStream($name);
|
||||
if ($stream === false) {
|
||||
throw new RuntimeException("Unable to read 3MF archive entry: {$name}.");
|
||||
}
|
||||
|
||||
$buffer = '';
|
||||
$total = 0;
|
||||
try {
|
||||
while (! feof($stream)) {
|
||||
$this->assertWithinDeadline();
|
||||
$chunk = fread($stream, 65536);
|
||||
if ($chunk === false) {
|
||||
throw new RuntimeException("Unable to read 3MF archive entry: {$name}.");
|
||||
}
|
||||
$total += strlen($chunk);
|
||||
if ($total > $perEntryCap) {
|
||||
throw new RuntimeException("3MF archive entry exceeds its size cap: {$name}.");
|
||||
}
|
||||
$remainingBudget -= strlen($chunk);
|
||||
if ($remainingBudget < 0) {
|
||||
throw new RuntimeException('3MF archive exceeds the total parse budget.');
|
||||
}
|
||||
$buffer .= $chunk;
|
||||
}
|
||||
} finally {
|
||||
fclose($stream);
|
||||
}
|
||||
|
||||
return $buffer;
|
||||
}
|
||||
|
||||
private function parseSafeXml(string $xml): DOMDocument
|
||||
{
|
||||
libxml_use_internal_errors(true);
|
||||
libxml_clear_errors();
|
||||
libxml_set_external_entity_loader(static fn (): mixed => null);
|
||||
|
||||
try {
|
||||
$document = new DOMDocument;
|
||||
$loaded = $document->loadXML($xml, LIBXML_NONET | LIBXML_NOERROR | LIBXML_NOWARNING);
|
||||
if ($loaded === false || $document->documentElement === null) {
|
||||
throw new RuntimeException('3MF model part is not well-formed XML.');
|
||||
}
|
||||
if ($document->doctype !== null) {
|
||||
throw new RuntimeException('3MF model part contains a disallowed DOCTYPE declaration.');
|
||||
}
|
||||
$root = $document->documentElement;
|
||||
if ($root->localName !== 'model' || ! str_starts_with((string) $root->namespaceURI, self::MODEL_NAMESPACE_PREFIX)) {
|
||||
throw new RuntimeException('3MF model part does not have a valid <model> root element.');
|
||||
}
|
||||
|
||||
return $document;
|
||||
} finally {
|
||||
libxml_clear_errors();
|
||||
libxml_set_external_entity_loader(null);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{0: array<int, array{type: string, pid: ?int, pindex: ?int, mesh: ?array{vertices: list<Point>, triangles: list<array{v1:int,v2:int,v3:int,pid:?int,p1:?int,p2:?int,p3:?int}>}, components: list<array{objectid:int, transform: array}>}>, 1: array<int, array<int, Color>>}
|
||||
*/
|
||||
private function parseResources(DOMDocument $document): array
|
||||
{
|
||||
$resources = null;
|
||||
foreach ($document->documentElement->childNodes as $child) {
|
||||
if ($child instanceof DOMElement && $child->localName === 'resources') {
|
||||
$resources = $child;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if ($resources === null) {
|
||||
throw new RuntimeException('3MF model part is missing a <resources> element.');
|
||||
}
|
||||
|
||||
$objects = [];
|
||||
$baseMaterials = [];
|
||||
$objectCount = 0;
|
||||
|
||||
foreach ($resources->childNodes as $node) {
|
||||
$this->assertWithinDeadline();
|
||||
if (! $node instanceof DOMElement) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (stripos($node->localName, 'texture') !== false) {
|
||||
throw new RuntimeException('3MF model declares texture resources, which are not supported by this renderer.');
|
||||
}
|
||||
|
||||
if ($node->localName === 'basematerials') {
|
||||
$id = $this->intAttribute($node, 'id');
|
||||
$baseMaterials[$id] = $this->parseBaseMaterials($node);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($node->localName === 'colorgroup') {
|
||||
$id = $this->intAttribute($node, 'id');
|
||||
$baseMaterials[$id] = $this->parseColorGroup($node);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($node->localName === 'object') {
|
||||
$objectCount++;
|
||||
if ($objectCount > self::MAX_OBJECTS) {
|
||||
throw new RuntimeException('3MF model declares too many objects.');
|
||||
}
|
||||
$id = $this->intAttribute($node, 'id');
|
||||
$objects[$id] = $this->parseObject($node);
|
||||
}
|
||||
}
|
||||
|
||||
return [$objects, $baseMaterials];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, Color>
|
||||
*/
|
||||
private function parseBaseMaterials(DOMElement $node): array
|
||||
{
|
||||
$colors = [];
|
||||
$index = 0;
|
||||
foreach ($node->childNodes as $child) {
|
||||
$this->assertWithinDeadline();
|
||||
if (! $child instanceof DOMElement || $child->localName !== 'base') {
|
||||
continue;
|
||||
}
|
||||
$hex = $child->getAttribute('displaycolor');
|
||||
$color = $this->parseHexColor($hex);
|
||||
if ($color !== null) {
|
||||
$colors[$index] = $color;
|
||||
}
|
||||
$index++;
|
||||
}
|
||||
|
||||
return $colors;
|
||||
}
|
||||
|
||||
/**
|
||||
* The Materials and Properties extension's <colorgroup>, resolved the
|
||||
* same way as <basematerials>: each <color color="#RRGGBB(AA)"/> child
|
||||
* is indexed by position so a pid/pindex pair can reference it
|
||||
* identically regardless of which group type it came from.
|
||||
*
|
||||
* @return array<int, Color>
|
||||
*/
|
||||
private function parseColorGroup(DOMElement $node): array
|
||||
{
|
||||
$colors = [];
|
||||
$index = 0;
|
||||
foreach ($node->childNodes as $child) {
|
||||
$this->assertWithinDeadline();
|
||||
if (! $child instanceof DOMElement || $child->localName !== 'color') {
|
||||
continue;
|
||||
}
|
||||
$color = $this->parseHexColor($child->getAttribute('color'));
|
||||
if ($color !== null) {
|
||||
$colors[$index] = $color;
|
||||
}
|
||||
$index++;
|
||||
}
|
||||
|
||||
return $colors;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Color|null
|
||||
*/
|
||||
private function parseHexColor(string $hex): ?array
|
||||
{
|
||||
$hex = ltrim($hex, '#');
|
||||
if (! preg_match('/^[0-9A-Fa-f]{6}([0-9A-Fa-f]{2})?$/', $hex)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$r = hexdec(substr($hex, 0, 2));
|
||||
$g = hexdec(substr($hex, 2, 2));
|
||||
$b = hexdec(substr($hex, 4, 2));
|
||||
$a = strlen($hex) === 8 ? hexdec(substr($hex, 6, 2)) : 255;
|
||||
|
||||
return [$r, $g, $b, $a];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{type: string, pid: ?int, pindex: ?int, mesh: ?array{vertices: list<Point>, triangles: list<array{v1:int,v2:int,v3:int,pid:?int,p1:?int,p2:?int,p3:?int}>}, components: list<array{objectid:int, transform: array}>}
|
||||
*/
|
||||
private function parseObject(DOMElement $object): array
|
||||
{
|
||||
$type = $object->getAttribute('type') ?: 'model';
|
||||
$pid = $object->hasAttribute('pid') ? $this->intAttribute($object, 'pid') : null;
|
||||
$pindex = $object->hasAttribute('pindex') ? $this->intAttribute($object, 'pindex') : null;
|
||||
$mesh = null;
|
||||
$components = [];
|
||||
|
||||
foreach ($object->childNodes as $child) {
|
||||
if (! $child instanceof DOMElement) {
|
||||
continue;
|
||||
}
|
||||
if ($child->localName === 'mesh') {
|
||||
$mesh = $this->parseMesh($child);
|
||||
} elseif ($child->localName === 'components') {
|
||||
foreach ($child->childNodes as $componentNode) {
|
||||
$this->assertWithinDeadline();
|
||||
if (! $componentNode instanceof DOMElement || $componentNode->localName !== 'component') {
|
||||
continue;
|
||||
}
|
||||
if ($this->hasPathAttribute($componentNode)) {
|
||||
throw new RuntimeException('3MF model references an external model part, which is not supported.');
|
||||
}
|
||||
$components[] = [
|
||||
'objectid' => $this->intAttribute($componentNode, 'objectid'),
|
||||
'transform' => $componentNode->hasAttribute('transform')
|
||||
? ThreeMfTransform::parse($componentNode->getAttribute('transform'))
|
||||
: ThreeMfTransform::identity(),
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ['type' => $type, 'pid' => $pid, 'pindex' => $pindex, 'mesh' => $mesh, 'components' => $components];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{vertices: list<Point>, triangles: list<array{v1:int,v2:int,v3:int,pid:?int,p1:?int,p2:?int,p3:?int}>}
|
||||
*/
|
||||
private function parseMesh(DOMElement $mesh): array
|
||||
{
|
||||
$vertices = [];
|
||||
$triangles = [];
|
||||
|
||||
foreach ($mesh->childNodes as $child) {
|
||||
if (! $child instanceof DOMElement) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($child->localName === 'vertices') {
|
||||
foreach ($child->childNodes as $vertexNode) {
|
||||
$this->assertWithinDeadline();
|
||||
if (! $vertexNode instanceof DOMElement || $vertexNode->localName !== 'vertex') {
|
||||
continue;
|
||||
}
|
||||
if (count($vertices) >= self::MAX_VERTICES_PER_MESH) {
|
||||
throw new RuntimeException('3MF mesh exceeds the maximum vertex count.');
|
||||
}
|
||||
if (++$this->totalParsedVertices > self::MAX_TOTAL_PARSED_VERTICES) {
|
||||
throw new RuntimeException('3MF model exceeds the maximum total vertex count across all objects.');
|
||||
}
|
||||
$vertices[] = [
|
||||
$this->floatAttribute($vertexNode, 'x'),
|
||||
$this->floatAttribute($vertexNode, 'y'),
|
||||
$this->floatAttribute($vertexNode, 'z'),
|
||||
];
|
||||
}
|
||||
} elseif ($child->localName === 'triangles') {
|
||||
foreach ($child->childNodes as $triangleNode) {
|
||||
$this->assertWithinDeadline();
|
||||
if (! $triangleNode instanceof DOMElement || $triangleNode->localName !== 'triangle') {
|
||||
continue;
|
||||
}
|
||||
if (count($triangles) >= self::MAX_TRIANGLES_PER_MESH) {
|
||||
throw new RuntimeException('3MF mesh exceeds the maximum triangle count.');
|
||||
}
|
||||
if (++$this->totalParsedTriangles > self::MAX_TOTAL_PARSED_TRIANGLES) {
|
||||
throw new RuntimeException('3MF model exceeds the maximum total triangle count across all objects.');
|
||||
}
|
||||
$triangles[] = [
|
||||
'v1' => $this->intAttribute($triangleNode, 'v1'),
|
||||
'v2' => $this->intAttribute($triangleNode, 'v2'),
|
||||
'v3' => $this->intAttribute($triangleNode, 'v3'),
|
||||
'pid' => $triangleNode->hasAttribute('pid') ? $this->intAttribute($triangleNode, 'pid') : null,
|
||||
'p1' => $triangleNode->hasAttribute('p1') ? $this->intAttribute($triangleNode, 'p1') : null,
|
||||
'p2' => $triangleNode->hasAttribute('p2') ? $this->intAttribute($triangleNode, 'p2') : null,
|
||||
'p3' => $triangleNode->hasAttribute('p3') ? $this->intAttribute($triangleNode, 'p3') : null,
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ['vertices' => $vertices, 'triangles' => $triangles];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array{objectid: int, transform: array}>
|
||||
*/
|
||||
private function parseBuildItems(DOMDocument $document): array
|
||||
{
|
||||
$build = null;
|
||||
foreach ($document->documentElement->childNodes as $child) {
|
||||
if ($child instanceof DOMElement && $child->localName === 'build') {
|
||||
$build = $child;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if ($build === null) {
|
||||
throw new RuntimeException('3MF model part is missing a <build> element.');
|
||||
}
|
||||
|
||||
$items = [];
|
||||
foreach ($build->childNodes as $child) {
|
||||
$this->assertWithinDeadline();
|
||||
if (! $child instanceof DOMElement || $child->localName !== 'item') {
|
||||
continue;
|
||||
}
|
||||
if ($this->hasPathAttribute($child)) {
|
||||
throw new RuntimeException('3MF build references an external model part, which is not supported.');
|
||||
}
|
||||
if (count($items) >= self::MAX_BUILD_ITEMS) {
|
||||
throw new RuntimeException('3MF build plate has too many items.');
|
||||
}
|
||||
$items[] = [
|
||||
'objectid' => $this->intAttribute($child, 'objectid'),
|
||||
'transform' => $child->hasAttribute('transform')
|
||||
? ThreeMfTransform::parse($child->getAttribute('transform'))
|
||||
: ThreeMfTransform::identity(),
|
||||
];
|
||||
}
|
||||
|
||||
return $items;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively resolves one placed instance of an object (a build item, or
|
||||
* a component nested inside another object) into world-space triangles,
|
||||
* composing transforms down the chain and bounding depth, cycles and the
|
||||
* total resolved triangle count incrementally.
|
||||
*
|
||||
* @param array<int, array{type: string, pid: ?int, pindex: ?int, mesh: ?array{vertices: list<Point>, triangles: list<array{v1:int,v2:int,v3:int,pid:?int,p1:?int,p2:?int,p3:?int}>}, components: list<array{objectid:int, transform: array}>}> $objects
|
||||
* @param array<int, array<int, Color>> $baseMaterials
|
||||
* @param list<int> $ancestry
|
||||
* @param list<Triangle> $triangles
|
||||
*/
|
||||
private function resolveObjectInstance(
|
||||
int $objectId,
|
||||
array $transform,
|
||||
array $objects,
|
||||
array $baseMaterials,
|
||||
array $ancestry,
|
||||
int $depth,
|
||||
array &$triangles,
|
||||
): void {
|
||||
$this->assertWithinDeadline();
|
||||
if ($depth > self::MAX_COMPONENT_DEPTH) {
|
||||
throw new RuntimeException('3MF model nests components too deeply.');
|
||||
}
|
||||
if (in_array($objectId, $ancestry, true)) {
|
||||
throw new RuntimeException('3MF model contains a circular component reference.');
|
||||
}
|
||||
if (! isset($objects[$objectId])) {
|
||||
throw new RuntimeException("3MF build references an object id that does not exist: {$objectId}.");
|
||||
}
|
||||
|
||||
$object = $objects[$objectId];
|
||||
$ancestry[] = $objectId;
|
||||
|
||||
if ($object['mesh'] !== null) {
|
||||
$defaultColor = $this->resolveColor($baseMaterials, $object['pid'], $object['pindex']);
|
||||
$determinant = $transform[0] * ($transform[4] * $transform[8] - $transform[5] * $transform[7])
|
||||
- $transform[1] * ($transform[3] * $transform[8] - $transform[5] * $transform[6])
|
||||
+ $transform[2] * ($transform[3] * $transform[7] - $transform[4] * $transform[6]);
|
||||
foreach ($object['mesh']['triangles'] as $triangle) {
|
||||
$this->assertWithinDeadline();
|
||||
if ($determinant < 0) {
|
||||
[$triangle['v2'], $triangle['v3']] = [$triangle['v3'], $triangle['v2']];
|
||||
}
|
||||
if (count($triangles) >= self::MAX_TOTAL_RESOLVED_TRIANGLES) {
|
||||
throw new RuntimeException('3MF model resolves to too many triangles once instances are expanded.');
|
||||
}
|
||||
$vertices = $object['mesh']['vertices'];
|
||||
if (! isset($vertices[$triangle['v1']], $vertices[$triangle['v2']], $vertices[$triangle['v3']])) {
|
||||
throw new RuntimeException('3MF triangle references a vertex index that does not exist.');
|
||||
}
|
||||
|
||||
$color = $triangle['pid'] !== null
|
||||
? $this->resolveColor($baseMaterials, $triangle['pid'], $this->resolveTriangleVertexIndex($triangle))
|
||||
: $defaultColor;
|
||||
|
||||
$triangles[] = [
|
||||
'v' => [
|
||||
ThreeMfTransform::apply($transform, $vertices[$triangle['v1']]),
|
||||
ThreeMfTransform::apply($transform, $vertices[$triangle['v2']]),
|
||||
ThreeMfTransform::apply($transform, $vertices[$triangle['v3']]),
|
||||
],
|
||||
'color' => $color,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($object['components'] as $component) {
|
||||
$this->resolveObjectInstance(
|
||||
$component['objectid'],
|
||||
ThreeMfTransform::compose($transform, $component['transform']),
|
||||
$objects,
|
||||
$baseMaterials,
|
||||
$ancestry,
|
||||
$depth + 1,
|
||||
$triangles,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* No silent grey fallback: a pid/pindex pair is only ever absent when no
|
||||
* colour was specified at all (the legitimate default-colour case). Once
|
||||
* a pid is specified, the referenced property group and index must
|
||||
* actually resolve, or this throws rather than quietly rendering the
|
||||
* wrong colour.
|
||||
*
|
||||
* @param array<int, array<int, Color>> $baseMaterials
|
||||
* @return Color
|
||||
*/
|
||||
private function resolveColor(array $baseMaterials, ?int $pid, ?int $pindex): array
|
||||
{
|
||||
if ($pid === null && $pindex === null) {
|
||||
return self::DEFAULT_COLOR;
|
||||
}
|
||||
if ($pid === null || $pindex === null) {
|
||||
throw new RuntimeException('3MF model specifies only one of pid/pindex; both are required to resolve a colour.');
|
||||
}
|
||||
if (! isset($baseMaterials[$pid])) {
|
||||
throw new RuntimeException("3MF model references an unsupported or unknown property group (pid={$pid}); only <basematerials> and <colorgroup> colours are supported.");
|
||||
}
|
||||
if (! isset($baseMaterials[$pid][$pindex])) {
|
||||
throw new RuntimeException("3MF model references an unresolved property index (pid={$pid}, pindex={$pindex}).");
|
||||
}
|
||||
|
||||
return $baseMaterials[$pid][$pindex];
|
||||
}
|
||||
|
||||
/**
|
||||
* A triangle's p1/p2/p3 attributes let each of its 3 vertices reference
|
||||
* a different property index, producing an interpolated gradient across
|
||||
* the face. This renderer only paints one flat colour per triangle, so a
|
||||
* triangle that actually declares differing per-vertex indices is
|
||||
* rejected explicitly rather than silently picking p1 and discarding the
|
||||
* rest of the declared colour data.
|
||||
*
|
||||
* @param array{v1:int,v2:int,v3:int,pid:?int,p1:?int,p2:?int,p3:?int} $triangle
|
||||
*/
|
||||
private function resolveTriangleVertexIndex(array $triangle): ?int
|
||||
{
|
||||
$p1 = $triangle['p1'];
|
||||
$p2 = $triangle['p2'] ?? $p1;
|
||||
$p3 = $triangle['p3'] ?? $p1;
|
||||
|
||||
if ($p2 !== $p1 || $p3 !== $p1) {
|
||||
throw new RuntimeException('3MF triangle declares interpolated per-vertex colours (p1/p2/p3 differ), which this renderer cannot faithfully render as a single flat colour.');
|
||||
}
|
||||
|
||||
return $p1;
|
||||
}
|
||||
|
||||
private function assertWithinDeadline(): void
|
||||
{
|
||||
if (microtime(true) > $this->deadline) {
|
||||
throw new RuntimeException('3MF parsing exceeded its time budget.');
|
||||
}
|
||||
}
|
||||
|
||||
private function hasPathAttribute(DOMElement $element): bool
|
||||
{
|
||||
foreach ($element->attributes ?? [] as $attribute) {
|
||||
if ($attribute instanceof \DOMAttr && $attribute->localName === 'path') {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private function intAttribute(DOMElement $element, string $name): int
|
||||
{
|
||||
$value = $element->getAttribute($name);
|
||||
if (! ctype_digit($value) && ! (str_starts_with($value, '-') && ctype_digit(substr($value, 1)))) {
|
||||
throw new RuntimeException("3MF model has a non-integer `{$name}` attribute.");
|
||||
}
|
||||
|
||||
return (int) $value;
|
||||
}
|
||||
|
||||
private function floatAttribute(DOMElement $element, string $name): float
|
||||
{
|
||||
$value = $element->getAttribute($name);
|
||||
if (! is_numeric($value)) {
|
||||
throw new RuntimeException("3MF model has a non-numeric `{$name}` attribute.");
|
||||
}
|
||||
$float = (float) $value;
|
||||
if (! is_finite($float)) {
|
||||
throw new RuntimeException("3MF model has a non-finite `{$name}` attribute.");
|
||||
}
|
||||
|
||||
return $float;
|
||||
}
|
||||
|
||||
private function assertSafeZipEntryName(string $name): void
|
||||
{
|
||||
if ($name === ''
|
||||
|| str_contains($name, '..')
|
||||
|| str_starts_with($name, '/')
|
||||
|| str_contains($name, '\\')
|
||||
|| preg_match('/^[A-Za-z]:/', $name) === 1) {
|
||||
throw new RuntimeException('3MF archive contains an unsafe entry path.');
|
||||
}
|
||||
}
|
||||
|
||||
private function assertNotSymlinkEntry(ZipArchive $zip, int $index, string $name): void
|
||||
{
|
||||
$external = $zip->getExternalAttributesIndex($index, $opsys, $attr) ? $attr : null;
|
||||
if ($external === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$unixMode = ($external >> 16) & 0xFFFF;
|
||||
if (($unixMode & 0xF000) === 0xA000) {
|
||||
throw new RuntimeException("3MF archive contains a symlink entry: {$name}.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* A 3MF `transform` attribute is 12 space-separated finite floats describing
|
||||
* an affine map as three row basis vectors plus a translation: [Xx Xy Xz Yx
|
||||
* Yy Yz Zx Zy Zz Tx Ty Tz]. A point is mapped as
|
||||
* p' = p.x*X + p.y*Y + p.z*Z + T (row-vector convention). Composing two
|
||||
* transforms (e.g. a build item transform applied on top of a component's
|
||||
* own transform) must yield the same result as applying the inner transform
|
||||
* first and then the outer one to the resulting point.
|
||||
*
|
||||
* @phpstan-type Matrix array{0:float,1:float,2:float,3:float,4:float,5:float,6:float,7:float,8:float,9:float,10:float,11:float}
|
||||
*/
|
||||
class ThreeMfTransform
|
||||
{
|
||||
/**
|
||||
* @return Matrix
|
||||
*/
|
||||
public static function identity(): array
|
||||
{
|
||||
return [1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Matrix
|
||||
*/
|
||||
public static function parse(string $raw): array
|
||||
{
|
||||
$parts = preg_split('/\s+/', trim($raw)) ?: [];
|
||||
if (count($parts) !== 12) {
|
||||
throw new RuntimeException('3MF transform must have exactly 12 values.');
|
||||
}
|
||||
|
||||
$values = [];
|
||||
foreach ($parts as $part) {
|
||||
if (! is_numeric($part)) {
|
||||
throw new RuntimeException('3MF transform contains a non-numeric value.');
|
||||
}
|
||||
$value = (float) $part;
|
||||
if (! is_finite($value)) {
|
||||
throw new RuntimeException('3MF transform contains a non-finite value.');
|
||||
}
|
||||
$values[] = $value;
|
||||
}
|
||||
|
||||
/** @var Matrix $values */
|
||||
return $values;
|
||||
}
|
||||
|
||||
/**
|
||||
* Composes transforms so that apply(compose($outer, $inner), $p) ===
|
||||
* apply($outer, apply($inner, $p)) for every point $p.
|
||||
*
|
||||
* @param Matrix $outer
|
||||
* @param Matrix $inner
|
||||
* @return Matrix
|
||||
*/
|
||||
public static function compose(array $outer, array $inner): array
|
||||
{
|
||||
$innerX = [$inner[0], $inner[1], $inner[2]];
|
||||
$innerY = [$inner[3], $inner[4], $inner[5]];
|
||||
$innerZ = [$inner[6], $inner[7], $inner[8]];
|
||||
$innerT = [$inner[9], $inner[10], $inner[11]];
|
||||
|
||||
$newX = self::applyLinear($outer, $innerX);
|
||||
$newY = self::applyLinear($outer, $innerY);
|
||||
$newZ = self::applyLinear($outer, $innerZ);
|
||||
$newT = self::apply($outer, $innerT);
|
||||
|
||||
return [
|
||||
$newX[0], $newX[1], $newX[2],
|
||||
$newY[0], $newY[1], $newY[2],
|
||||
$newZ[0], $newZ[1], $newZ[2],
|
||||
$newT[0], $newT[1], $newT[2],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Matrix $m
|
||||
* @param array{0:float,1:float,2:float} $p
|
||||
* @return array{0:float,1:float,2:float}
|
||||
*/
|
||||
public static function apply(array $m, array $p): array
|
||||
{
|
||||
$linear = self::applyLinear($m, $p);
|
||||
|
||||
return [$linear[0] + $m[9], $linear[1] + $m[10], $linear[2] + $m[11]];
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies only the linear (basis) part of the transform, ignoring translation.
|
||||
*
|
||||
* @param Matrix $m
|
||||
* @param array{0:float,1:float,2:float} $p
|
||||
* @return array{0:float,1:float,2:float}
|
||||
*/
|
||||
private static function applyLinear(array $m, array $p): array
|
||||
{
|
||||
return [
|
||||
$p[0] * $m[0] + $p[1] * $m[3] + $p[2] * $m[6],
|
||||
$p[0] * $m[1] + $p[1] * $m[4] + $p[2] * $m[7],
|
||||
$p[0] * $m[2] + $p[1] * $m[5] + $p[2] * $m[8],
|
||||
];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user