Add project listing selector, confirmed revision and private 3MF renders
This commit is contained in:
@@ -71,6 +71,9 @@ Projects are the product aggregate: a reversible internal lifecycle (Design, Rea
|
||||
- Releasing a revision to a listing mapping requires a cost snapshot for that same revision with every category costed (filament, packaging and shipping — even as an explicit `0`, but not missing) and pins an exact revision + cost snapshot; uploading a newer revision never moves that pointer on its own, and both release and revert create a new append-only `project_releases` row under an optimistic lock (`project_listing_links.lock_version`) rather than overwriting history. A detached listing mapping can no longer be released or reverted to.
|
||||
- Cost snapshots use fixed-point integer arithmetic (`App\Support\FixedPoint` / `App\Services\ProjectCosting`), not floats: the bcmath/gmp PHP extensions are not installed in this project's runtime, so exact-decimal math is done by scaling decimal strings into bounded native integers with explicit overflow and storable-precision checks. Estimate and actual snapshots are both append-only and never overwrite each other; a missing cost category is stored as `null` ("not costed"), distinct from an intentional `0.0000`. Each category accepts exactly one quantity unit (filament `g`, packaging `unit`, shipping `parcel`) so a mistyped unit is rejected rather than silently mis-costed. Snapshot currency must match the project (no implied FX). Shipping quantities are parcels; `units_per_parcel` allocates that parcel cost across the saleable batch yield (`batch_units / units_per_parcel * parcel cost`) independently of `perUnit()`. Actual shipping needs a confirmed shipment or invoice reference. The form starts with separate filament, packaging and shipping lines and can add more materials/components; Fusion project/document/version fields on a revision are optional manual provenance only.
|
||||
- Stage can only move to Live once *every* non-detached listing mapping has an active release (not merely one of several); moving a project's internal stage never calls the Etsy API or publishes anything. Attaching a new, not-yet-released listing mapping to a project already at Live drops its stage back to Design rather than leaving Live claiming coverage it no longer has.
|
||||
- Linking an Etsy listing uses a dropdown of the shop's complete Live and Draft listings together (optgroups, title + ID + state), not a free-typed ID — fetched in full via the existing cached Etsy service, bounded to 2000 listings per state (20 pages of 100), and failing explicitly rather than silently showing a truncated list if a shop exceeds that or a page comes back short. The server always re-verifies the submitted ID's shop ownership and Live/Draft state on its own, independent of what the dropdown offered.
|
||||
- A project has one "confirmed current revision" pointer (`projects.current_revision_id`), separate from each listing mapping's own `liveRelease` pointer — it marks the project's current source of truth independent of what is released to any listing. Only an explicit confirm or clear action moves it (never an upload), under an optimistic lock (`projects.lock_version`); rolling back to an older approved revision is just re-confirming it, never mutating revision history.
|
||||
- "Generate product image" (per uploaded `.3mf` file, admin-only, explicit POST, CSRF-protected) renders a real numeric preview from that file's own mesh — never a thumbnail or a stock photo. It parses the 3MF scene graph itself (objects, nested components, build-item instances, `<basematerials>`/`<colorgroup>` colours) as its own bounded, XXE-safe security boundary, then does a real isometric projection with backface culling and per-face Lambertian shading, rasterised to PNG via GD with a genuine per-pixel depth buffer (or a fixed-content, numeric-only SVG if GD is unavailable). Unsupported input — transparent materials, texture resources, external/multi-part model references, a pid that doesn't resolve to a known colour group, an out-of-range colour index, or a triangle with genuinely interpolated per-vertex colours — is rejected with a clear error rather than silently rendered wrong. The render is cached per source file on the private `project_renders` disk (not web-served); regenerating an existing render is a no-op, concurrent requests are serialised by a project-row lock, generation never happens on a page view, and it never modifies the original upload or uploads anything to Etsy.
|
||||
|
||||
## Branding
|
||||
|
||||
@@ -85,6 +88,8 @@ A restorable backup needs **both** of the following, taken together (the private
|
||||
|
||||
Verify a restore against an isolated copy before trusting it, and spot-check a `project_files.sha256` value against the restored bytes on disk.
|
||||
|
||||
Generated product images at `storage/app/project-renders/` must also be included alongside their `project_file_renders` database rows. Although derived from source geometry, existing records are idempotent and do not automatically rebuild missing bytes; restoring only the database leaves broken previews. Keep the database, source files and generated-image disk together.
|
||||
|
||||
## Verification
|
||||
|
||||
```sh
|
||||
|
||||
@@ -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],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -46,6 +46,14 @@ return [
|
||||
'report' => false,
|
||||
],
|
||||
|
||||
'project_renders' => [
|
||||
'driver' => 'local',
|
||||
'root' => storage_path('app/project-renders'),
|
||||
'serve' => false,
|
||||
'throw' => true,
|
||||
'report' => false,
|
||||
],
|
||||
|
||||
'public' => [
|
||||
'driver' => 'local',
|
||||
'root' => storage_path('app/public'),
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\ProjectFile;
|
||||
use App\Models\ProjectFileRender;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/**
|
||||
* @extends Factory<ProjectFileRender>
|
||||
*/
|
||||
class ProjectFileRenderFactory extends Factory
|
||||
{
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'project_file_id' => ProjectFile::factory(),
|
||||
'renderer' => ProjectFileRender::RENDERER_SVG,
|
||||
'mime_type' => 'image/svg+xml',
|
||||
'storage_key' => fake()->unique()->uuid().'.svg',
|
||||
'width' => 512,
|
||||
'height' => 512,
|
||||
'triangle_count' => 2,
|
||||
'instance_count' => 1,
|
||||
'alt_text' => 'Numeric 3D render preview.',
|
||||
'byte_size' => 256,
|
||||
'sha256' => hash('sha256', fake()->unique()->uuid()),
|
||||
'generated_by' => User::factory()->create(['role' => 'admin'])->id,
|
||||
'generated_at' => now(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('projects', function (Blueprint $table): void {
|
||||
$table->foreignId('current_revision_id')->nullable()->after('stage')
|
||||
->constrained('project_revisions')->nullOnDelete();
|
||||
$table->unsignedInteger('lock_version')->default(0)->after('archived_at');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('projects', function (Blueprint $table): void {
|
||||
$table->dropConstrainedForeignId('current_revision_id');
|
||||
$table->dropColumn('lock_version');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('project_file_renders', function (Blueprint $table): void {
|
||||
$table->id();
|
||||
$table->foreignId('project_file_id')->unique()->constrained('project_files')->cascadeOnDelete();
|
||||
$table->string('renderer');
|
||||
$table->string('mime_type');
|
||||
$table->string('storage_key')->unique();
|
||||
$table->unsignedInteger('width');
|
||||
$table->unsignedInteger('height');
|
||||
$table->unsignedInteger('triangle_count');
|
||||
$table->unsignedInteger('instance_count');
|
||||
$table->text('alt_text');
|
||||
$table->unsignedBigInteger('byte_size');
|
||||
$table->string('sha256', 64);
|
||||
// Legacy users.id is signed INT, not bigint.
|
||||
$table->integer('generated_by');
|
||||
$table->foreign('generated_by')->references('id')->on('users')->restrictOnDelete();
|
||||
$table->timestamp('generated_at');
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('project_file_renders');
|
||||
}
|
||||
};
|
||||
@@ -16,25 +16,54 @@
|
||||
</div>
|
||||
@if($project->notes)<p class="rounded-2xl border border-slate-800 bg-slate-900 p-4 text-sm text-slate-300">{{ $project->notes }}</p>@endif
|
||||
|
||||
<div class="rounded-2xl border border-slate-800 bg-slate-900 p-4">
|
||||
@if($project->currentRevision)
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<p class="text-sm text-slate-300">Confirmed current revision: <span class="rounded-full bg-brand/10 px-2 py-1 text-xs font-semibold text-brand">Rev {{ $project->currentRevision->revision_number }}</span></p>
|
||||
<form method="post" action="{{ route('projects.current-revision.clear', $project) }}">@csrf @method('DELETE')<input type="hidden" name="lock_version" value="{{ $project->lock_version }}"><button class="rounded-lg border border-rose-500/40 px-3 py-2 text-xs text-rose-300">Clear current revision</button></form>
|
||||
</div>
|
||||
@else
|
||||
<p class="text-sm text-slate-400">No revision has been confirmed as current yet.</p>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<section>
|
||||
<h3 class="text-lg font-semibold">Revisions & files</h3>
|
||||
<div class="mt-3 grid gap-3">
|
||||
@forelse($project->revisions as $revision)
|
||||
<article class="rounded-2xl border border-slate-800 bg-slate-900 p-5">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<p class="font-semibold">Revision {{ $revision->revision_number }} <span class="ml-2 rounded-full bg-slate-800 px-2 py-1 text-xs capitalize">{{ $revision->status }}</span></p>
|
||||
<p class="font-semibold">Revision {{ $revision->revision_number }} <span class="ml-2 rounded-full bg-slate-800 px-2 py-1 text-xs capitalize">{{ $revision->status }}</span>@if($project->current_revision_id === $revision->id)<span class="ml-2 rounded-full bg-brand/10 px-2 py-1 text-xs text-brand">Current</span>@endif</p>
|
||||
@if(! $revision->isApproved())
|
||||
<form method="post" action="{{ route('projects.revisions.approve', [$project, $revision]) }}"><input type="hidden">@csrf<button class="rounded-lg border border-brand/40 px-3 py-2 text-xs text-brand">Approve</button></form>
|
||||
@else
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-xs text-slate-400">Approved by {{ $revision->approver?->username }} on {{ $revision->approved_at?->format('d M Y') }}</span>
|
||||
@if($project->current_revision_id !== $revision->id)
|
||||
<form method="post" action="{{ route('projects.revisions.confirm-current', [$project, $revision]) }}">@csrf<input type="hidden" name="lock_version" value="{{ $project->lock_version }}"><button class="rounded-lg border border-brand/40 px-3 py-2 text-xs text-brand">Confirm as current</button></form>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@if($revision->change_notes)<p class="mt-2 text-sm text-slate-400">{{ $revision->change_notes }}</p>@endif
|
||||
<ul class="mt-3 divide-y divide-slate-800 text-sm">
|
||||
@foreach($revision->files as $file)
|
||||
<li class="flex items-center justify-between gap-3 py-2">
|
||||
<li class="py-2">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<span><span class="rounded-full bg-slate-800 px-2 py-0.5 text-xs uppercase">{{ $file->extension }}</span> {{ $file->original_filename }} <span class="font-mono text-xs text-slate-500">({{ number_format($file->byte_size / 1024, 1) }} KB)</span></span>
|
||||
<a class="text-brand hover:underline" href="{{ route('projects.files.download', [$project, $file]) }}">Download</a>
|
||||
</div>
|
||||
@if($file->extension === '3mf')
|
||||
<div class="mt-2 flex flex-wrap items-center gap-3">
|
||||
@if($file->render)
|
||||
<img src="{{ route('projects.files.render.preview', [$project, $file]) }}" alt="{{ $file->render->alt_text }}" class="h-16 w-16 rounded-lg border border-slate-700 bg-slate-950 object-contain">
|
||||
<a class="text-xs text-brand hover:underline" href="{{ route('projects.files.render.download', [$project, $file]) }}">Download product image</a>
|
||||
<span class="text-xs text-slate-500">{{ $file->render->triangle_count }} triangles · {{ $file->render->instance_count }} instance(s) · {{ strtoupper($file->render->renderer) }}</span>
|
||||
@else
|
||||
<form method="post" action="{{ route('projects.files.render', [$project, $file]) }}">@csrf<button class="rounded-lg border border-brand/40 px-3 py-2 text-xs text-brand">Generate product image</button></form>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
@@ -57,7 +86,7 @@
|
||||
</details>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<section id="listings">
|
||||
<h3 class="text-lg font-semibold">Listings</h3>
|
||||
<div class="mt-3 grid gap-3">
|
||||
@forelse($project->listingLinks as $link)
|
||||
@@ -96,13 +125,40 @@
|
||||
<p class="text-sm text-slate-400">No listings linked yet.</p>
|
||||
@endforelse
|
||||
</div>
|
||||
<details class="mt-4 rounded-2xl border border-slate-800 bg-slate-900 p-5">
|
||||
<details class="mt-4 rounded-2xl border border-slate-800 bg-slate-900 p-5" open>
|
||||
<summary class="cursor-pointer text-sm font-semibold text-brand">Link an Etsy listing</summary>
|
||||
@if(! $listingOptionsConnected)
|
||||
<p class="mt-3 rounded-xl border border-amber-500/30 bg-amber-500/10 p-4 text-sm text-amber-200">Connect the Etsy shop on the <a class="underline" href="{{ route('etsy.index') }}">Sales page</a> before linking a listing.</p>
|
||||
@elseif($listingOptionsError)
|
||||
<p class="mt-3 rounded-xl border border-rose-500/30 bg-rose-500/10 p-4 text-sm text-rose-200">{{ $listingOptionsError }}</p>
|
||||
@else
|
||||
@php($totalListingOptions = count($listingOptionsByState['active'] ?? []) + count($listingOptionsByState['draft'] ?? []))
|
||||
<form method="post" action="{{ route('projects.listings.store', $project) }}" class="mt-4 flex flex-wrap gap-3">
|
||||
@csrf
|
||||
<input type="number" name="listing_id" placeholder="Etsy listing ID" required class="rounded-xl border border-slate-700 bg-slate-950 px-4 py-3 text-slate-100">
|
||||
@if($totalListingOptions === 0)
|
||||
<p class="text-sm text-slate-400">No live or draft listings found in this shop.</p>
|
||||
@else
|
||||
<select name="listing_id" required class="rounded-xl border border-slate-700 bg-slate-950 px-4 py-3 text-slate-100">
|
||||
<option value="">Select a listing… ({{ $totalListingOptions }} total)</option>
|
||||
@if(count($listingOptionsByState['active'] ?? []) > 0)
|
||||
<optgroup label="Live">
|
||||
@foreach($listingOptionsByState['active'] as $option)
|
||||
<option value="{{ $option['listing_id'] }}">{{ $option['title'] ?? 'Untitled listing' }} (#{{ $option['listing_id'] }}) · Live</option>
|
||||
@endforeach
|
||||
</optgroup>
|
||||
@endif
|
||||
@if(count($listingOptionsByState['draft'] ?? []) > 0)
|
||||
<optgroup label="Draft">
|
||||
@foreach($listingOptionsByState['draft'] as $option)
|
||||
<option value="{{ $option['listing_id'] }}">{{ $option['title'] ?? 'Untitled listing' }} (#{{ $option['listing_id'] }}) · Draft</option>
|
||||
@endforeach
|
||||
</optgroup>
|
||||
@endif
|
||||
</select>
|
||||
<button class="rounded-lg bg-brand px-4 py-2 text-sm font-semibold text-slate-950">Verify & link</button>
|
||||
@endif
|
||||
</form>
|
||||
@endif
|
||||
</details>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -12,7 +12,9 @@ use App\Http\Controllers\Parcel2GoConnectionController;
|
||||
use App\Http\Controllers\PrinterController;
|
||||
use App\Http\Controllers\ProjectController;
|
||||
use App\Http\Controllers\ProjectCostSnapshotController;
|
||||
use App\Http\Controllers\ProjectCurrentRevisionController;
|
||||
use App\Http\Controllers\ProjectFileController;
|
||||
use App\Http\Controllers\ProjectFileRenderController;
|
||||
use App\Http\Controllers\ProjectListingLinkController;
|
||||
use App\Http\Controllers\ProjectReleaseController;
|
||||
use App\Http\Controllers\ProjectRevisionController;
|
||||
@@ -64,7 +66,12 @@ Route::middleware(['auth', EnsureAccountActive::class])->group(function (): void
|
||||
Route::post('/projects/{project}/archive', [ProjectController::class, 'archive'])->name('projects.archive');
|
||||
Route::post('/projects/{project}/revisions', [ProjectRevisionController::class, 'store'])->name('projects.revisions.store');
|
||||
Route::post('/projects/{project}/revisions/{revision}/approve', [ProjectRevisionController::class, 'approve'])->name('projects.revisions.approve');
|
||||
Route::post('/projects/{project}/revisions/{revision}/confirm-current', [ProjectCurrentRevisionController::class, 'update'])->name('projects.revisions.confirm-current');
|
||||
Route::delete('/projects/{project}/current-revision', [ProjectCurrentRevisionController::class, 'destroy'])->name('projects.current-revision.clear');
|
||||
Route::get('/projects/{project}/files/{file}/download', [ProjectFileController::class, 'download'])->name('projects.files.download');
|
||||
Route::post('/projects/{project}/files/{file}/render', [ProjectFileRenderController::class, 'store'])->name('projects.files.render');
|
||||
Route::get('/projects/{project}/files/{file}/render/preview', [ProjectFileRenderController::class, 'preview'])->name('projects.files.render.preview');
|
||||
Route::get('/projects/{project}/files/{file}/render/download', [ProjectFileRenderController::class, 'download'])->name('projects.files.render.download');
|
||||
Route::post('/projects/{project}/listings', [ProjectListingLinkController::class, 'store'])->name('projects.listings.store');
|
||||
Route::delete('/projects/{project}/listings/{listingLink}', [ProjectListingLinkController::class, 'destroy'])->name('projects.listings.destroy');
|
||||
Route::post('/projects/{project}/cost-snapshots', [ProjectCostSnapshotController::class, 'store'])->name('projects.cost-snapshots.store');
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\Project;
|
||||
use App\Models\ProjectFile;
|
||||
use App\Models\ProjectRevision;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\LazilyRefreshDatabase;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Tests\TestCase;
|
||||
|
||||
class ProjectCurrentRevisionTest extends TestCase
|
||||
{
|
||||
use LazilyRefreshDatabase;
|
||||
|
||||
private function approvedRevisionWithFile(Project $project, int $number = 1): ProjectRevision
|
||||
{
|
||||
$revision = ProjectRevision::factory()->approved()->for($project)->create(['revision_number' => $number]);
|
||||
ProjectFile::factory()->for($revision, 'revision')->create();
|
||||
|
||||
return $revision;
|
||||
}
|
||||
|
||||
public function test_admin_can_confirm_an_approved_revision_as_current(): void
|
||||
{
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
$project = Project::factory()->create();
|
||||
$revision = $this->approvedRevisionWithFile($project);
|
||||
|
||||
$this->actingAs($admin)
|
||||
->post(route('projects.revisions.confirm-current', [$project, $revision]), ['lock_version' => 0])
|
||||
->assertRedirect(route('projects.show', $project));
|
||||
|
||||
$project->refresh();
|
||||
$this->assertSame($revision->id, $project->current_revision_id);
|
||||
$this->assertSame(1, $project->lock_version);
|
||||
$this->assertDatabaseHas('project_events', [
|
||||
'project_id' => $project->id,
|
||||
'event_type' => 'current_revision_confirmed',
|
||||
'subject_type' => ProjectRevision::class,
|
||||
'subject_id' => $revision->id,
|
||||
]);
|
||||
$event = $project->events()->where('event_type', 'current_revision_confirmed')->first();
|
||||
$this->assertSame(['previous_current_revision_id' => null, 'new_current_revision_id' => $revision->id], $event->metadata);
|
||||
}
|
||||
|
||||
public function test_rejects_an_unapproved_revision(): void
|
||||
{
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
$project = Project::factory()->create();
|
||||
$revision = ProjectRevision::factory()->for($project)->create(['revision_number' => 1]);
|
||||
ProjectFile::factory()->for($revision, 'revision')->create();
|
||||
|
||||
$this->actingAs($admin)
|
||||
->post(route('projects.revisions.confirm-current', [$project, $revision]), ['lock_version' => 0])
|
||||
->assertSessionHasErrors('revision_id');
|
||||
|
||||
$this->assertNull($project->fresh()->current_revision_id);
|
||||
}
|
||||
|
||||
public function test_rejects_an_approved_revision_with_no_files(): void
|
||||
{
|
||||
// Approving through the normal endpoint already requires at least one file,
|
||||
// but the confirm endpoint must not trust that invariant blindly.
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
$project = Project::factory()->create();
|
||||
$revision = ProjectRevision::factory()->approved()->for($project)->create(['revision_number' => 1]);
|
||||
|
||||
$this->actingAs($admin)
|
||||
->post(route('projects.revisions.confirm-current', [$project, $revision]), ['lock_version' => 0])
|
||||
->assertSessionHasErrors('revision_id');
|
||||
|
||||
$this->assertNull($project->fresh()->current_revision_id);
|
||||
}
|
||||
|
||||
public function test_rejects_a_revision_belonging_to_a_different_project(): void
|
||||
{
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
$projectA = Project::factory()->create();
|
||||
$projectB = Project::factory()->create();
|
||||
$revision = $this->approvedRevisionWithFile($projectB);
|
||||
|
||||
$this->actingAs($admin)
|
||||
->post(route('projects.revisions.confirm-current', [$projectA, $revision]), ['lock_version' => 0])
|
||||
->assertNotFound();
|
||||
|
||||
$this->assertNull($projectA->fresh()->current_revision_id);
|
||||
}
|
||||
|
||||
public function test_stale_lock_version_is_rejected_as_a_conflict(): void
|
||||
{
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
$project = Project::factory()->create();
|
||||
$revision = $this->approvedRevisionWithFile($project);
|
||||
|
||||
$response = $this->actingAs($admin)
|
||||
->post(route('projects.revisions.confirm-current', [$project, $revision]), ['lock_version' => 5]);
|
||||
|
||||
$response->assertStatus(409);
|
||||
$this->assertNull($project->fresh()->current_revision_id);
|
||||
}
|
||||
|
||||
/**
|
||||
* A real HTML <form> POST body is always strings, never native PHP ints
|
||||
* (unlike the test client's array-based helpers used elsewhere in this
|
||||
* file). The matching current lock_version must still be accepted, not
|
||||
* rejected as a false conflict by a strict `!==` comparing a DB int to a
|
||||
* form string.
|
||||
*/
|
||||
public function test_confirm_succeeds_when_lock_version_is_submitted_as_a_matching_string(): void
|
||||
{
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
$project = Project::factory()->create();
|
||||
$revision = $this->approvedRevisionWithFile($project);
|
||||
|
||||
$this->actingAs($admin)
|
||||
->post(route('projects.revisions.confirm-current', [$project, $revision]), ['lock_version' => '0'])
|
||||
->assertRedirect(route('projects.show', $project));
|
||||
|
||||
$this->assertSame($revision->id, $project->fresh()->current_revision_id);
|
||||
}
|
||||
|
||||
public function test_clear_succeeds_when_lock_version_is_submitted_as_a_matching_string(): void
|
||||
{
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
$project = Project::factory()->create();
|
||||
$revision = $this->approvedRevisionWithFile($project);
|
||||
$this->actingAs($admin)->post(route('projects.revisions.confirm-current', [$project, $revision]), ['lock_version' => 0]);
|
||||
|
||||
$this->actingAs($admin)
|
||||
->delete(route('projects.current-revision.clear', $project), ['lock_version' => '1'])
|
||||
->assertRedirect(route('projects.show', $project));
|
||||
|
||||
$this->assertNull($project->fresh()->current_revision_id);
|
||||
}
|
||||
|
||||
/**
|
||||
* A genuinely stale lock_version submitted as a string must still be
|
||||
* rejected as a conflict once both sides are compared numerically —
|
||||
* proves the fix does not accidentally accept every string as "matching".
|
||||
*/
|
||||
public function test_genuinely_stale_lock_version_submitted_as_a_string_is_still_rejected(): void
|
||||
{
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
$project = Project::factory()->create();
|
||||
$revision = $this->approvedRevisionWithFile($project);
|
||||
|
||||
$response = $this->actingAs($admin)
|
||||
->post(route('projects.revisions.confirm-current', [$project, $revision]), ['lock_version' => '5']);
|
||||
|
||||
$response->assertStatus(409);
|
||||
$this->assertNull($project->fresh()->current_revision_id);
|
||||
}
|
||||
|
||||
public function test_uploading_a_new_revision_does_not_promote_it_to_current(): void
|
||||
{
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
$project = Project::factory()->create();
|
||||
$revision = $this->approvedRevisionWithFile($project);
|
||||
$this->actingAs($admin)->post(route('projects.revisions.confirm-current', [$project, $revision]), ['lock_version' => 0]);
|
||||
|
||||
Storage::fake('project_files');
|
||||
$stl = UploadedFile::fake()->createWithContent(
|
||||
'part.stl',
|
||||
"solid cube\nfacet normal 0 0 0\nouter loop\nvertex 0 0 0\nvertex 1 0 0\nvertex 0 1 0\nendloop\nendfacet\nendsolid cube\n",
|
||||
);
|
||||
$this->actingAs($admin)->post(route('projects.revisions.store', $project), [
|
||||
'files' => [$stl], 'roles' => ['body'],
|
||||
]);
|
||||
|
||||
$this->assertSame($revision->id, $project->fresh()->current_revision_id);
|
||||
}
|
||||
|
||||
public function test_admin_can_clear_the_confirmed_current_revision(): void
|
||||
{
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
$project = Project::factory()->create();
|
||||
$revision = $this->approvedRevisionWithFile($project);
|
||||
$this->actingAs($admin)->post(route('projects.revisions.confirm-current', [$project, $revision]), ['lock_version' => 0]);
|
||||
|
||||
$this->actingAs($admin)
|
||||
->delete(route('projects.current-revision.clear', $project), ['lock_version' => 1])
|
||||
->assertRedirect(route('projects.show', $project));
|
||||
|
||||
$project->refresh();
|
||||
$this->assertNull($project->current_revision_id);
|
||||
$this->assertSame(2, $project->lock_version);
|
||||
$this->assertDatabaseHas('project_events', ['project_id' => $project->id, 'event_type' => 'current_revision_cleared']);
|
||||
}
|
||||
|
||||
public function test_an_older_revision_can_be_re_confirmed_as_a_rollback_without_mutating_history(): void
|
||||
{
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
$project = Project::factory()->create();
|
||||
$revisionOne = $this->approvedRevisionWithFile($project, 1);
|
||||
$revisionTwo = $this->approvedRevisionWithFile($project, 2);
|
||||
|
||||
$this->actingAs($admin)->post(route('projects.revisions.confirm-current', [$project, $revisionTwo]), ['lock_version' => 0]);
|
||||
$this->assertSame($revisionTwo->id, $project->fresh()->current_revision_id);
|
||||
|
||||
$this->actingAs($admin)
|
||||
->post(route('projects.revisions.confirm-current', [$project, $revisionOne]), ['lock_version' => 1])
|
||||
->assertRedirect(route('projects.show', $project));
|
||||
|
||||
$project->refresh();
|
||||
$this->assertSame($revisionOne->id, $project->current_revision_id);
|
||||
$this->assertSame(2, $project->lock_version);
|
||||
// Neither revision row is mutated by the rollback — only the pointer moves.
|
||||
$this->assertSame('approved', $revisionOne->fresh()->status);
|
||||
$this->assertSame('approved', $revisionTwo->fresh()->status);
|
||||
$event = $project->events()->where('event_type', 'current_revision_confirmed')->latest('id')->first();
|
||||
$this->assertSame(['previous_current_revision_id' => $revisionTwo->id, 'new_current_revision_id' => $revisionOne->id], $event->metadata);
|
||||
}
|
||||
|
||||
public function test_guest_cannot_confirm_or_clear_current_revision(): void
|
||||
{
|
||||
$project = Project::factory()->create();
|
||||
$revision = ProjectRevision::factory()->for($project)->create();
|
||||
|
||||
$this->post(route('projects.revisions.confirm-current', [$project, $revision]), ['lock_version' => 0])
|
||||
->assertRedirect(route('login'));
|
||||
$this->delete(route('projects.current-revision.clear', $project), ['lock_version' => 0])
|
||||
->assertRedirect(route('login'));
|
||||
}
|
||||
|
||||
public function test_non_admin_cannot_confirm_or_clear_current_revision(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$project = Project::factory()->create();
|
||||
$revision = ProjectRevision::factory()->for($project)->create();
|
||||
|
||||
$this->actingAs($user)
|
||||
->post(route('projects.revisions.confirm-current', [$project, $revision]), ['lock_version' => 0])
|
||||
->assertForbidden();
|
||||
$this->actingAs($user)
|
||||
->delete(route('projects.current-revision.clear', $project), ['lock_version' => 0])
|
||||
->assertForbidden();
|
||||
}
|
||||
|
||||
public function test_missing_csrf_token_is_rejected_when_verification_actually_runs(): void
|
||||
{
|
||||
$this->app['env'] = 'local';
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
$project = Project::factory()->create();
|
||||
$revision = $this->approvedRevisionWithFile($project);
|
||||
|
||||
$response = $this->actingAs($admin)
|
||||
->post(route('projects.revisions.confirm-current', [$project, $revision]), ['lock_version' => 0]);
|
||||
|
||||
$response->assertStatus(419);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\Project;
|
||||
use App\Models\ProjectEvent;
|
||||
use App\Models\ProjectFile;
|
||||
use App\Models\ProjectFileRender;
|
||||
use App\Models\ProjectRevision;
|
||||
use App\Models\User;
|
||||
use App\Services\ThreeMfGeometryExtractor;
|
||||
use Illuminate\Foundation\Testing\LazilyRefreshDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Tests\TestCase;
|
||||
use ZipArchive;
|
||||
|
||||
class ProjectFileRenderTest extends TestCase
|
||||
{
|
||||
use LazilyRefreshDatabase;
|
||||
|
||||
private const NS = 'http://schemas.microsoft.com/3dmanufacturing/core/2015/02';
|
||||
|
||||
private function validModelXml(): string
|
||||
{
|
||||
return '<?xml version="1.0"?><model xmlns="'.self::NS.'"><resources>
|
||||
<object id="1" type="model">
|
||||
<mesh>
|
||||
<vertices>
|
||||
<vertex x="0" y="0" z="0"/>
|
||||
<vertex x="10" y="0" z="0"/>
|
||||
<vertex x="0" y="10" z="0"/>
|
||||
</vertices>
|
||||
<triangles><triangle v1="0" v2="1" v3="2"/></triangles>
|
||||
</mesh>
|
||||
</object>
|
||||
</resources><build><item objectid="1"/></build></model>';
|
||||
}
|
||||
|
||||
private function validThreeMfBytes(): string
|
||||
{
|
||||
$path = tempnam(sys_get_temp_dir(), 'tmf').'.3mf';
|
||||
$zip = new ZipArchive;
|
||||
$zip->open($path, ZipArchive::CREATE);
|
||||
$zip->addFromString('[Content_Types].xml', '<?xml version="1.0"?><Types xmlns="x"/>');
|
||||
$zip->addFromString('3D/3dmodel.model', $this->validModelXml());
|
||||
$zip->addFromString('_rels/.rels', '<?xml version="1.0"?><Relationships xmlns="x"><Relationship Target="/3D/3dmodel.model" Id="rel0" Type="y"/></Relationships>');
|
||||
$zip->close();
|
||||
$bytes = file_get_contents($path);
|
||||
unlink($path);
|
||||
|
||||
return $bytes;
|
||||
}
|
||||
|
||||
private function maliciousThreeMfBytes(): string
|
||||
{
|
||||
$path = tempnam(sys_get_temp_dir(), 'tmf').'.3mf';
|
||||
$zip = new ZipArchive;
|
||||
$zip->open($path, ZipArchive::CREATE);
|
||||
$zip->addFromString('[Content_Types].xml', '<?xml version="1.0"?><Types xmlns="x"/>');
|
||||
$zip->addFromString('3D/3dmodel.model', $this->validModelXml());
|
||||
$zip->addFromString('bomb.bin', str_repeat("\0", 20 * 1024 * 1024));
|
||||
$zip->setCompressionName('bomb.bin', ZipArchive::CM_DEFLATE, 9);
|
||||
$zip->close();
|
||||
$bytes = file_get_contents($path);
|
||||
unlink($path);
|
||||
|
||||
return $bytes;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{0: Project, 1: ProjectFile}
|
||||
*/
|
||||
private function projectWith3mfFile(string $bytes): array
|
||||
{
|
||||
$project = Project::factory()->create();
|
||||
$revision = ProjectRevision::factory()->for($project)->create();
|
||||
$key = 'source.3mf';
|
||||
Storage::disk('project_files')->put($key, $bytes);
|
||||
$file = ProjectFile::factory()->for($revision, 'revision')->create([
|
||||
'extension' => '3mf', 'media_type' => 'model/3mf', 'storage_key' => $key, 'original_filename' => 'widget.3mf',
|
||||
]);
|
||||
|
||||
return [$project, $file];
|
||||
}
|
||||
|
||||
public function test_generation_is_inside_the_serializing_database_transaction(): void
|
||||
{
|
||||
Storage::fake('project_files');
|
||||
Storage::fake('project_renders');
|
||||
[$project, $file] = $this->projectWith3mfFile($this->validThreeMfBytes());
|
||||
$scene = (new ThreeMfGeometryExtractor)->extract(Storage::disk('project_files')->path($file->storage_key));
|
||||
$level = DB::transactionLevel();
|
||||
$this->mock(ThreeMfGeometryExtractor::class)->shouldReceive('extract')->once()->andReturnUsing(function () use ($scene, $level): array {
|
||||
$this->assertGreaterThan($level, DB::transactionLevel());
|
||||
|
||||
return $scene;
|
||||
});
|
||||
$this->actingAs(User::factory()->create(['role' => 'admin']))
|
||||
->post(route('projects.files.render', [$project, $file]))->assertRedirect();
|
||||
}
|
||||
|
||||
public function test_failed_render_storage_does_not_create_a_render_record(): void
|
||||
{
|
||||
Storage::fake('project_files');
|
||||
Storage::fake('project_renders');
|
||||
[$project, $file] = $this->projectWith3mfFile($this->validThreeMfBytes());
|
||||
$disk = \Mockery::mock(Storage::disk('project_renders'))->makePartial();
|
||||
$disk->shouldReceive('put')->once()->andReturnFalse();
|
||||
Storage::set('project_renders', $disk);
|
||||
$this->actingAs(User::factory()->create(['role' => 'admin']))
|
||||
->post(route('projects.files.render', [$project, $file]))->assertStatus(422);
|
||||
$this->assertDatabaseCount('project_file_renders', 0);
|
||||
}
|
||||
|
||||
public function test_audit_failure_rolls_back_the_record_and_cleans_up_generated_bytes(): void
|
||||
{
|
||||
Storage::fake('project_files');
|
||||
Storage::fake('project_renders');
|
||||
[$project, $file] = $this->projectWith3mfFile($this->validThreeMfBytes());
|
||||
ProjectEvent::creating(function (): void {
|
||||
throw new \RuntimeException('Synthetic audit storage failure');
|
||||
});
|
||||
try {
|
||||
$this->actingAs(User::factory()->create(['role' => 'admin']))
|
||||
->post(route('projects.files.render', [$project, $file]))->assertStatus(422);
|
||||
$this->assertDatabaseCount('project_file_renders', 0);
|
||||
$this->assertSame([], Storage::disk('project_renders')->allFiles());
|
||||
} finally {
|
||||
ProjectEvent::flushEventListeners();
|
||||
}
|
||||
}
|
||||
|
||||
public function test_admin_can_generate_a_product_image_from_a_valid_3mf_file(): void
|
||||
{
|
||||
Storage::fake('project_files');
|
||||
Storage::fake('project_renders');
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
[$project, $file] = $this->projectWith3mfFile($this->validThreeMfBytes());
|
||||
|
||||
$this->actingAs($admin)->post(route('projects.files.render', [$project, $file]))
|
||||
->assertRedirect(route('projects.show', $project));
|
||||
|
||||
$this->assertDatabaseCount('project_file_renders', 1);
|
||||
$render = ProjectFileRender::first();
|
||||
$this->assertSame($file->id, $render->project_file_id);
|
||||
$this->assertSame(1, $render->triangle_count);
|
||||
$this->assertSame(1, $render->instance_count);
|
||||
$this->assertStringContainsString('widget.3mf', $render->alt_text);
|
||||
Storage::disk('project_renders')->assertExists($render->storage_key);
|
||||
$this->assertDatabaseHas('project_events', ['project_id' => $project->id, 'event_type' => 'product_image_generated']);
|
||||
}
|
||||
|
||||
public function test_generating_does_not_modify_the_original_file(): void
|
||||
{
|
||||
Storage::fake('project_files');
|
||||
Storage::fake('project_renders');
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
$bytes = $this->validThreeMfBytes();
|
||||
[$project, $file] = $this->projectWith3mfFile($bytes);
|
||||
$before = Storage::disk('project_files')->get($file->storage_key);
|
||||
|
||||
$this->actingAs($admin)->post(route('projects.files.render', [$project, $file]));
|
||||
|
||||
$after = Storage::disk('project_files')->get($file->storage_key);
|
||||
$this->assertSame($before, $after);
|
||||
$this->assertSame(hash('sha256', $bytes), hash('sha256', $after));
|
||||
}
|
||||
|
||||
public function test_calling_generate_twice_is_idempotent_and_creates_only_one_render(): void
|
||||
{
|
||||
Storage::fake('project_files');
|
||||
Storage::fake('project_renders');
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
[$project, $file] = $this->projectWith3mfFile($this->validThreeMfBytes());
|
||||
|
||||
$this->actingAs($admin)->post(route('projects.files.render', [$project, $file]))->assertRedirect();
|
||||
$firstKey = ProjectFileRender::first()->storage_key;
|
||||
|
||||
$this->actingAs($admin)->post(route('projects.files.render', [$project, $file]))
|
||||
->assertRedirect(route('projects.show', $project))
|
||||
->assertSessionHas('status', 'Product image already generated for this file.');
|
||||
|
||||
$this->assertDatabaseCount('project_file_renders', 1);
|
||||
$this->assertSame($firstKey, ProjectFileRender::first()->storage_key);
|
||||
$this->assertDatabaseCount('project_events', 1);
|
||||
}
|
||||
|
||||
public function test_rejects_generation_for_a_non_3mf_file(): void
|
||||
{
|
||||
Storage::fake('project_files');
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
$project = Project::factory()->create();
|
||||
$revision = ProjectRevision::factory()->for($project)->create();
|
||||
$file = ProjectFile::factory()->for($revision, 'revision')->create(['extension' => 'stl']);
|
||||
|
||||
$this->actingAs($admin)->post(route('projects.files.render', [$project, $file]))
|
||||
->assertSessionHasErrors('file');
|
||||
$this->assertDatabaseCount('project_file_renders', 0);
|
||||
}
|
||||
|
||||
public function test_rejects_a_malicious_archive_with_a_clear_error_and_no_render_created(): void
|
||||
{
|
||||
Storage::fake('project_files');
|
||||
Storage::fake('project_renders');
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
[$project, $file] = $this->projectWith3mfFile($this->maliciousThreeMfBytes());
|
||||
|
||||
$this->actingAs($admin)->post(route('projects.files.render', [$project, $file]))
|
||||
->assertSessionHasErrors('file');
|
||||
|
||||
$this->assertDatabaseCount('project_file_renders', 0);
|
||||
$this->assertCount(0, Storage::disk('project_renders')->allFiles());
|
||||
}
|
||||
|
||||
public function test_guest_cannot_generate_preview_or_download(): void
|
||||
{
|
||||
Storage::fake('project_files');
|
||||
[$project, $file] = $this->projectWith3mfFile($this->validThreeMfBytes());
|
||||
|
||||
$this->post(route('projects.files.render', [$project, $file]))->assertRedirect(route('login'));
|
||||
$this->get(route('projects.files.render.preview', [$project, $file]))->assertRedirect(route('login'));
|
||||
$this->get(route('projects.files.render.download', [$project, $file]))->assertRedirect(route('login'));
|
||||
}
|
||||
|
||||
public function test_non_admin_cannot_generate_preview_or_download(): void
|
||||
{
|
||||
Storage::fake('project_files');
|
||||
$user = User::factory()->create();
|
||||
[$project, $file] = $this->projectWith3mfFile($this->validThreeMfBytes());
|
||||
|
||||
$this->actingAs($user)->post(route('projects.files.render', [$project, $file]))->assertForbidden();
|
||||
$this->actingAs($user)->get(route('projects.files.render.preview', [$project, $file]))->assertForbidden();
|
||||
$this->actingAs($user)->get(route('projects.files.render.download', [$project, $file]))->assertForbidden();
|
||||
}
|
||||
|
||||
public function test_cannot_reach_a_file_through_a_different_projects_url(): void
|
||||
{
|
||||
Storage::fake('project_files');
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
$otherProject = Project::factory()->create();
|
||||
[, $file] = $this->projectWith3mfFile($this->validThreeMfBytes());
|
||||
|
||||
$this->actingAs($admin)->post(route('projects.files.render', [$otherProject, $file]))->assertNotFound();
|
||||
$this->actingAs($admin)->get(route('projects.files.render.preview', [$otherProject, $file]))->assertNotFound();
|
||||
$this->actingAs($admin)->get(route('projects.files.render.download', [$otherProject, $file]))->assertNotFound();
|
||||
}
|
||||
|
||||
public function test_preview_and_download_404_before_a_render_exists(): void
|
||||
{
|
||||
Storage::fake('project_files');
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
[$project, $file] = $this->projectWith3mfFile($this->validThreeMfBytes());
|
||||
|
||||
$this->actingAs($admin)->get(route('projects.files.render.preview', [$project, $file]))->assertNotFound();
|
||||
$this->actingAs($admin)->get(route('projects.files.render.download', [$project, $file]))->assertNotFound();
|
||||
}
|
||||
|
||||
public function test_preview_returns_the_image_with_the_correct_content_type(): void
|
||||
{
|
||||
Storage::fake('project_files');
|
||||
Storage::fake('project_renders');
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
[$project, $file] = $this->projectWith3mfFile($this->validThreeMfBytes());
|
||||
$this->actingAs($admin)->post(route('projects.files.render', [$project, $file]));
|
||||
$render = ProjectFileRender::first();
|
||||
|
||||
$this->actingAs($admin)->get(route('projects.files.render.preview', [$project, $file]))
|
||||
->assertOk()
|
||||
->assertHeader('Content-Type', $render->mime_type);
|
||||
}
|
||||
|
||||
public function test_download_returns_an_attachment_named_after_the_original_file(): void
|
||||
{
|
||||
Storage::fake('project_files');
|
||||
Storage::fake('project_renders');
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
[$project, $file] = $this->projectWith3mfFile($this->validThreeMfBytes());
|
||||
$this->actingAs($admin)->post(route('projects.files.render', [$project, $file]));
|
||||
$render = ProjectFileRender::first();
|
||||
$extension = $render->mime_type === 'image/png' ? 'png' : 'svg';
|
||||
|
||||
$this->actingAs($admin)->get(route('projects.files.render.download', [$project, $file]))
|
||||
->assertOk()
|
||||
->assertHeader('content-disposition', 'attachment; filename='.$file->original_filename.'.'.$extension);
|
||||
}
|
||||
|
||||
public function test_viewing_the_project_page_never_triggers_rendering(): void
|
||||
{
|
||||
Storage::fake('project_files');
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
[$project, $file] = $this->projectWith3mfFile($this->validThreeMfBytes());
|
||||
|
||||
$this->actingAs($admin)->get(route('projects.show', $project))->assertOk();
|
||||
|
||||
$this->assertDatabaseCount('project_file_renders', 0);
|
||||
}
|
||||
|
||||
public function test_missing_csrf_token_is_rejected_when_verification_actually_runs(): void
|
||||
{
|
||||
$this->app['env'] = 'local';
|
||||
Storage::fake('project_files');
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
[$project, $file] = $this->projectWith3mfFile($this->validThreeMfBytes());
|
||||
|
||||
$this->actingAs($admin)->post(route('projects.files.render', [$project, $file]))
|
||||
->assertStatus(419);
|
||||
}
|
||||
}
|
||||
@@ -153,6 +153,36 @@ class ProjectListingLinkTest extends TestCase
|
||||
$this->assertDatabaseCount('project_listing_links', 0);
|
||||
}
|
||||
|
||||
public function test_rejects_a_listing_that_is_inactive_rather_than_live_or_draft(): void
|
||||
{
|
||||
Http::preventStrayRequests();
|
||||
$this->connectEtsy();
|
||||
Http::fake(['https://api.etsy.com/v3/application/listings/777*' => Http::response([
|
||||
'listing_id' => 777, 'shop_id' => 42, 'title' => 'Old item', 'state' => 'inactive',
|
||||
])]);
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
$project = Project::factory()->create();
|
||||
|
||||
$this->actingAs($admin)->post(route('projects.listings.store', $project), ['listing_id' => 777])
|
||||
->assertSessionHasErrors('listing_id');
|
||||
$this->assertDatabaseCount('project_listing_links', 0);
|
||||
}
|
||||
|
||||
public function test_rejects_a_sold_out_listing(): void
|
||||
{
|
||||
Http::preventStrayRequests();
|
||||
$this->connectEtsy();
|
||||
Http::fake(['https://api.etsy.com/v3/application/listings/778*' => Http::response([
|
||||
'listing_id' => 778, 'shop_id' => 42, 'title' => 'Sold out item', 'state' => 'sold_out',
|
||||
])]);
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
$project = Project::factory()->create();
|
||||
|
||||
$this->actingAs($admin)->post(route('projects.listings.store', $project), ['listing_id' => 778])
|
||||
->assertSessionHasErrors('listing_id');
|
||||
$this->assertDatabaseCount('project_listing_links', 0);
|
||||
}
|
||||
|
||||
public function test_non_admin_cannot_attach_listings(): void
|
||||
{
|
||||
$project = Project::factory()->create();
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\EtsyConnection;
|
||||
use App\Models\Project;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\LazilyRefreshDatabase;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Tests\TestCase;
|
||||
|
||||
class ProjectListingOptionsTest extends TestCase
|
||||
{
|
||||
use LazilyRefreshDatabase;
|
||||
|
||||
private function connectEtsy(): void
|
||||
{
|
||||
config()->set('services.etsy', ['keystring' => 'key', 'shared_secret' => 'secret', 'shop_name' => 'HickLab3D']);
|
||||
EtsyConnection::create([
|
||||
'shop_id' => 42, 'shop_name' => 'HickLab3D', 'access_token' => 'token', 'refresh_token' => 'refresh',
|
||||
'expires_at' => now()->addHour(), 'scopes' => 'listings_r listings_w transactions_r transactions_w shops_r',
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_dropdown_shows_live_and_draft_listings_together_in_optgroups(): void
|
||||
{
|
||||
Http::preventStrayRequests();
|
||||
$this->connectEtsy();
|
||||
Http::fake([
|
||||
'https://api.etsy.com/v3/application/shops/42/listings*' => Http::sequence()
|
||||
->push(['count' => 1, 'results' => [['listing_id' => 101, 'shop_id' => 42, 'title' => 'Dragon figurine', 'state' => 'active']]])
|
||||
->push(['count' => 1, 'results' => [['listing_id' => 202, 'shop_id' => 42, 'title' => 'Draft figurine', 'state' => 'draft']]]),
|
||||
]);
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
$project = Project::factory()->create();
|
||||
|
||||
$this->actingAs($admin)->get(route('projects.show', $project))->assertOk()
|
||||
->assertSee('<optgroup label="Live">', false)
|
||||
->assertSee('<optgroup label="Draft">', false)
|
||||
->assertSee('Dragon figurine (#101)', false)
|
||||
->assertSee('Draft figurine (#202)', false);
|
||||
Http::assertSentCount(2);
|
||||
}
|
||||
|
||||
public function test_duplicate_listing_pages_never_masquerade_as_a_complete_dropdown(): void
|
||||
{
|
||||
Http::preventStrayRequests();
|
||||
$this->connectEtsy();
|
||||
$first = array_map(fn (int $i) => ['listing_id' => $i, 'shop_id' => 42, 'title' => "Item {$i}", 'state' => 'active'], range(1, 100));
|
||||
Http::fake(['https://api.etsy.com/v3/application/shops/42/listings*' => Http::sequence()
|
||||
->push(['count' => 150, 'results' => $first])
|
||||
->push(['count' => 150, 'results' => array_slice($first, 50)])
|
||||
->push(['count' => 0, 'results' => []])]);
|
||||
$this->actingAs(User::factory()->create(['role' => 'admin']))
|
||||
->get(route('projects.show', Project::factory()->create()))->assertOk()
|
||||
->assertSee('temporarily unavailable or incomplete')->assertDontSee('<optgroup', false);
|
||||
}
|
||||
|
||||
public function test_fetches_every_page_of_a_state_with_over_one_hundred_listings(): void
|
||||
{
|
||||
Http::preventStrayRequests();
|
||||
$this->connectEtsy();
|
||||
$page1 = array_map(fn (int $i) => ['listing_id' => $i, 'shop_id' => 42, 'title' => "Active Item {$i}", 'state' => 'active'], range(1, 100));
|
||||
$page2 = array_map(fn (int $i) => ['listing_id' => $i, 'shop_id' => 42, 'title' => "Active Item {$i}", 'state' => 'active'], range(101, 150));
|
||||
Http::fake([
|
||||
'https://api.etsy.com/v3/application/shops/42/listings*' => Http::sequence()
|
||||
->push(['count' => 150, 'results' => $page1])
|
||||
->push(['count' => 150, 'results' => $page2])
|
||||
->push(['count' => 0, 'results' => []]),
|
||||
]);
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
$project = Project::factory()->create();
|
||||
|
||||
$this->actingAs($admin)->get(route('projects.show', $project))->assertOk()
|
||||
->assertSee('Active Item 150 (#150)', false);
|
||||
Http::assertSentCount(3);
|
||||
Http::assertSent(fn ($request) => ($request['offset'] ?? null) === 0 && str_contains($request->url(), '/listings'));
|
||||
Http::assertSent(fn ($request) => ($request['offset'] ?? null) === 100 && str_contains($request->url(), '/listings'));
|
||||
}
|
||||
|
||||
public function test_fails_explicitly_rather_than_silently_truncating_when_a_state_exceeds_the_page_bound(): void
|
||||
{
|
||||
Http::preventStrayRequests();
|
||||
$this->connectEtsy();
|
||||
// Every page claims 5000 total but only ever returns another 100 — never converges within the bound.
|
||||
$hugePage = array_map(fn (int $i) => ['listing_id' => $i, 'shop_id' => 42, 'title' => "Item {$i}", 'state' => 'active'], range(1, 100));
|
||||
Http::fake(['https://api.etsy.com/v3/application/shops/42/listings*' => Http::response(['count' => 5000, 'results' => $hugePage])]);
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
$project = Project::factory()->create();
|
||||
|
||||
$this->actingAs($admin)->get(route('projects.show', $project))->assertOk()
|
||||
->assertSee('temporarily unavailable or incomplete')
|
||||
->assertDontSee('<optgroup', false);
|
||||
// 20 pages fetched (the bound) before failing explicitly — never an unbounded fetch loop.
|
||||
Http::assertSentCount(20);
|
||||
}
|
||||
|
||||
public function test_fails_explicitly_when_a_page_comes_back_short_of_the_reported_total(): void
|
||||
{
|
||||
Http::preventStrayRequests();
|
||||
$this->connectEtsy();
|
||||
Http::fake(['https://api.etsy.com/v3/application/shops/42/listings*' => Http::response(['count' => 200, 'results' => []])]);
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
$project = Project::factory()->create();
|
||||
|
||||
$this->actingAs($admin)->get(route('projects.show', $project))->assertOk()
|
||||
->assertSee('temporarily unavailable or incomplete')
|
||||
->assertDontSee('<optgroup', false);
|
||||
}
|
||||
|
||||
public function test_zero_results_in_both_states_is_shown_as_an_explicit_empty_state_not_an_error(): void
|
||||
{
|
||||
Http::preventStrayRequests();
|
||||
$this->connectEtsy();
|
||||
Http::fake(['https://api.etsy.com/v3/application/shops/42/listings*' => Http::response(['count' => 0, 'results' => []])]);
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
$project = Project::factory()->create();
|
||||
|
||||
$this->actingAs($admin)->get(route('projects.show', $project))->assertOk()
|
||||
->assertSee('No live or draft listings found')
|
||||
->assertDontSee('temporarily unavailable');
|
||||
}
|
||||
|
||||
public function test_no_etsy_connection_shows_an_explicit_message_instead_of_an_empty_dropdown(): void
|
||||
{
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
$project = Project::factory()->create();
|
||||
|
||||
$this->actingAs($admin)->get(route('projects.show', $project))->assertOk()
|
||||
->assertSee('Connect the Etsy shop');
|
||||
}
|
||||
|
||||
public function test_show_page_never_makes_a_real_network_call(): void
|
||||
{
|
||||
Http::preventStrayRequests();
|
||||
$this->connectEtsy();
|
||||
Http::fake(['https://api.etsy.com/v3/application/shops/42/listings*' => Http::response(['count' => 0, 'results' => []])]);
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
$project = Project::factory()->create();
|
||||
|
||||
$this->actingAs($admin)->get(route('projects.show', $project))->assertOk();
|
||||
}
|
||||
|
||||
/**
|
||||
* The dropdown's rendered options are just a convenience; the server
|
||||
* must never trust that a submitted listing_id was actually one of
|
||||
* them. A tampered id the dropdown never offered still goes through the
|
||||
* same live ownership/state re-verification as any other submission.
|
||||
*/
|
||||
public function test_submitting_a_listing_id_absent_from_the_rendered_dropdown_is_still_independently_verified(): void
|
||||
{
|
||||
Http::preventStrayRequests();
|
||||
$this->connectEtsy();
|
||||
Http::fake([
|
||||
'https://api.etsy.com/v3/application/shops/42/listings*' => Http::response(['count' => 1, 'results' => [
|
||||
['listing_id' => 101, 'shop_id' => 42, 'title' => 'Dragon figurine', 'state' => 'active'],
|
||||
]]),
|
||||
'https://api.etsy.com/v3/application/listings/999*' => Http::response([
|
||||
'listing_id' => 999, 'shop_id' => 999999, 'title' => 'Not ours', 'state' => 'active',
|
||||
]),
|
||||
]);
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
$project = Project::factory()->create();
|
||||
$this->actingAs($admin)->get(route('projects.show', $project))->assertOk()->assertDontSee('#999', false);
|
||||
|
||||
$this->actingAs($admin)->post(route('projects.listings.store', $project), ['listing_id' => 999])
|
||||
->assertSessionHasErrors('listing_id');
|
||||
$this->assertDatabaseCount('project_listing_links', 0);
|
||||
}
|
||||
}
|
||||
@@ -47,6 +47,47 @@ class ProjectReleaseTest extends TestCase
|
||||
$this->assertDatabaseHas('project_releases', ['project_id' => $project->id, 'revision_id' => $revision->id, 'action' => 'release']);
|
||||
}
|
||||
|
||||
/**
|
||||
* A real HTML <form> POST body is always strings, never native PHP ints
|
||||
* (unlike the test client's array-based helpers used elsewhere in this
|
||||
* file). The matching current lock_version must still be accepted, not
|
||||
* rejected as a false conflict by a strict `!==` comparing a DB int to a
|
||||
* form string.
|
||||
*/
|
||||
public function test_release_succeeds_when_lock_version_is_submitted_as_a_matching_string(): void
|
||||
{
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
$project = Project::factory()->create();
|
||||
[$revision, $snapshot] = $this->approvedRevisionWithCost($project);
|
||||
$link = ProjectListingLink::factory()->for($project)->create(['lock_version' => 0, 'live_release_id' => null]);
|
||||
|
||||
$response = $this->actingAs($admin)->post(route('projects.listings.release', [$project, $link]), [
|
||||
'revision_id' => $revision->id, 'cost_snapshot_id' => $snapshot->id, 'lock_version' => '0',
|
||||
]);
|
||||
|
||||
$response->assertRedirect();
|
||||
$this->assertNotNull($link->fresh()->live_release_id);
|
||||
}
|
||||
|
||||
public function test_revert_succeeds_when_lock_version_is_submitted_as_a_matching_string(): void
|
||||
{
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
$project = Project::factory()->create();
|
||||
[$revision, $snapshot] = $this->approvedRevisionWithCost($project);
|
||||
$link = ProjectListingLink::factory()->for($project)->create(['lock_version' => 0, 'live_release_id' => null]);
|
||||
$this->actingAs($admin)->post(route('projects.listings.release', [$project, $link]), [
|
||||
'revision_id' => $revision->id, 'cost_snapshot_id' => $snapshot->id, 'lock_version' => 0,
|
||||
]);
|
||||
$firstReleaseId = $link->fresh()->live_release_id;
|
||||
|
||||
$response = $this->actingAs($admin)->post(route('projects.listings.revert', [$project, $link]), [
|
||||
'release_id' => $firstReleaseId, 'lock_version' => '1',
|
||||
]);
|
||||
|
||||
$response->assertRedirect();
|
||||
$this->assertSame(2, $link->fresh()->lock_version);
|
||||
}
|
||||
|
||||
public function test_uploading_v2_does_not_move_the_live_pointer(): void
|
||||
{
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit;
|
||||
|
||||
use App\Services\ProductImageRenderer;
|
||||
use RuntimeException;
|
||||
use Tests\TestCase;
|
||||
|
||||
class ProductImageRendererTest extends TestCase
|
||||
{
|
||||
/**
|
||||
* @return list<array{v: array{0:array{0:float,1:float,2:float},1:array{0:float,1:float,2:float},2:array{0:float,1:float,2:float}}, color: array{0:int,1:int,2:int,3:int}}>
|
||||
*/
|
||||
private function oneRedTriangle(): array
|
||||
{
|
||||
return [[
|
||||
'v' => [[0.0, 0.0, 0.0], [10.0, 0.0, 0.0], [0.0, 10.0, 0.0]],
|
||||
'color' => [204, 0, 0, 255],
|
||||
]];
|
||||
}
|
||||
|
||||
public function test_renders_a_real_png_when_gd_is_available(): void
|
||||
{
|
||||
$renderer = new ProductImageRenderer(gdAvailable: true);
|
||||
|
||||
$result = $renderer->render($this->oneRedTriangle());
|
||||
|
||||
$this->assertSame('image/png', $result['mime']);
|
||||
$this->assertSame('gd', $result['renderer']);
|
||||
$this->assertSame(512, $result['width']);
|
||||
$this->assertSame(512, $result['height']);
|
||||
$this->assertStringStartsWith("\x89PNG\x0d\x0a\x1a\x0a", $result['bytes']);
|
||||
|
||||
// Decode it back and confirm an actual non-background pixel exists where the
|
||||
// triangle should project — this is a real rasterisation, not a blank canvas.
|
||||
$image = imagecreatefromstring($result['bytes']);
|
||||
$this->assertNotFalse($image);
|
||||
$backgroundRgb = imagecolorat($image, 5, 5);
|
||||
// The projected triangle's centroid, computed from its known world
|
||||
// coordinates and the corrected (non-flipped) isometric projection:
|
||||
// vertices land at pixels (256,32), (480,161), (32,161).
|
||||
$insideTriangleRgb = imagecolorat($image, 256, 120);
|
||||
$this->assertNotSame($backgroundRgb, $insideTriangleRgb);
|
||||
imagedestroy($image);
|
||||
}
|
||||
|
||||
public function test_a_triangle_facing_away_from_the_camera_is_culled_and_never_drawn(): void
|
||||
{
|
||||
// Same triangle as oneRedTriangle() but with v1/v2 swapped, flipping
|
||||
// its winding so the normal points -Z (away from the (1,1,1) camera).
|
||||
$backFacing = [[
|
||||
'v' => [[0.0, 0.0, 0.0], [0.0, 10.0, 0.0], [10.0, 0.0, 0.0]],
|
||||
'color' => [204, 0, 0, 255],
|
||||
]];
|
||||
$renderer = new ProductImageRenderer(gdAvailable: true);
|
||||
|
||||
$this->expectException(RuntimeException::class);
|
||||
$this->expectExceptionMessageMatches('/faces away from the camera/i');
|
||||
$renderer->render($backFacing);
|
||||
}
|
||||
|
||||
public function test_falls_back_to_fixed_content_numeric_only_svg_when_gd_is_unavailable(): void
|
||||
{
|
||||
$renderer = new ProductImageRenderer(gdAvailable: false);
|
||||
|
||||
$result = $renderer->render($this->oneRedTriangle());
|
||||
|
||||
$this->assertSame('image/svg+xml', $result['mime']);
|
||||
$this->assertSame('svg', $result['renderer']);
|
||||
$this->assertStringContainsString('<svg', $result['bytes']);
|
||||
// Shaded from the pure red base colour (204,0,0) by this face's normal
|
||||
// angle to the fixed light direction, not the unshaded base colour.
|
||||
$this->assertStringContainsString('fill="#bb0000"', $result['bytes']);
|
||||
$this->assertStringNotContainsString('<script', $result['bytes']);
|
||||
$this->assertStringNotContainsString('<image', $result['bytes']);
|
||||
$this->assertStringNotContainsString('xlink:href', $result['bytes']);
|
||||
$this->assertStringNotContainsString('url(', $result['bytes']);
|
||||
// Only numeric coordinates and hex colours appear inside each polygon tag.
|
||||
$this->assertMatchesRegularExpression('/<polygon points="[\d.,\s-]+" fill="#[0-9a-f]{6}"\/>/', $result['bytes']);
|
||||
}
|
||||
|
||||
public function test_rejects_an_empty_scene_rather_than_rendering_a_blank_image(): void
|
||||
{
|
||||
$renderer = new ProductImageRenderer(gdAvailable: true);
|
||||
|
||||
$this->expectException(RuntimeException::class);
|
||||
$renderer->render([]);
|
||||
}
|
||||
|
||||
public function test_rejects_translucent_material_instead_of_silently_rendering_it_opaque(): void
|
||||
{
|
||||
$triangles = $this->oneRedTriangle();
|
||||
$triangles[0]['color'][3] = 128;
|
||||
$this->expectException(RuntimeException::class);
|
||||
$this->expectExceptionMessageMatches('/transparen/i');
|
||||
(new ProductImageRenderer(gdAvailable: true))->render($triangles);
|
||||
}
|
||||
|
||||
public function test_renderer_name_reflects_gd_availability(): void
|
||||
{
|
||||
$this->assertSame('gd', (new ProductImageRenderer(gdAvailable: true))->rendererName());
|
||||
$this->assertSame('svg', (new ProductImageRenderer(gdAvailable: false))->rendererName());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit;
|
||||
|
||||
use App\Services\ProductImageRenderer;
|
||||
use App\Services\ThreeMfGeometryExtractor;
|
||||
use Tests\TestCase;
|
||||
use ZipArchive;
|
||||
|
||||
/**
|
||||
* Full-pipeline regression (3MF bytes -> ThreeMfGeometryExtractor -> ProductImageRenderer
|
||||
* -> decoded PNG pixels) using a correctly-wound 20x20x20 cube with a distinct colour
|
||||
* on each of its 6 faces. This is the same cube used to generate the sample image for
|
||||
* visual inspection. Only the three faces whose outward normal points toward the
|
||||
* (1,1,1) camera (+Z "top", +X "right", +Y "back") should ever be visible; the other
|
||||
* three (-Z, -X, -Y) must be fully culled. This catches both the "upside down"
|
||||
* projection defect (an earlier version flipped Z) and the "no real hidden-surface
|
||||
* removal" defect (an earlier version only sorted by triangle centroid, which does not
|
||||
* guarantee a culled-looking, non-overlapping solid) with pixel-level assertions, not
|
||||
* just "the canvas isn't blank".
|
||||
*/
|
||||
class ProductImageRenderingPipelineTest extends TestCase
|
||||
{
|
||||
private const NS = 'http://schemas.microsoft.com/3dmanufacturing/core/2015/02';
|
||||
|
||||
// Base material indices, in <basematerials> declaration order.
|
||||
private const TOP = 0; // +Z, visible, red
|
||||
|
||||
private const RIGHT = 1; // +X, visible, green
|
||||
|
||||
private const BACK = 2; // +Y, visible, blue
|
||||
|
||||
private const BOTTOM = 3; // -Z, culled, yellow
|
||||
|
||||
private const LEFT = 4; // -X, culled, magenta
|
||||
|
||||
private const FRONT = 5; // -Y, culled, cyan
|
||||
|
||||
/**
|
||||
* A 20x20x20 cube with correct outward CCW winding (verified by hand per
|
||||
* the 3MF Core Specification's right-hand-rule convention) and one
|
||||
* base-material colour triangle-pinned per face.
|
||||
*/
|
||||
private function coloredCubeBytes(): string
|
||||
{
|
||||
$vertices = '<vertex x="0" y="0" z="0"/><vertex x="20" y="0" z="0"/><vertex x="20" y="20" z="0"/><vertex x="0" y="20" z="0"/>'
|
||||
.'<vertex x="0" y="0" z="20"/><vertex x="20" y="0" z="20"/><vertex x="20" y="20" z="20"/><vertex x="0" y="20" z="20"/>';
|
||||
|
||||
$tri = fn (int $v1, int $v2, int $v3, int $colorIndex): string => sprintf(
|
||||
'<triangle v1="%d" v2="%d" v3="%d" pid="1" p1="%d"/>', $v1, $v2, $v3, $colorIndex,
|
||||
);
|
||||
|
||||
$triangles = $tri(0, 3, 2, self::BOTTOM).$tri(0, 2, 1, self::BOTTOM)
|
||||
.$tri(4, 5, 6, self::TOP).$tri(4, 6, 7, self::TOP)
|
||||
.$tri(1, 2, 6, self::RIGHT).$tri(1, 6, 5, self::RIGHT)
|
||||
.$tri(0, 4, 7, self::LEFT).$tri(0, 7, 3, self::LEFT)
|
||||
.$tri(0, 1, 5, self::FRONT).$tri(0, 5, 4, self::FRONT)
|
||||
.$tri(2, 7, 6, self::BACK).$tri(2, 3, 7, self::BACK);
|
||||
|
||||
$modelXml = '<?xml version="1.0"?><model xmlns="'.self::NS.'"><resources>'
|
||||
.'<basematerials id="1">'
|
||||
.'<base name="top-red" displaycolor="#FF0000"/>'
|
||||
.'<base name="right-green" displaycolor="#00FF00"/>'
|
||||
.'<base name="back-blue" displaycolor="#0000FF"/>'
|
||||
.'<base name="bottom-yellow" displaycolor="#FFFF00"/>'
|
||||
.'<base name="left-magenta" displaycolor="#FF00FF"/>'
|
||||
.'<base name="front-cyan" displaycolor="#00FFFF"/>'
|
||||
.'</basematerials>'
|
||||
."<object id=\"1\" type=\"model\"><mesh><vertices>{$vertices}</vertices><triangles>{$triangles}</triangles></mesh></object>"
|
||||
.'</resources><build><item objectid="1"/></build></model>';
|
||||
|
||||
$path = tempnam(sys_get_temp_dir(), 'cube').'.3mf';
|
||||
$zip = new ZipArchive;
|
||||
$zip->open($path, ZipArchive::CREATE);
|
||||
$zip->addFromString('[Content_Types].xml', '<?xml version="1.0"?><Types xmlns="x"/>');
|
||||
$zip->addFromString('3D/3dmodel.model', $modelXml);
|
||||
$zip->addFromString('_rels/.rels', '<?xml version="1.0"?><Relationships xmlns="x"><Relationship Target="/3D/3dmodel.model" Id="rel0" Type="y"/></Relationships>');
|
||||
$zip->close();
|
||||
$bytes = file_get_contents($path);
|
||||
unlink($path);
|
||||
|
||||
return $bytes;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return \GdImage
|
||||
*/
|
||||
private function renderCube()
|
||||
{
|
||||
$path = tempnam(sys_get_temp_dir(), 'cube').'.3mf';
|
||||
file_put_contents($path, $this->coloredCubeBytes());
|
||||
$scene = (new ThreeMfGeometryExtractor)->extract($path);
|
||||
unlink($path);
|
||||
|
||||
$rendered = (new ProductImageRenderer(gdAvailable: true))->render($scene['triangles']);
|
||||
$this->assertSame(12, $scene['triangle_count']);
|
||||
|
||||
$image = imagecreatefromstring($rendered['bytes']);
|
||||
$this->assertNotFalse($image);
|
||||
|
||||
return $image;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{red: int, green: int, blue: int}
|
||||
*/
|
||||
private function rgbAt(\GdImage $image, int $x, int $y): array
|
||||
{
|
||||
return imagecolorsforindex($image, imagecolorat($image, $x, $y));
|
||||
}
|
||||
|
||||
public function test_top_face_is_visible_at_the_top_of_the_image_with_its_own_colour(): void
|
||||
{
|
||||
$image = $this->renderCube();
|
||||
|
||||
// Face centroid in screen space, derived from the cube's known world
|
||||
// coordinates and the corrected (non-flipped) isometric projection.
|
||||
$top = $this->rgbAt($image, 226, 144);
|
||||
|
||||
$this->assertGreaterThan(100, $top['red'], 'Top face should be red-shaded.');
|
||||
$this->assertLessThan(50, $top['green']);
|
||||
$this->assertLessThan(50, $top['blue']);
|
||||
imagedestroy($image);
|
||||
}
|
||||
|
||||
public function test_right_face_is_visible_with_its_own_colour(): void
|
||||
{
|
||||
$image = $this->renderCube();
|
||||
|
||||
$right = $this->rgbAt($image, 323, 312);
|
||||
|
||||
$this->assertLessThan(50, $right['red']);
|
||||
$this->assertGreaterThan(100, $right['green'], 'Right face should be green-shaded.');
|
||||
$this->assertLessThan(50, $right['blue']);
|
||||
imagedestroy($image);
|
||||
}
|
||||
|
||||
public function test_back_face_is_visible_with_its_own_colour(): void
|
||||
{
|
||||
$image = $this->renderCube();
|
||||
|
||||
$back = $this->rgbAt($image, 129, 312);
|
||||
|
||||
$this->assertLessThan(50, $back['red']);
|
||||
$this->assertLessThan(50, $back['green']);
|
||||
$this->assertGreaterThan(30, $back['blue'], 'Back face should be blue-shaded.');
|
||||
imagedestroy($image);
|
||||
}
|
||||
|
||||
public function test_the_three_hidden_interior_faces_are_never_visible_anywhere_in_the_image(): void
|
||||
{
|
||||
$image = $this->renderCube();
|
||||
|
||||
// Yellow (bottom), magenta (left) and cyan (front) each have two
|
||||
// channels simultaneously high and one low; the dark-navy background
|
||||
// (15,23,42) and the three shaded visible faces (each with exactly
|
||||
// one dominant channel) never match that signature, so any match
|
||||
// here proves a culled face leaked through.
|
||||
for ($x = 0; $x < ProductImageRenderer::CANVAS_SIZE; $x += 4) {
|
||||
for ($y = 0; $y < ProductImageRenderer::CANVAS_SIZE; $y += 4) {
|
||||
$rgb = $this->rgbAt($image, $x, $y);
|
||||
$isYellowLike = $rgb['red'] > 100 && $rgb['green'] > 100 && $rgb['blue'] < 50;
|
||||
$isMagentaLike = $rgb['red'] > 100 && $rgb['blue'] > 100 && $rgb['green'] < 50;
|
||||
$isCyanLike = $rgb['green'] > 100 && $rgb['blue'] > 100 && $rgb['red'] < 50;
|
||||
$this->assertFalse($isYellowLike, "Bottom (culled) face leaked at ({$x},{$y}).");
|
||||
$this->assertFalse($isMagentaLike, "Left (culled) face leaked at ({$x},{$y}).");
|
||||
$this->assertFalse($isCyanLike, "Front (culled) face leaked at ({$x},{$y}).");
|
||||
}
|
||||
}
|
||||
imagedestroy($image);
|
||||
}
|
||||
|
||||
public function test_background_corners_remain_background(): void
|
||||
{
|
||||
$image = $this->renderCube();
|
||||
|
||||
$corner = $this->rgbAt($image, 5, 5);
|
||||
|
||||
$this->assertSame(15, $corner['red']);
|
||||
$this->assertSame(23, $corner['green']);
|
||||
$this->assertSame(42, $corner['blue']);
|
||||
imagedestroy($image);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,429 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit;
|
||||
|
||||
use App\Services\ThreeMfGeometryExtractor;
|
||||
use RuntimeException;
|
||||
use Tests\TestCase;
|
||||
use ZipArchive;
|
||||
|
||||
class ThreeMfGeometryExtractorTest extends TestCase
|
||||
{
|
||||
private ThreeMfGeometryExtractor $extractor;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->extractor = new ThreeMfGeometryExtractor;
|
||||
}
|
||||
|
||||
private const NS = 'http://schemas.microsoft.com/3dmanufacturing/core/2015/02';
|
||||
|
||||
private function model(string $resources, string $build): string
|
||||
{
|
||||
return '<?xml version="1.0"?><model xmlns="'.self::NS.'"><resources>'.$resources.'</resources><build>'.$build.'</build></model>';
|
||||
}
|
||||
|
||||
private function singleTriangleObject(int $id = 1, string $attrs = ''): string
|
||||
{
|
||||
return '<object id="'.$id.'" type="model"'.$attrs.'>
|
||||
<mesh>
|
||||
<vertices>
|
||||
<vertex x="0" y="0" z="0"/>
|
||||
<vertex x="10" y="0" z="0"/>
|
||||
<vertex x="0" y="10" z="0"/>
|
||||
</vertices>
|
||||
<triangles><triangle v1="0" v2="1" v3="2"/></triangles>
|
||||
</mesh>
|
||||
</object>';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, string> $entries
|
||||
*/
|
||||
private function make3mf(array $entries): string
|
||||
{
|
||||
$zip = new ZipArchive;
|
||||
$path = tempnam(sys_get_temp_dir(), 'tmf').'.3mf';
|
||||
$zip->open($path, ZipArchive::CREATE);
|
||||
foreach ($entries as $name => $contents) {
|
||||
$zip->addFromString($name, $contents);
|
||||
}
|
||||
$zip->close();
|
||||
|
||||
return $path;
|
||||
}
|
||||
|
||||
private function validArchive(string $modelXml): string
|
||||
{
|
||||
return $this->make3mf([
|
||||
'[Content_Types].xml' => '<?xml version="1.0"?><Types xmlns="x"/>',
|
||||
'3D/3dmodel.model' => $modelXml,
|
||||
'_rels/.rels' => '<?xml version="1.0"?><Relationships xmlns="x"><Relationship Target="/3D/3dmodel.model" Id="rel0" Type="y"/></Relationships>',
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_mirrored_build_transform_preserves_outward_triangle_winding(): void
|
||||
{
|
||||
$path = $this->validArchive($this->model($this->singleTriangleObject(), '<item objectid="1" transform="-1 0 0 0 1 0 0 0 1 0 0 0"/>'));
|
||||
try {
|
||||
$scene = $this->extractor->extract($path);
|
||||
[$a, $b, $c] = $scene['triangles'][0]['v'];
|
||||
$normalZ = ($b[0] - $a[0]) * ($c[1] - $a[1]) - ($b[1] - $a[1]) * ($c[0] - $a[0]);
|
||||
$this->assertGreaterThan(0, $normalZ, 'Mirroring must preserve the outward +Z surface.');
|
||||
} finally {
|
||||
unlink($path);
|
||||
}
|
||||
}
|
||||
|
||||
public function test_exhausted_parse_budget_is_enforced_before_expanding_build_items(): void
|
||||
{
|
||||
$path = $this->validArchive($this->model($this->singleTriangleObject(), ''));
|
||||
try {
|
||||
$this->expectException(RuntimeException::class);
|
||||
$this->expectExceptionMessageMatches('/time budget/i');
|
||||
(new ThreeMfGeometryExtractor(timeBudgetSeconds: -1.0))->extract($path);
|
||||
} finally {
|
||||
unlink($path);
|
||||
}
|
||||
}
|
||||
|
||||
public function test_extracts_a_single_triangle_placed_by_one_build_item(): void
|
||||
{
|
||||
$path = $this->validArchive($this->model($this->singleTriangleObject(), '<item objectid="1"/>'));
|
||||
|
||||
$scene = $this->extractor->extract($path);
|
||||
|
||||
$this->assertSame(1, $scene['triangle_count']);
|
||||
$this->assertSame(1, $scene['instance_count']);
|
||||
$this->assertSame([148, 163, 184, 255], $scene['triangles'][0]['color']);
|
||||
unlink($path);
|
||||
}
|
||||
|
||||
public function test_resolves_object_level_base_material_color(): void
|
||||
{
|
||||
$resources = '<basematerials id="2"><base name="Red" displaycolor="#CC0000"/></basematerials>'
|
||||
.$this->singleTriangleObject(1, ' pid="2" pindex="0"');
|
||||
$path = $this->validArchive($this->model($resources, '<item objectid="1"/>'));
|
||||
|
||||
$scene = $this->extractor->extract($path);
|
||||
|
||||
$this->assertSame([204, 0, 0, 255], $scene['triangles'][0]['color']);
|
||||
unlink($path);
|
||||
}
|
||||
|
||||
public function test_triangle_level_pid_overrides_the_objects_default_color(): void
|
||||
{
|
||||
$resources = '<basematerials id="2"><base name="Red" displaycolor="#CC0000"/><base name="Blue" displaycolor="#0000CC"/></basematerials>
|
||||
<object id="1" type="model" pid="2" pindex="0">
|
||||
<mesh>
|
||||
<vertices>
|
||||
<vertex x="0" y="0" z="0"/><vertex x="10" y="0" z="0"/><vertex x="0" y="10" z="0"/>
|
||||
</vertices>
|
||||
<triangles><triangle v1="0" v2="1" v3="2" pid="2" p1="1"/></triangles>
|
||||
</mesh>
|
||||
</object>';
|
||||
$path = $this->validArchive($this->model($resources, '<item objectid="1"/>'));
|
||||
|
||||
$scene = $this->extractor->extract($path);
|
||||
|
||||
$this->assertSame([0, 0, 204, 255], $scene['triangles'][0]['color']);
|
||||
unlink($path);
|
||||
}
|
||||
|
||||
public function test_resolves_object_level_color_from_a_colorgroup(): void
|
||||
{
|
||||
$resources = '<colorgroup id="5"><color color="#00CC33"/></colorgroup>'
|
||||
.$this->singleTriangleObject(1, ' pid="5" pindex="0"');
|
||||
$path = $this->validArchive($this->model($resources, '<item objectid="1"/>'));
|
||||
|
||||
$scene = $this->extractor->extract($path);
|
||||
|
||||
$this->assertSame([0, 204, 51, 255], $scene['triangles'][0]['color']);
|
||||
unlink($path);
|
||||
}
|
||||
|
||||
public function test_rejects_a_pid_referencing_an_unknown_property_group_rather_than_defaulting_to_grey(): void
|
||||
{
|
||||
$path = $this->validArchive($this->model($this->singleTriangleObject(1, ' pid="999" pindex="0"'), '<item objectid="1"/>'));
|
||||
|
||||
try {
|
||||
$this->expectException(RuntimeException::class);
|
||||
$this->extractor->extract($path);
|
||||
} finally {
|
||||
unlink($path);
|
||||
}
|
||||
}
|
||||
|
||||
public function test_rejects_a_pindex_that_does_not_exist_within_a_known_property_group(): void
|
||||
{
|
||||
$resources = '<basematerials id="2"><base name="Red" displaycolor="#CC0000"/></basematerials>'
|
||||
.$this->singleTriangleObject(1, ' pid="2" pindex="7"');
|
||||
$path = $this->validArchive($this->model($resources, '<item objectid="1"/>'));
|
||||
|
||||
try {
|
||||
$this->expectException(RuntimeException::class);
|
||||
$this->extractor->extract($path);
|
||||
} finally {
|
||||
unlink($path);
|
||||
}
|
||||
}
|
||||
|
||||
public function test_rejects_a_triangle_with_interpolated_per_vertex_colors(): void
|
||||
{
|
||||
$resources = '<basematerials id="2"><base name="Red" displaycolor="#CC0000"/><base name="Blue" displaycolor="#0000CC"/></basematerials>
|
||||
<object id="1" type="model">
|
||||
<mesh>
|
||||
<vertices>
|
||||
<vertex x="0" y="0" z="0"/><vertex x="10" y="0" z="0"/><vertex x="0" y="10" z="0"/>
|
||||
</vertices>
|
||||
<triangles><triangle v1="0" v2="1" v3="2" pid="2" p1="0" p2="1" p3="0"/></triangles>
|
||||
</mesh>
|
||||
</object>';
|
||||
$path = $this->validArchive($this->model($resources, '<item objectid="1"/>'));
|
||||
|
||||
try {
|
||||
$this->expectException(RuntimeException::class);
|
||||
$this->expectExceptionMessageMatches('/interpolat/i');
|
||||
$this->extractor->extract($path);
|
||||
} finally {
|
||||
unlink($path);
|
||||
}
|
||||
}
|
||||
|
||||
public function test_resolves_nested_components_composing_transforms(): void
|
||||
{
|
||||
$resources = $this->singleTriangleObject(1).
|
||||
'<object id="2" type="model"><components><component objectid="1" transform="1 0 0 0 1 0 0 0 1 100 0 0"/></components></object>';
|
||||
$path = $this->validArchive($this->model($resources, '<item objectid="2" transform="1 0 0 0 1 0 0 0 1 0 5 0"/>'));
|
||||
|
||||
$scene = $this->extractor->extract($path);
|
||||
|
||||
$this->assertSame(1, $scene['triangle_count']);
|
||||
// World vertex 0 (local 0,0,0) shifted by the component's +100 X then the item's +5 Y.
|
||||
$this->assertSame([100.0, 5.0, 0.0], $scene['triangles'][0]['v'][0]);
|
||||
unlink($path);
|
||||
}
|
||||
|
||||
public function test_multiple_build_items_count_as_multiple_instances(): void
|
||||
{
|
||||
$path = $this->validArchive($this->model(
|
||||
$this->singleTriangleObject(),
|
||||
'<item objectid="1"/><item objectid="1" transform="1 0 0 0 1 0 0 0 1 50 0 0"/>',
|
||||
));
|
||||
|
||||
$scene = $this->extractor->extract($path);
|
||||
|
||||
$this->assertSame(2, $scene['triangle_count']);
|
||||
$this->assertSame(2, $scene['instance_count']);
|
||||
unlink($path);
|
||||
}
|
||||
|
||||
public function test_rejects_texture_resources_as_unsupported(): void
|
||||
{
|
||||
$resources = '<texture2d id="9" path="/textures/skin.png" contenttype="image/png"/>'.$this->singleTriangleObject();
|
||||
$path = $this->validArchive($this->model($resources, '<item objectid="1"/>'));
|
||||
|
||||
try {
|
||||
$this->expectException(RuntimeException::class);
|
||||
$this->expectExceptionMessageMatches('/texture/i');
|
||||
$this->extractor->extract($path);
|
||||
} finally {
|
||||
unlink($path);
|
||||
}
|
||||
}
|
||||
|
||||
public function test_rejects_a_component_referencing_an_external_model_part(): void
|
||||
{
|
||||
$resources = $this->singleTriangleObject(1).
|
||||
'<object id="2" type="model"><components><component objectid="1" path="/other.model"/></components></object>';
|
||||
$path = $this->validArchive($this->model($resources, '<item objectid="2"/>'));
|
||||
|
||||
try {
|
||||
$this->expectException(RuntimeException::class);
|
||||
$this->expectExceptionMessageMatches('/external/i');
|
||||
$this->extractor->extract($path);
|
||||
} finally {
|
||||
unlink($path);
|
||||
}
|
||||
}
|
||||
|
||||
public function test_rejects_a_build_item_referencing_an_external_model_part(): void
|
||||
{
|
||||
$path = $this->validArchive($this->model($this->singleTriangleObject(), '<item objectid="1" path="/other.model"/>'));
|
||||
|
||||
try {
|
||||
$this->expectException(RuntimeException::class);
|
||||
$this->expectExceptionMessageMatches('/external/i');
|
||||
$this->extractor->extract($path);
|
||||
} finally {
|
||||
unlink($path);
|
||||
}
|
||||
}
|
||||
|
||||
public function test_rejects_a_circular_component_reference(): void
|
||||
{
|
||||
$resources = '<object id="1" type="model"><components><component objectid="2"/></components></object>'
|
||||
.'<object id="2" type="model"><components><component objectid="1"/></components></object>';
|
||||
$path = $this->validArchive($this->model($resources, '<item objectid="1"/>'));
|
||||
|
||||
try {
|
||||
$this->expectException(RuntimeException::class);
|
||||
$this->expectExceptionMessageMatches('/circular/i');
|
||||
$this->extractor->extract($path);
|
||||
} finally {
|
||||
unlink($path);
|
||||
}
|
||||
}
|
||||
|
||||
public function test_rejects_a_build_item_referencing_a_nonexistent_object(): void
|
||||
{
|
||||
$path = $this->validArchive($this->model($this->singleTriangleObject(), '<item objectid="999"/>'));
|
||||
|
||||
try {
|
||||
$this->expectException(RuntimeException::class);
|
||||
$this->extractor->extract($path);
|
||||
} finally {
|
||||
unlink($path);
|
||||
}
|
||||
}
|
||||
|
||||
public function test_rejects_an_empty_build_plate_as_no_renderable_geometry(): void
|
||||
{
|
||||
$resources = '<object id="1" type="model"></object>';
|
||||
$path = $this->validArchive($this->model($resources, '<item objectid="1"/>'));
|
||||
|
||||
try {
|
||||
$this->expectException(RuntimeException::class);
|
||||
$this->expectExceptionMessageMatches('/no renderable geometry/i');
|
||||
$this->extractor->extract($path);
|
||||
} finally {
|
||||
unlink($path);
|
||||
}
|
||||
}
|
||||
|
||||
public function test_rejects_too_many_build_items(): void
|
||||
{
|
||||
$items = str_repeat('<item objectid="1"/>', 201);
|
||||
$path = $this->validArchive($this->model($this->singleTriangleObject(), $items));
|
||||
|
||||
try {
|
||||
$this->expectException(RuntimeException::class);
|
||||
$this->extractor->extract($path);
|
||||
} finally {
|
||||
unlink($path);
|
||||
}
|
||||
}
|
||||
|
||||
public function test_rejects_a_triangle_referencing_a_vertex_index_that_does_not_exist(): void
|
||||
{
|
||||
$resources = '<object id="1" type="model"><mesh><vertices><vertex x="0" y="0" z="0"/></vertices>
|
||||
<triangles><triangle v1="0" v2="1" v3="2"/></triangles></mesh></object>';
|
||||
$path = $this->validArchive($this->model($resources, '<item objectid="1"/>'));
|
||||
|
||||
try {
|
||||
$this->expectException(RuntimeException::class);
|
||||
$this->extractor->extract($path);
|
||||
} finally {
|
||||
unlink($path);
|
||||
}
|
||||
}
|
||||
|
||||
public function test_rejects_3mf_missing_required_model_part(): void
|
||||
{
|
||||
$path = $this->make3mf(['[Content_Types].xml' => '<?xml version="1.0"?><Types xmlns="x"/>']);
|
||||
|
||||
try {
|
||||
$this->expectException(RuntimeException::class);
|
||||
$this->extractor->extract($path);
|
||||
} finally {
|
||||
unlink($path);
|
||||
}
|
||||
}
|
||||
|
||||
public function test_rejects_3mf_with_path_traversal_entry(): void
|
||||
{
|
||||
$path = $this->make3mf([
|
||||
'[Content_Types].xml' => '<?xml version="1.0"?><Types xmlns="x"/>',
|
||||
'3D/3dmodel.model' => $this->model($this->singleTriangleObject(), '<item objectid="1"/>'),
|
||||
'../../../etc/evil.txt' => 'pwned',
|
||||
]);
|
||||
|
||||
try {
|
||||
$this->expectException(RuntimeException::class);
|
||||
$this->extractor->extract($path);
|
||||
} finally {
|
||||
unlink($path);
|
||||
}
|
||||
}
|
||||
|
||||
public function test_rejects_3mf_entry_with_extreme_compression_ratio(): void
|
||||
{
|
||||
$zip = new ZipArchive;
|
||||
$path = tempnam(sys_get_temp_dir(), 'tmf').'.3mf';
|
||||
$zip->open($path, ZipArchive::CREATE);
|
||||
$zip->addFromString('[Content_Types].xml', '<?xml version="1.0"?><Types xmlns="x"/>');
|
||||
$zip->addFromString('3D/3dmodel.model', $this->model($this->singleTriangleObject(), '<item objectid="1"/>'));
|
||||
$zip->addFromString('bomb.bin', str_repeat("\0", 20 * 1024 * 1024));
|
||||
$zip->setCompressionName('bomb.bin', ZipArchive::CM_DEFLATE, 9);
|
||||
$zip->close();
|
||||
|
||||
try {
|
||||
$this->expectException(RuntimeException::class);
|
||||
$this->extractor->extract($path);
|
||||
} finally {
|
||||
unlink($path);
|
||||
}
|
||||
}
|
||||
|
||||
public function test_rejects_3mf_with_too_many_entries(): void
|
||||
{
|
||||
$entries = [
|
||||
'[Content_Types].xml' => '<?xml version="1.0"?><Types xmlns="x"/>',
|
||||
'3D/3dmodel.model' => $this->model($this->singleTriangleObject(), '<item objectid="1"/>'),
|
||||
];
|
||||
for ($i = 0; $i < 2500; $i++) {
|
||||
$entries["junk/{$i}.txt"] = 'x';
|
||||
}
|
||||
$path = $this->make3mf($entries);
|
||||
|
||||
try {
|
||||
$this->expectException(RuntimeException::class);
|
||||
$this->extractor->extract($path);
|
||||
} finally {
|
||||
unlink($path);
|
||||
}
|
||||
}
|
||||
|
||||
public function test_rejects_model_part_with_doctype_declaration(): void
|
||||
{
|
||||
$modelXml = '<?xml version="1.0"?><!DOCTYPE model [<!ENTITY xxe SYSTEM "file:///etc/passwd">]><model xmlns="'.self::NS.'">&xxe;</model>';
|
||||
$path = $this->validArchive($modelXml);
|
||||
|
||||
try {
|
||||
$this->expectException(RuntimeException::class);
|
||||
$this->extractor->extract($path);
|
||||
} finally {
|
||||
unlink($path);
|
||||
}
|
||||
}
|
||||
|
||||
public function test_rejects_3mf_with_a_symlink_entry(): void
|
||||
{
|
||||
$zip = new ZipArchive;
|
||||
$path = tempnam(sys_get_temp_dir(), 'tmf').'.3mf';
|
||||
$zip->open($path, ZipArchive::CREATE);
|
||||
$zip->addFromString('[Content_Types].xml', '<?xml version="1.0"?><Types xmlns="x"/>');
|
||||
$zip->addFromString('3D/3dmodel.model', $this->model($this->singleTriangleObject(), '<item objectid="1"/>'));
|
||||
$zip->addFromString('evil-link', '/etc/passwd');
|
||||
$zip->setExternalAttributesName('evil-link', ZipArchive::OPSYS_UNIX, 0120777 << 16);
|
||||
$zip->close();
|
||||
|
||||
try {
|
||||
$this->expectException(RuntimeException::class);
|
||||
$this->extractor->extract($path);
|
||||
} finally {
|
||||
unlink($path);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit;
|
||||
|
||||
use App\Services\ThreeMfTransform;
|
||||
use RuntimeException;
|
||||
use Tests\TestCase;
|
||||
|
||||
class ThreeMfTransformTest extends TestCase
|
||||
{
|
||||
public function test_identity_leaves_a_point_unchanged(): void
|
||||
{
|
||||
$this->assertSame([1.0, 2.0, 3.0], ThreeMfTransform::apply(ThreeMfTransform::identity(), [1.0, 2.0, 3.0]));
|
||||
}
|
||||
|
||||
public function test_parses_twelve_space_separated_floats(): void
|
||||
{
|
||||
$matrix = ThreeMfTransform::parse('1 0 0 0 1 0 0 0 1 10 20 30');
|
||||
|
||||
$this->assertSame([10.0, 20.0, 33.0], ThreeMfTransform::apply($matrix, [0.0, 0.0, 3.0]));
|
||||
}
|
||||
|
||||
public function test_rejects_a_transform_without_exactly_twelve_values(): void
|
||||
{
|
||||
$this->expectException(RuntimeException::class);
|
||||
ThreeMfTransform::parse('1 0 0 0 1 0 0 0 1');
|
||||
}
|
||||
|
||||
public function test_rejects_a_non_numeric_transform_value(): void
|
||||
{
|
||||
$this->expectException(RuntimeException::class);
|
||||
ThreeMfTransform::parse('1 0 0 0 1 0 0 0 1 x 0 0');
|
||||
}
|
||||
|
||||
public function test_rejects_a_non_finite_transform_value(): void
|
||||
{
|
||||
$this->expectException(RuntimeException::class);
|
||||
ThreeMfTransform::parse('1 0 0 0 1 0 0 0 1 NAN 0 0');
|
||||
}
|
||||
|
||||
public function test_translation_moves_a_point(): void
|
||||
{
|
||||
$translate = ThreeMfTransform::parse('1 0 0 0 1 0 0 0 1 5 6 7');
|
||||
|
||||
$this->assertSame([6.0, 8.0, 10.0], ThreeMfTransform::apply($translate, [1.0, 2.0, 3.0]));
|
||||
}
|
||||
|
||||
public function test_scale_multiplies_each_axis(): void
|
||||
{
|
||||
$scale = ThreeMfTransform::parse('2 0 0 0 3 0 0 0 4 0 0 0');
|
||||
|
||||
$this->assertSame([2.0, 6.0, 12.0], ThreeMfTransform::apply($scale, [1.0, 2.0, 3.0]));
|
||||
}
|
||||
|
||||
public function test_compose_applies_inner_transform_before_outer(): void
|
||||
{
|
||||
$translateThenScale = ThreeMfTransform::compose(
|
||||
outer: ThreeMfTransform::parse('2 0 0 0 2 0 0 0 2 0 0 0'),
|
||||
inner: ThreeMfTransform::parse('1 0 0 0 1 0 0 0 1 10 0 0'),
|
||||
);
|
||||
|
||||
// Expected: apply(outer, apply(inner, p)) = apply(outer, [11,0,0]) = [22,0,0]
|
||||
$this->assertSame([22.0, 0.0, 0.0], ThreeMfTransform::apply($translateThenScale, [1.0, 0.0, 0.0]));
|
||||
}
|
||||
|
||||
public function test_compose_matches_applying_each_transform_in_turn_for_an_arbitrary_rotation_like_matrix(): void
|
||||
{
|
||||
$outer = ThreeMfTransform::parse('0 1 0 -1 0 0 0 0 1 3 4 5');
|
||||
$inner = ThreeMfTransform::parse('1 0 0 0 1 0 0 0 1 1 2 3');
|
||||
$point = [7.0, -2.0, 0.5];
|
||||
|
||||
$composed = ThreeMfTransform::compose($outer, $inner);
|
||||
|
||||
$expected = ThreeMfTransform::apply($outer, ThreeMfTransform::apply($inner, $point));
|
||||
$this->assertSame($expected, ThreeMfTransform::apply($composed, $point));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user