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