diff --git a/app/Services/ThreeMfGeometryExtractor.php b/app/Services/ThreeMfGeometryExtractor.php index 96b39a4..db90551 100644 --- a/app/Services/ThreeMfGeometryExtractor.php +++ b/app/Services/ThreeMfGeometryExtractor.php @@ -561,7 +561,7 @@ class ThreeMfGeometryExtractor $ancestry[] = $objectId; if ($object['mesh'] !== null) { - $defaultColor = $this->resolveColor($baseMaterials, $object['pid'], $object['pindex']); + $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]); @@ -578,9 +578,11 @@ class ThreeMfGeometryExtractor 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; + $color = $this->resolveColor( + $baseMaterials, + $triangle['pid'] ?? $object['pid'], + $this->resolveTriangleVertexIndex($triangle, $object['pindex']), + ); $triangles[] = [ 'v' => [ @@ -640,13 +642,14 @@ class ThreeMfGeometryExtractor * 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. + * rest of the declared colour data. The property group and first index + * inherit independently from object pid and pindex; omitted p2/p3 use p1. * * @param array{v1:int,v2:int,v3:int,pid:?int,p1:?int,p2:?int,p3:?int} $triangle */ - private function resolveTriangleVertexIndex(array $triangle): ?int + private function resolveTriangleVertexIndex(array $triangle, ?int $objectPindex): ?int { - $p1 = $triangle['p1']; + $p1 = $triangle['p1'] ?? $objectPindex; $p2 = $triangle['p2'] ?? $p1; $p3 = $triangle['p3'] ?? $p1; diff --git a/tests/Unit/ThreeMfGeometryExtractorTest.php b/tests/Unit/ThreeMfGeometryExtractorTest.php index 3d18b3f..060355d 100644 --- a/tests/Unit/ThreeMfGeometryExtractorTest.php +++ b/tests/Unit/ThreeMfGeometryExtractorTest.php @@ -2,7 +2,11 @@ namespace Tests\Unit; +use App\Services\ManufacturingFileValidator; +use App\Services\ProductImageRenderer; use App\Services\ThreeMfGeometryExtractor; +use Illuminate\Http\UploadedFile; +use PHPUnit\Framework\Attributes\DataProvider; use RuntimeException; use Tests\TestCase; use ZipArchive; @@ -131,6 +135,91 @@ class ThreeMfGeometryExtractorTest extends TestCase unlink($path); } + private function validatedTetrahedron(string $triangleProperties): string + { + $faces = ''; + foreach ([[0, 2, 1], [0, 1, 3], [0, 3, 2], [1, 2, 3]] as [$v1, $v2, $v3]) { + $faces .= ''; + } + $resources = '' + .'' + .'' + .'' + .'' + .''.$faces.''; + $path = $this->validArchive($this->model($resources, '')); + try { + $validated = (new ManufacturingFileValidator)->validate(new UploadedFile($path, 'tetrahedron.3mf', test: true)); + $this->assertSame('3mf', $validated['extension']); + } catch (\Throwable $exception) { + unlink($path); + throw $exception; + } + + return $path; + } + + /** @return array */ + public static function flatInheritedProperties(): array + { + return [ + 'p2 and p3 default to p1' => ['p1="1"'], + 'all vertex properties specified' => ['p1="1" p2="1" p3="1"'], + ]; + } + + #[DataProvider('flatInheritedProperties')] + public function test_inherited_triangle_property_group_applies_flat_override_through_png_rendering(string $properties): void + { + $path = $this->validatedTetrahedron($properties); + try { + $scene = $this->extractor->extract($path); + $this->assertSame(4, $scene['triangle_count']); + foreach ($scene['triangles'] as $triangle) { + $this->assertSame([0, 0, 255, 255], $triangle['color']); + } + $rendered = (new ProductImageRenderer)->render($scene['triangles']); + $this->assertSame('image/png', $rendered['mime']); + $image = imagecreatefromstring($rendered['bytes']); + $this->assertNotFalse($image); + try { + $pixel = imagecolorsforindex($image, imagecolorat($image, 256, 256)); + $this->assertSame(0, $pixel['red']); + $this->assertSame(0, $pixel['green']); + $this->assertGreaterThan(0, $pixel['blue']); + $this->assertSame(0, $pixel['alpha']); + } finally { + imagedestroy($image); + } + } finally { + unlink($path); + } + } + + public function test_inherited_triangle_property_group_rejects_interpolated_override(): void + { + $path = $this->validatedTetrahedron('p1="0" p2="1" p3="0"'); + try { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessageMatches('/interpolat/i'); + (new ProductImageRenderer)->render($this->extractor->extract($path)['triangles']); + } finally { + unlink($path); + } + } + + public function test_inherited_triangle_property_group_rejects_transparent_override(): void + { + $path = $this->validatedTetrahedron('p1="2"'); + try { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessageMatches('/transparen/i'); + (new ProductImageRenderer)->render($this->extractor->extract($path)['triangles']); + } finally { + unlink($path); + } + } + public function test_resolves_object_level_color_from_a_colorgroup(): void { $resources = ''