430 lines
16 KiB
PHP
430 lines
16 KiB
PHP
<?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);
|
|
}
|
|
}
|
|
}
|