Files
HickLab3D_Dashboard/tests/Unit/ProductImageRenderingPipelineTest.php
T

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);
}
}