519 lines
20 KiB
PHP
519 lines
20 KiB
PHP
<?php
|
|
|
|
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;
|
|
|
|
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);
|
|
}
|
|
|
|
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 .= '<triangle v1="'.$v1.'" v2="'.$v2.'" v3="'.$v3.'" '.$triangleProperties.'/>';
|
|
}
|
|
$resources = '<m:colorgroup xmlns:m="http://schemas.microsoft.com/3dmanufacturing/material/2015/02" id="2">'
|
|
.'<m:color color="#FF0000"/><m:color color="#0000FF"/><m:color color="#00FF0080"/></m:colorgroup>'
|
|
.'<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"/><vertex x="0" y="0" z="10"/>'
|
|
.'</vertices><triangles>'.$faces.'</triangles></mesh></object>';
|
|
$path = $this->validArchive($this->model($resources, '<item objectid="1"/>'));
|
|
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<string, array{string}> */
|
|
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 = '<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);
|
|
}
|
|
}
|
|
}
|