Add project listing selector, confirmed revision and private 3MF renders
This commit is contained in:
@@ -0,0 +1,104 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit;
|
||||
|
||||
use App\Services\ProductImageRenderer;
|
||||
use RuntimeException;
|
||||
use Tests\TestCase;
|
||||
|
||||
class ProductImageRendererTest extends TestCase
|
||||
{
|
||||
/**
|
||||
* @return list<array{v: array{0:array{0:float,1:float,2:float},1:array{0:float,1:float,2:float},2:array{0:float,1:float,2:float}}, color: array{0:int,1:int,2:int,3:int}}>
|
||||
*/
|
||||
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('<svg', $result['bytes']);
|
||||
// Shaded from the pure red base colour (204,0,0) by this face's normal
|
||||
// angle to the fixed light direction, not the unshaded base colour.
|
||||
$this->assertStringContainsString('fill="#bb0000"', $result['bytes']);
|
||||
$this->assertStringNotContainsString('<script', $result['bytes']);
|
||||
$this->assertStringNotContainsString('<image', $result['bytes']);
|
||||
$this->assertStringNotContainsString('xlink:href', $result['bytes']);
|
||||
$this->assertStringNotContainsString('url(', $result['bytes']);
|
||||
// Only numeric coordinates and hex colours appear inside each polygon tag.
|
||||
$this->assertMatchesRegularExpression('/<polygon points="[\d.,\s-]+" fill="#[0-9a-f]{6}"\/>/', $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());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
<?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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,429 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit;
|
||||
|
||||
use App\Services\ThreeMfGeometryExtractor;
|
||||
use RuntimeException;
|
||||
use Tests\TestCase;
|
||||
use ZipArchive;
|
||||
|
||||
class ThreeMfGeometryExtractorTest extends TestCase
|
||||
{
|
||||
private ThreeMfGeometryExtractor $extractor;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->extractor = new ThreeMfGeometryExtractor;
|
||||
}
|
||||
|
||||
private const NS = 'http://schemas.microsoft.com/3dmanufacturing/core/2015/02';
|
||||
|
||||
private function model(string $resources, string $build): string
|
||||
{
|
||||
return '<?xml version="1.0"?><model xmlns="'.self::NS.'"><resources>'.$resources.'</resources><build>'.$build.'</build></model>';
|
||||
}
|
||||
|
||||
private function singleTriangleObject(int $id = 1, string $attrs = ''): string
|
||||
{
|
||||
return '<object id="'.$id.'" type="model"'.$attrs.'>
|
||||
<mesh>
|
||||
<vertices>
|
||||
<vertex x="0" y="0" z="0"/>
|
||||
<vertex x="10" y="0" z="0"/>
|
||||
<vertex x="0" y="10" z="0"/>
|
||||
</vertices>
|
||||
<triangles><triangle v1="0" v2="1" v3="2"/></triangles>
|
||||
</mesh>
|
||||
</object>';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, string> $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' => '<?xml version="1.0"?><Types xmlns="x"/>',
|
||||
'3D/3dmodel.model' => $modelXml,
|
||||
'_rels/.rels' => '<?xml version="1.0"?><Relationships xmlns="x"><Relationship Target="/3D/3dmodel.model" Id="rel0" Type="y"/></Relationships>',
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_mirrored_build_transform_preserves_outward_triangle_winding(): void
|
||||
{
|
||||
$path = $this->validArchive($this->model($this->singleTriangleObject(), '<item objectid="1" transform="-1 0 0 0 1 0 0 0 1 0 0 0"/>'));
|
||||
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(), '<item objectid="1"/>'));
|
||||
|
||||
$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 = '<basematerials id="2"><base name="Red" displaycolor="#CC0000"/></basematerials>'
|
||||
.$this->singleTriangleObject(1, ' pid="2" pindex="0"');
|
||||
$path = $this->validArchive($this->model($resources, '<item objectid="1"/>'));
|
||||
|
||||
$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 = '<basematerials id="2"><base name="Red" displaycolor="#CC0000"/><base name="Blue" displaycolor="#0000CC"/></basematerials>
|
||||
<object id="1" type="model" pid="2" pindex="0">
|
||||
<mesh>
|
||||
<vertices>
|
||||
<vertex x="0" y="0" z="0"/><vertex x="10" y="0" z="0"/><vertex x="0" y="10" z="0"/>
|
||||
</vertices>
|
||||
<triangles><triangle v1="0" v2="1" v3="2" pid="2" p1="1"/></triangles>
|
||||
</mesh>
|
||||
</object>';
|
||||
$path = $this->validArchive($this->model($resources, '<item objectid="1"/>'));
|
||||
|
||||
$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 = '<colorgroup id="5"><color color="#00CC33"/></colorgroup>'
|
||||
.$this->singleTriangleObject(1, ' pid="5" pindex="0"');
|
||||
$path = $this->validArchive($this->model($resources, '<item objectid="1"/>'));
|
||||
|
||||
$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"'), '<item objectid="1"/>'));
|
||||
|
||||
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 = '<basematerials id="2"><base name="Red" displaycolor="#CC0000"/></basematerials>'
|
||||
.$this->singleTriangleObject(1, ' pid="2" pindex="7"');
|
||||
$path = $this->validArchive($this->model($resources, '<item objectid="1"/>'));
|
||||
|
||||
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 = '<basematerials id="2"><base name="Red" displaycolor="#CC0000"/><base name="Blue" displaycolor="#0000CC"/></basematerials>
|
||||
<object id="1" type="model">
|
||||
<mesh>
|
||||
<vertices>
|
||||
<vertex x="0" y="0" z="0"/><vertex x="10" y="0" z="0"/><vertex x="0" y="10" z="0"/>
|
||||
</vertices>
|
||||
<triangles><triangle v1="0" v2="1" v3="2" pid="2" p1="0" p2="1" p3="0"/></triangles>
|
||||
</mesh>
|
||||
</object>';
|
||||
$path = $this->validArchive($this->model($resources, '<item objectid="1"/>'));
|
||||
|
||||
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).
|
||||
'<object id="2" type="model"><components><component objectid="1" transform="1 0 0 0 1 0 0 0 1 100 0 0"/></components></object>';
|
||||
$path = $this->validArchive($this->model($resources, '<item objectid="2" transform="1 0 0 0 1 0 0 0 1 0 5 0"/>'));
|
||||
|
||||
$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(),
|
||||
'<item objectid="1"/><item objectid="1" transform="1 0 0 0 1 0 0 0 1 50 0 0"/>',
|
||||
));
|
||||
|
||||
$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 = '<texture2d id="9" path="/textures/skin.png" contenttype="image/png"/>'.$this->singleTriangleObject();
|
||||
$path = $this->validArchive($this->model($resources, '<item objectid="1"/>'));
|
||||
|
||||
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).
|
||||
'<object id="2" type="model"><components><component objectid="1" path="/other.model"/></components></object>';
|
||||
$path = $this->validArchive($this->model($resources, '<item objectid="2"/>'));
|
||||
|
||||
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(), '<item objectid="1" path="/other.model"/>'));
|
||||
|
||||
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 = '<object id="1" type="model"><components><component objectid="2"/></components></object>'
|
||||
.'<object id="2" type="model"><components><component objectid="1"/></components></object>';
|
||||
$path = $this->validArchive($this->model($resources, '<item objectid="1"/>'));
|
||||
|
||||
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(), '<item objectid="999"/>'));
|
||||
|
||||
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 = '<object id="1" type="model"></object>';
|
||||
$path = $this->validArchive($this->model($resources, '<item objectid="1"/>'));
|
||||
|
||||
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('<item objectid="1"/>', 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 = '<object id="1" type="model"><mesh><vertices><vertex x="0" y="0" z="0"/></vertices>
|
||||
<triangles><triangle v1="0" v2="1" v3="2"/></triangles></mesh></object>';
|
||||
$path = $this->validArchive($this->model($resources, '<item objectid="1"/>'));
|
||||
|
||||
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' => '<?xml version="1.0"?><Types xmlns="x"/>']);
|
||||
|
||||
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' => '<?xml version="1.0"?><Types xmlns="x"/>',
|
||||
'3D/3dmodel.model' => $this->model($this->singleTriangleObject(), '<item objectid="1"/>'),
|
||||
'../../../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', '<?xml version="1.0"?><Types xmlns="x"/>');
|
||||
$zip->addFromString('3D/3dmodel.model', $this->model($this->singleTriangleObject(), '<item objectid="1"/>'));
|
||||
$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' => '<?xml version="1.0"?><Types xmlns="x"/>',
|
||||
'3D/3dmodel.model' => $this->model($this->singleTriangleObject(), '<item objectid="1"/>'),
|
||||
];
|
||||
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 = '<?xml version="1.0"?><!DOCTYPE model [<!ENTITY xxe SYSTEM "file:///etc/passwd">]><model xmlns="'.self::NS.'">&xxe;</model>';
|
||||
$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', '<?xml version="1.0"?><Types xmlns="x"/>');
|
||||
$zip->addFromString('3D/3dmodel.model', $this->model($this->singleTriangleObject(), '<item objectid="1"/>'));
|
||||
$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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit;
|
||||
|
||||
use App\Services\ThreeMfTransform;
|
||||
use RuntimeException;
|
||||
use Tests\TestCase;
|
||||
|
||||
class ThreeMfTransformTest extends TestCase
|
||||
{
|
||||
public function test_identity_leaves_a_point_unchanged(): void
|
||||
{
|
||||
$this->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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user