185 lines
7.6 KiB
PHP
185 lines
7.6 KiB
PHP
<?php
|
|
|
|
namespace Tests\Unit;
|
|
|
|
use App\Services\ProductImageRenderer;
|
|
use App\Services\ThreeMfGeometryExtractor;
|
|
use Tests\TestCase;
|
|
use ZipArchive;
|
|
|
|
/**
|
|
* Full-pipeline regression (3MF bytes -> ThreeMfGeometryExtractor -> ProductImageRenderer
|
|
* -> decoded PNG pixels) using a correctly-wound 20x20x20 cube with a distinct colour
|
|
* on each of its 6 faces. This is the same cube used to generate the sample image for
|
|
* visual inspection. Only the three faces whose outward normal points toward the
|
|
* (1,1,1) camera (+Z "top", +X "right", +Y "back") should ever be visible; the other
|
|
* three (-Z, -X, -Y) must be fully culled. This catches both the "upside down"
|
|
* projection defect (an earlier version flipped Z) and the "no real hidden-surface
|
|
* removal" defect (an earlier version only sorted by triangle centroid, which does not
|
|
* guarantee a culled-looking, non-overlapping solid) with pixel-level assertions, not
|
|
* just "the canvas isn't blank".
|
|
*/
|
|
class ProductImageRenderingPipelineTest extends TestCase
|
|
{
|
|
private const NS = 'http://schemas.microsoft.com/3dmanufacturing/core/2015/02';
|
|
|
|
// Base material indices, in <basematerials> declaration order.
|
|
private const TOP = 0; // +Z, visible, red
|
|
|
|
private const RIGHT = 1; // +X, visible, green
|
|
|
|
private const BACK = 2; // +Y, visible, blue
|
|
|
|
private const BOTTOM = 3; // -Z, culled, yellow
|
|
|
|
private const LEFT = 4; // -X, culled, magenta
|
|
|
|
private const FRONT = 5; // -Y, culled, cyan
|
|
|
|
/**
|
|
* A 20x20x20 cube with correct outward CCW winding (verified by hand per
|
|
* the 3MF Core Specification's right-hand-rule convention) and one
|
|
* base-material colour triangle-pinned per face.
|
|
*/
|
|
private function coloredCubeBytes(): string
|
|
{
|
|
$vertices = '<vertex x="0" y="0" z="0"/><vertex x="20" y="0" z="0"/><vertex x="20" y="20" z="0"/><vertex x="0" y="20" z="0"/>'
|
|
.'<vertex x="0" y="0" z="20"/><vertex x="20" y="0" z="20"/><vertex x="20" y="20" z="20"/><vertex x="0" y="20" z="20"/>';
|
|
|
|
$tri = fn (int $v1, int $v2, int $v3, int $colorIndex): string => sprintf(
|
|
'<triangle v1="%d" v2="%d" v3="%d" pid="1" p1="%d"/>', $v1, $v2, $v3, $colorIndex,
|
|
);
|
|
|
|
$triangles = $tri(0, 3, 2, self::BOTTOM).$tri(0, 2, 1, self::BOTTOM)
|
|
.$tri(4, 5, 6, self::TOP).$tri(4, 6, 7, self::TOP)
|
|
.$tri(1, 2, 6, self::RIGHT).$tri(1, 6, 5, self::RIGHT)
|
|
.$tri(0, 4, 7, self::LEFT).$tri(0, 7, 3, self::LEFT)
|
|
.$tri(0, 1, 5, self::FRONT).$tri(0, 5, 4, self::FRONT)
|
|
.$tri(2, 7, 6, self::BACK).$tri(2, 3, 7, self::BACK);
|
|
|
|
$modelXml = '<?xml version="1.0"?><model xmlns="'.self::NS.'"><resources>'
|
|
.'<basematerials id="1">'
|
|
.'<base name="top-red" displaycolor="#FF0000"/>'
|
|
.'<base name="right-green" displaycolor="#00FF00"/>'
|
|
.'<base name="back-blue" displaycolor="#0000FF"/>'
|
|
.'<base name="bottom-yellow" displaycolor="#FFFF00"/>'
|
|
.'<base name="left-magenta" displaycolor="#FF00FF"/>'
|
|
.'<base name="front-cyan" displaycolor="#00FFFF"/>'
|
|
.'</basematerials>'
|
|
."<object id=\"1\" type=\"model\"><mesh><vertices>{$vertices}</vertices><triangles>{$triangles}</triangles></mesh></object>"
|
|
.'</resources><build><item objectid="1"/></build></model>';
|
|
|
|
$path = tempnam(sys_get_temp_dir(), 'cube').'.3mf';
|
|
$zip = new ZipArchive;
|
|
$zip->open($path, ZipArchive::CREATE);
|
|
$zip->addFromString('[Content_Types].xml', '<?xml version="1.0"?><Types xmlns="x"/>');
|
|
$zip->addFromString('3D/3dmodel.model', $modelXml);
|
|
$zip->addFromString('_rels/.rels', '<?xml version="1.0"?><Relationships xmlns="x"><Relationship Target="/3D/3dmodel.model" Id="rel0" Type="y"/></Relationships>');
|
|
$zip->close();
|
|
$bytes = file_get_contents($path);
|
|
unlink($path);
|
|
|
|
return $bytes;
|
|
}
|
|
|
|
/**
|
|
* @return \GdImage
|
|
*/
|
|
private function renderCube()
|
|
{
|
|
$path = tempnam(sys_get_temp_dir(), 'cube').'.3mf';
|
|
file_put_contents($path, $this->coloredCubeBytes());
|
|
$scene = (new ThreeMfGeometryExtractor)->extract($path);
|
|
unlink($path);
|
|
|
|
$rendered = (new ProductImageRenderer(gdAvailable: true))->render($scene['triangles']);
|
|
$this->assertSame(12, $scene['triangle_count']);
|
|
|
|
$image = imagecreatefromstring($rendered['bytes']);
|
|
$this->assertNotFalse($image);
|
|
|
|
return $image;
|
|
}
|
|
|
|
/**
|
|
* @return array{red: int, green: int, blue: int}
|
|
*/
|
|
private function rgbAt(\GdImage $image, int $x, int $y): array
|
|
{
|
|
return imagecolorsforindex($image, imagecolorat($image, $x, $y));
|
|
}
|
|
|
|
public function test_top_face_is_visible_at_the_top_of_the_image_with_its_own_colour(): void
|
|
{
|
|
$image = $this->renderCube();
|
|
|
|
// Face centroid in screen space, derived from the cube's known world
|
|
// coordinates and the corrected (non-flipped) isometric projection.
|
|
$top = $this->rgbAt($image, 226, 144);
|
|
|
|
$this->assertGreaterThan(100, $top['red'], 'Top face should be red-shaded.');
|
|
$this->assertLessThan(50, $top['green']);
|
|
$this->assertLessThan(50, $top['blue']);
|
|
imagedestroy($image);
|
|
}
|
|
|
|
public function test_right_face_is_visible_with_its_own_colour(): void
|
|
{
|
|
$image = $this->renderCube();
|
|
|
|
$right = $this->rgbAt($image, 323, 312);
|
|
|
|
$this->assertLessThan(50, $right['red']);
|
|
$this->assertGreaterThan(100, $right['green'], 'Right face should be green-shaded.');
|
|
$this->assertLessThan(50, $right['blue']);
|
|
imagedestroy($image);
|
|
}
|
|
|
|
public function test_back_face_is_visible_with_its_own_colour(): void
|
|
{
|
|
$image = $this->renderCube();
|
|
|
|
$back = $this->rgbAt($image, 129, 312);
|
|
|
|
$this->assertLessThan(50, $back['red']);
|
|
$this->assertLessThan(50, $back['green']);
|
|
$this->assertGreaterThan(30, $back['blue'], 'Back face should be blue-shaded.');
|
|
imagedestroy($image);
|
|
}
|
|
|
|
public function test_the_three_hidden_interior_faces_are_never_visible_anywhere_in_the_image(): void
|
|
{
|
|
$image = $this->renderCube();
|
|
|
|
// Yellow (bottom), magenta (left) and cyan (front) each have two
|
|
// channels simultaneously high and one low; the dark-navy background
|
|
// (15,23,42) and the three shaded visible faces (each with exactly
|
|
// one dominant channel) never match that signature, so any match
|
|
// here proves a culled face leaked through.
|
|
for ($x = 0; $x < ProductImageRenderer::CANVAS_SIZE; $x += 4) {
|
|
for ($y = 0; $y < ProductImageRenderer::CANVAS_SIZE; $y += 4) {
|
|
$rgb = $this->rgbAt($image, $x, $y);
|
|
$isYellowLike = $rgb['red'] > 100 && $rgb['green'] > 100 && $rgb['blue'] < 50;
|
|
$isMagentaLike = $rgb['red'] > 100 && $rgb['blue'] > 100 && $rgb['green'] < 50;
|
|
$isCyanLike = $rgb['green'] > 100 && $rgb['blue'] > 100 && $rgb['red'] < 50;
|
|
$this->assertFalse($isYellowLike, "Bottom (culled) face leaked at ({$x},{$y}).");
|
|
$this->assertFalse($isMagentaLike, "Left (culled) face leaked at ({$x},{$y}).");
|
|
$this->assertFalse($isCyanLike, "Front (culled) face leaked at ({$x},{$y}).");
|
|
}
|
|
}
|
|
imagedestroy($image);
|
|
}
|
|
|
|
public function test_background_corners_remain_background(): void
|
|
{
|
|
$image = $this->renderCube();
|
|
|
|
$corner = $this->rgbAt($image, 5, 5);
|
|
|
|
$this->assertSame(15, $corner['red']);
|
|
$this->assertSame(23, $corner['green']);
|
|
$this->assertSame(42, $corner['blue']);
|
|
imagedestroy($image);
|
|
}
|
|
}
|