From 4e675510fde27384a111f2ff0515992d18a744a7 Mon Sep 17 00:00:00 2001 From: "Odin (Hermes)" Date: Sun, 8 Nov 2026 18:49:32 +0000 Subject: [PATCH] Add project listing selector, confirmed revision and private 3MF renders --- README.md | 5 + app/Http/Controllers/ProjectController.php | 102 ++- .../ProjectCurrentRevisionController.php | 101 +++ .../ProjectFileRenderController.php | 131 ++++ .../ProjectListingLinkController.php | 7 + .../Controllers/ProjectReleaseController.php | 4 +- app/Models/Project.php | 10 +- app/Models/ProjectFile.php | 6 + app/Models/ProjectFileRender.php | 41 + app/Policies/ProjectFilePolicy.php | 9 + app/Policies/ProjectPolicy.php | 5 + app/Services/ProductImageRenderer.php | 371 +++++++++ app/Services/ThreeMfGeometryExtractor.php | 725 ++++++++++++++++++ app/Services/ThreeMfTransform.php | 109 +++ config/filesystems.php | 8 + .../factories/ProjectFileRenderFactory.php | 36 + ...add_current_revision_to_projects_table.php | 25 + ...1100_create_project_file_renders_table.php | 36 + resources/views/projects/show.blade.php | 72 +- routes/web.php | 7 + tests/Feature/ProjectCurrentRevisionTest.php | 254 ++++++ tests/Feature/ProjectFileRenderTest.php | 309 ++++++++ tests/Feature/ProjectListingLinkTest.php | 30 + tests/Feature/ProjectListingOptionsTest.php | 170 ++++ tests/Feature/ProjectReleaseTest.php | 41 + tests/Unit/ProductImageRendererTest.php | 104 +++ .../ProductImageRenderingPipelineTest.php | 184 +++++ tests/Unit/ThreeMfGeometryExtractorTest.php | 429 +++++++++++ tests/Unit/ThreeMfTransformTest.php | 77 ++ 29 files changed, 3394 insertions(+), 14 deletions(-) create mode 100644 app/Http/Controllers/ProjectCurrentRevisionController.php create mode 100644 app/Http/Controllers/ProjectFileRenderController.php create mode 100644 app/Models/ProjectFileRender.php create mode 100644 app/Services/ProductImageRenderer.php create mode 100644 app/Services/ThreeMfGeometryExtractor.php create mode 100644 app/Services/ThreeMfTransform.php create mode 100644 database/factories/ProjectFileRenderFactory.php create mode 100644 database/migrations/2026_11_08_001000_add_current_revision_to_projects_table.php create mode 100644 database/migrations/2026_11_08_001100_create_project_file_renders_table.php create mode 100644 tests/Feature/ProjectCurrentRevisionTest.php create mode 100644 tests/Feature/ProjectFileRenderTest.php create mode 100644 tests/Feature/ProjectListingOptionsTest.php create mode 100644 tests/Unit/ProductImageRendererTest.php create mode 100644 tests/Unit/ProductImageRenderingPipelineTest.php create mode 100644 tests/Unit/ThreeMfGeometryExtractorTest.php create mode 100644 tests/Unit/ThreeMfTransformTest.php diff --git a/README.md b/README.md index 088310f..6627eb0 100644 --- a/README.md +++ b/README.md @@ -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, ``/`` 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 diff --git a/app/Http/Controllers/ProjectController.php b/app/Http/Controllers/ProjectController.php index 00e6410..b435004 100644 --- a/app/Http/Controllers/ProjectController.php +++ b/app/Http/Controllers/ProjectController.php @@ -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 */ + 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>>, + * 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> + */ + 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 diff --git a/app/Http/Controllers/ProjectCurrentRevisionController.php b/app/Http/Controllers/ProjectCurrentRevisionController.php new file mode 100644 index 0000000..7a0e644 --- /dev/null +++ b/app/Http/Controllers/ProjectCurrentRevisionController.php @@ -0,0 +1,101 @@ +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.'); + } +} diff --git a/app/Http/Controllers/ProjectFileRenderController.php b/app/Http/Controllers/ProjectFileRenderController.php new file mode 100644 index 0000000..468876e --- /dev/null +++ b/app/Http/Controllers/ProjectFileRenderController.php @@ -0,0 +1,131 @@ +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); + } +} diff --git a/app/Http/Controllers/ProjectListingLinkController.php b/app/Http/Controllers/ProjectListingLinkController.php index 22cfbd5..62f4422 100644 --- a/app/Http/Controllers/ProjectListingLinkController.php +++ b/app/Http/Controllers/ProjectListingLinkController.php @@ -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(); diff --git a/app/Http/Controllers/ProjectReleaseController.php b/app/Http/Controllers/ProjectReleaseController.php index 0ddc27d..c926a2c 100644 --- a/app/Http/Controllers/ProjectReleaseController.php +++ b/app/Http/Controllers/ProjectReleaseController.php @@ -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.'); } diff --git a/app/Models/Project.php b/app/Models/Project.php index a9bfed4..6838fd1 100644 --- a/app/Models/Project.php +++ b/app/Models/Project.php @@ -16,7 +16,10 @@ class Project extends Model /** @var list */ 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); diff --git a/app/Models/ProjectFile.php b/app/Models/ProjectFile.php index 7f85ad4..202d4a6 100644 --- a/app/Models/ProjectFile.php +++ b/app/Models/ProjectFile.php @@ -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); + } } diff --git a/app/Models/ProjectFileRender.php b/app/Models/ProjectFileRender.php new file mode 100644 index 0000000..4dc3277 --- /dev/null +++ b/app/Models/ProjectFileRender.php @@ -0,0 +1,41 @@ + */ + 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'); + } +} diff --git a/app/Policies/ProjectFilePolicy.php b/app/Policies/ProjectFilePolicy.php index 684f2a5..dde3a22 100644 --- a/app/Policies/ProjectFilePolicy.php +++ b/app/Policies/ProjectFilePolicy.php @@ -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; + } } diff --git a/app/Policies/ProjectPolicy.php b/app/Policies/ProjectPolicy.php index 16caea4..85e877e 100644 --- a/app/Policies/ProjectPolicy.php +++ b/app/Policies/ProjectPolicy.php @@ -55,4 +55,9 @@ class ProjectPolicy { return $user->role === 'admin'; } + + public function confirmCurrentRevision(User $user, Project $project): bool + { + return $user->role === 'admin'; + } } diff --git a/app/Services/ProductImageRenderer.php b/app/Services/ProductImageRenderer.php new file mode 100644 index 0000000..23f2159 --- /dev/null +++ b/app/Services/ProductImageRenderer.php @@ -0,0 +1,371 @@ +gdAvailable = $gdAvailable ?? extension_loaded('gd'); + } + + /** + * @param list $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 $triangles + * @return list + */ + 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 $visible + * @return list + */ + 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 $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 $depthBuffer null = untouched background pixel */ + $depthBuffer = array_fill(0, $size * $size, null); + /** @var array $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 $depthBuffer + * @param array $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 $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 = [ + '', + sprintf('', $size, $size, $size, $size), + sprintf('', $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( + '', + $x1, $y1, $x2, $y2, $x3, $y3, $r, $g, $b, + ); + } + $parts[] = ''; + + $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.'); + } + } +} diff --git a/app/Services/ThreeMfGeometryExtractor.php b/app/Services/ThreeMfGeometryExtractor.php new file mode 100644 index 0000000..96b39a4 --- /dev/null +++ b/app/Services/ThreeMfGeometryExtractor.php @@ -0,0 +1,725 @@ + meshes, nested with their own transforms, and + * 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 or + * group. Anything this cannot safely and faithfully resolve — + * a resource, an / 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 '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 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_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 root element.'); + } + + return $document; + } finally { + libxml_clear_errors(); + libxml_set_external_entity_loader(null); + } + } + + /** + * @return array{0: array, triangles: list}, components: list}>, 1: array>} + */ + 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 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 + */ + 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 , resolved the + * same way as : each child + * is indexed by position so a pid/pindex pair can reference it + * identically regardless of which group type it came from. + * + * @return array + */ + 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, triangles: list}, components: list} + */ + 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, triangles: list} + */ + 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 + */ + 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 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, triangles: list}, components: list}> $objects + * @param array> $baseMaterials + * @param list $ancestry + * @param list $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> $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 and 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}."); + } + } +} diff --git a/app/Services/ThreeMfTransform.php b/app/Services/ThreeMfTransform.php new file mode 100644 index 0000000..66698e2 --- /dev/null +++ b/app/Services/ThreeMfTransform.php @@ -0,0 +1,109 @@ + 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'), diff --git a/database/factories/ProjectFileRenderFactory.php b/database/factories/ProjectFileRenderFactory.php new file mode 100644 index 0000000..3f32b14 --- /dev/null +++ b/database/factories/ProjectFileRenderFactory.php @@ -0,0 +1,36 @@ + + */ +class ProjectFileRenderFactory extends Factory +{ + /** + * @return array + */ + 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(), + ]; + } +} diff --git a/database/migrations/2026_11_08_001000_add_current_revision_to_projects_table.php b/database/migrations/2026_11_08_001000_add_current_revision_to_projects_table.php new file mode 100644 index 0000000..2234ead --- /dev/null +++ b/database/migrations/2026_11_08_001000_add_current_revision_to_projects_table.php @@ -0,0 +1,25 @@ +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'); + }); + } +}; diff --git a/database/migrations/2026_11_08_001100_create_project_file_renders_table.php b/database/migrations/2026_11_08_001100_create_project_file_renders_table.php new file mode 100644 index 0000000..3ebd04b --- /dev/null +++ b/database/migrations/2026_11_08_001100_create_project_file_renders_table.php @@ -0,0 +1,36 @@ +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'); + } +}; diff --git a/resources/views/projects/show.blade.php b/resources/views/projects/show.blade.php index 140e654..ad43bd9 100644 --- a/resources/views/projects/show.blade.php +++ b/resources/views/projects/show.blade.php @@ -16,25 +16,54 @@ @if($project->notes)

