Files
HickLab3D_Dashboard/tests/Unit/ThreeMfGeometryExtractorTest.php
T
hermes fd6a7cd2f3
Deploy HickLab3D Dashboard / Deploy to production (push) Successful in 50s
Fix inherited 3MF triangle property group resolution
2026-11-08 18:59:12 +00:00

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