*/ 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()); } }