extractor = new ThreeMfGeometryExtractor; } private const NS = 'http://schemas.microsoft.com/3dmanufacturing/core/2015/02'; private function model(string $resources, string $build): string { return ''.$resources.''.$build.''; } private function singleTriangleObject(int $id = 1, string $attrs = ''): string { return ' '; } /** * @param array $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' => '', '3D/3dmodel.model' => $modelXml, '_rels/.rels' => '', ]); } public function test_mirrored_build_transform_preserves_outward_triangle_winding(): void { $path = $this->validArchive($this->model($this->singleTriangleObject(), '')); 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(), '')); $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 = '' .$this->singleTriangleObject(1, ' pid="2" pindex="0"'); $path = $this->validArchive($this->model($resources, '')); $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 = ' '; $path = $this->validArchive($this->model($resources, '')); $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 = '' .$this->singleTriangleObject(1, ' pid="5" pindex="0"'); $path = $this->validArchive($this->model($resources, '')); $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"'), '')); 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 = '' .$this->singleTriangleObject(1, ' pid="2" pindex="7"'); $path = $this->validArchive($this->model($resources, '')); 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 = ' '; $path = $this->validArchive($this->model($resources, '')); 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). ''; $path = $this->validArchive($this->model($resources, '')); $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(), '', )); $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 = ''.$this->singleTriangleObject(); $path = $this->validArchive($this->model($resources, '')); 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). ''; $path = $this->validArchive($this->model($resources, '')); 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(), '')); 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 = '' .''; $path = $this->validArchive($this->model($resources, '')); 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(), '')); 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 = ''; $path = $this->validArchive($this->model($resources, '')); 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('', 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 = ' '; $path = $this->validArchive($this->model($resources, '')); 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' => '']); 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' => '', '3D/3dmodel.model' => $this->model($this->singleTriangleObject(), ''), '../../../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', ''); $zip->addFromString('3D/3dmodel.model', $this->model($this->singleTriangleObject(), '')); $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' => '', '3D/3dmodel.model' => $this->model($this->singleTriangleObject(), ''), ]; 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 = ']>&xxe;'; $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', ''); $zip->addFromString('3D/3dmodel.model', $this->model($this->singleTriangleObject(), '')); $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); } } }