{{ $project->notes }}

@endif +
+ @if($project->currentRevision) +
+

Confirmed current revision: Rev {{ $project->currentRevision->revision_number }}

+
@csrf @method('DELETE')
+
+ @else +

No revision has been confirmed as current yet.

+ @endif +
+

Revisions & files

@forelse($project->revisions as $revision)
-

Revision {{ $revision->revision_number }} {{ $revision->status }}

+

Revision {{ $revision->revision_number }} {{ $revision->status }}@if($project->current_revision_id === $revision->id)Current@endif

@if(! $revision->isApproved())
@csrf
@else - Approved by {{ $revision->approver?->username }} on {{ $revision->approved_at?->format('d M Y') }} +
+ Approved by {{ $revision->approver?->username }} on {{ $revision->approved_at?->format('d M Y') }} + @if($project->current_revision_id !== $revision->id) +
@csrf
+ @endif +
@endif
@if($revision->change_notes)

{{ $revision->change_notes }}

@endif
    @foreach($revision->files as $file) -
  • - {{ $file->extension }} {{ $file->original_filename }} ({{ number_format($file->byte_size / 1024, 1) }} KB) - Download +
  • +
    + {{ $file->extension }} {{ $file->original_filename }} ({{ number_format($file->byte_size / 1024, 1) }} KB) + Download +
    + @if($file->extension === '3mf') +
    + @if($file->render) + {{ $file->render->alt_text }} + Download product image + {{ $file->render->triangle_count }} triangles · {{ $file->render->instance_count }} instance(s) · {{ strtoupper($file->render->renderer) }} + @else +
    @csrf
    + @endif +
    + @endif
  • @endforeach
@@ -57,7 +86,7 @@
-
+

Listings

@forelse($project->listingLinks as $link) @@ -96,13 +125,40 @@

No listings linked yet.

@endforelse
-
+
Link an Etsy listing + @if(! $listingOptionsConnected) +

Connect the Etsy shop on the Sales page before linking a listing.

+ @elseif($listingOptionsError) +

{{ $listingOptionsError }}

+ @else + @php($totalListingOptions = count($listingOptionsByState['active'] ?? []) + count($listingOptionsByState['draft'] ?? []))
@csrf - + @if($totalListingOptions === 0) +

No live or draft listings found in this shop.

+ @else + + @endif
+ @endif
diff --git a/routes/web.php b/routes/web.php index 3c06406..e946b54 100644 --- a/routes/web.php +++ b/routes/web.php @@ -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'); diff --git a/tests/Feature/ProjectCurrentRevisionTest.php b/tests/Feature/ProjectCurrentRevisionTest.php new file mode 100644 index 0000000..0f177f9 --- /dev/null +++ b/tests/Feature/ProjectCurrentRevisionTest.php @@ -0,0 +1,254 @@ +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
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); + } +} diff --git a/tests/Feature/ProjectFileRenderTest.php b/tests/Feature/ProjectFileRenderTest.php new file mode 100644 index 0000000..102edc7 --- /dev/null +++ b/tests/Feature/ProjectFileRenderTest.php @@ -0,0 +1,309 @@ + + + + + + + + + + + + '; + } + + 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', ''); + $zip->addFromString('3D/3dmodel.model', $this->validModelXml()); + $zip->addFromString('_rels/.rels', ''); + $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', ''); + $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); + } +} diff --git a/tests/Feature/ProjectListingLinkTest.php b/tests/Feature/ProjectListingLinkTest.php index df092eb..7abe895 100644 --- a/tests/Feature/ProjectListingLinkTest.php +++ b/tests/Feature/ProjectListingLinkTest.php @@ -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(); diff --git a/tests/Feature/ProjectListingOptionsTest.php b/tests/Feature/ProjectListingOptionsTest.php new file mode 100644 index 0000000..a13523c --- /dev/null +++ b/tests/Feature/ProjectListingOptionsTest.php @@ -0,0 +1,170 @@ +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('', false) + ->assertSee('', 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('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('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('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); + } +} diff --git a/tests/Feature/ProjectReleaseTest.php b/tests/Feature/ProjectReleaseTest.php index ba26a83..86ed4b4 100644 --- a/tests/Feature/ProjectReleaseTest.php +++ b/tests/Feature/ProjectReleaseTest.php @@ -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 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']); diff --git a/tests/Unit/ProductImageRendererTest.php b/tests/Unit/ProductImageRendererTest.php new file mode 100644 index 0000000..01e872c --- /dev/null +++ b/tests/Unit/ProductImageRendererTest.php @@ -0,0 +1,104 @@ + + */ + 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('assertStringContainsString('fill="#bb0000"', $result['bytes']); + $this->assertStringNotContainsString('assertStringNotContainsString('assertStringNotContainsString('xlink:href', $result['bytes']); + $this->assertStringNotContainsString('url(', $result['bytes']); + // Only numeric coordinates and hex colours appear inside each polygon tag. + $this->assertMatchesRegularExpression('//', $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()); + } +} diff --git a/tests/Unit/ProductImageRenderingPipelineTest.php b/tests/Unit/ProductImageRenderingPipelineTest.php new file mode 100644 index 0000000..dfb09f6 --- /dev/null +++ b/tests/Unit/ProductImageRenderingPipelineTest.php @@ -0,0 +1,184 @@ + 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 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 = '' + .''; + + $tri = fn (int $v1, int $v2, int $v3, int $colorIndex): string => sprintf( + '', $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 = '' + .'' + .'' + .'' + .'' + .'' + .'' + .'' + .'' + ."{$vertices}{$triangles}" + .''; + + $path = tempnam(sys_get_temp_dir(), 'cube').'.3mf'; + $zip = new ZipArchive; + $zip->open($path, ZipArchive::CREATE); + $zip->addFromString('[Content_Types].xml', ''); + $zip->addFromString('3D/3dmodel.model', $modelXml); + $zip->addFromString('_rels/.rels', ''); + $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); + } +} diff --git a/tests/Unit/ThreeMfGeometryExtractorTest.php b/tests/Unit/ThreeMfGeometryExtractorTest.php new file mode 100644 index 0000000..3d18b3f --- /dev/null +++ b/tests/Unit/ThreeMfGeometryExtractorTest.php @@ -0,0 +1,429 @@ +extractor = new ThreeMfGeometryExtractor; + } + + private const NS = 'http://schemas.microsoft.com/3dmanufacturing/core/2015/02'; + + private function model(string $resources, string $build): string + { + return ''.$resources.''.$build.''; + } + + private function singleTriangleObject(int $id = 1, string $attrs = ''): string + { + return ' + + + + + + + + + '; + } + + /** + * @param array $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' => '', + '3D/3dmodel.model' => $modelXml, + '_rels/.rels' => '', + ]); + } + + public function test_mirrored_build_transform_preserves_outward_triangle_winding(): void + { + $path = $this->validArchive($this->model($this->singleTriangleObject(), '')); + 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(), '')); + + $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 = '' + .$this->singleTriangleObject(1, ' pid="2" pindex="0"'); + $path = $this->validArchive($this->model($resources, '')); + + $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 = ' + + + + + + + + '; + $path = $this->validArchive($this->model($resources, '')); + + $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 = '' + .$this->singleTriangleObject(1, ' pid="5" pindex="0"'); + $path = $this->validArchive($this->model($resources, '')); + + $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"'), '')); + + 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 = '' + .$this->singleTriangleObject(1, ' pid="2" pindex="7"'); + $path = $this->validArchive($this->model($resources, '')); + + 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 = ' + + + + + + + + '; + $path = $this->validArchive($this->model($resources, '')); + + 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). + ''; + $path = $this->validArchive($this->model($resources, '')); + + $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(), + '', + )); + + $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 = ''.$this->singleTriangleObject(); + $path = $this->validArchive($this->model($resources, '')); + + 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). + ''; + $path = $this->validArchive($this->model($resources, '')); + + 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(), '')); + + 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 = '' + .''; + $path = $this->validArchive($this->model($resources, '')); + + 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(), '')); + + 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 = ''; + $path = $this->validArchive($this->model($resources, '')); + + 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('', 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 = ' + '; + $path = $this->validArchive($this->model($resources, '')); + + 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' => '']); + + 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' => '', + '3D/3dmodel.model' => $this->model($this->singleTriangleObject(), ''), + '../../../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', ''); + $zip->addFromString('3D/3dmodel.model', $this->model($this->singleTriangleObject(), '')); + $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' => '', + '3D/3dmodel.model' => $this->model($this->singleTriangleObject(), ''), + ]; + 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 = ']>&xxe;'; + $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', ''); + $zip->addFromString('3D/3dmodel.model', $this->model($this->singleTriangleObject(), '')); + $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); + } + } +} diff --git a/tests/Unit/ThreeMfTransformTest.php b/tests/Unit/ThreeMfTransformTest.php new file mode 100644 index 0000000..cc2d868 --- /dev/null +++ b/tests/Unit/ThreeMfTransformTest.php @@ -0,0 +1,77 @@ +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)); + } +}