meshes, nested with their own transforms, and * items placing one or more instances of an object on the plate — * composing 3MF affine transforms via ThreeMfTransform. Flat colours are * resolved where an object or triangle references a or * group. Anything this cannot safely and faithfully resolve — * a resource, an / referencing another * package part via a `path` attribute, a pid referencing an unrecognised or * nonexistent property group, an out-of-range pindex, or a triangle with * genuinely interpolated (differing p1/p2/p3) per-vertex colours — is * rejected with a clear message rather than silently rendered wrong (e.g. * defaulting to grey). * * Geometry is bounded on every axis that drives time or memory: entry/part * byte budgets, object count, vertices/triangles per mesh, an aggregate cap * on total vertices/triangles across every object (independent of the * per-mesh cap, closing the "500 objects x 100k vertices each" worst case), * build item count, component nesting depth (with cycle detection), and the * total number of triangles resolved after component/instance expansion — * checked incrementally during expansion, not only at the end. A single * wall-clock parse budget (injectable via the constructor for deterministic * tests) is checked throughout every bounded-read and parsing loop — zip * entry iteration, chunked entry reads, resource/mesh/build-item iteration, * and scene-graph expansion — not only inside the final expansion step; the * DOMDocument parse itself cannot be preempted mid-call, so it is bounded by * the byte caps feeding it and checked immediately before and after. * * @phpstan-type Point array{0:float,1:float,2:float} * @phpstan-type Color array{0:int,1:int,2:int,3:int} * @phpstan-type Triangle array{v: array{0:Point,1:Point,2:Point}, color: Color} */ class ThreeMfGeometryExtractor { private const MAX_ENTRIES = 2000; private const MAX_TOTAL_UNCOMPRESSED = 128 * 1024 * 1024; private const MAX_RATIO = 100; private const MAX_MODEL_PART_BYTES = 64 * 1024 * 1024; private const MAX_TOTAL_PARSED_BYTES = 128 * 1024 * 1024; private const MAX_OBJECTS = 500; private const MAX_VERTICES_PER_MESH = 100_000; private const MAX_TRIANGLES_PER_MESH = 100_000; private const MAX_BUILD_ITEMS = 200; private const MAX_COMPONENT_DEPTH = 8; private const MAX_TOTAL_RESOLVED_TRIANGLES = 150_000; /** Aggregate cap across every 's parsed mesh, independent of the per-mesh cap — closes the "500 objects x 100k vertices each" worst case. */ private const MAX_TOTAL_PARSED_VERTICES = 500_000; private const MAX_TOTAL_PARSED_TRIANGLES = 500_000; private const PARSE_TIME_BUDGET_SECONDS = 5.0; private const MODEL_NAMESPACE_PREFIX = 'http://schemas.microsoft.com/3dmanufacturing/core/'; /** @var array default grey used when no base material resolves */ private const DEFAULT_COLOR = [148, 163, 184, 255]; private readonly float $timeBudgetSeconds; private float $deadline = 0.0; private int $totalParsedVertices = 0; private int $totalParsedTriangles = 0; /** * @param ?float $timeBudgetSeconds Injectable for tests so a deadline-exceeded path can * be exercised deterministically (an already-tiny or already-past budget) rather than * with a real sleep. Defaults to PARSE_TIME_BUDGET_SECONDS in production. */ public function __construct(?float $timeBudgetSeconds = null) { $this->timeBudgetSeconds = $timeBudgetSeconds ?? self::PARSE_TIME_BUDGET_SECONDS; } /** * @return array{triangles: list, triangle_count: int, instance_count: int} */ public function extract(string $path): array { $this->deadline = microtime(true) + $this->timeBudgetSeconds; $this->totalParsedVertices = 0; $this->totalParsedTriangles = 0; $zip = new ZipArchive; if ($zip->open($path) !== true) { throw new RuntimeException('Unable to read 3MF archive.'); } try { $modelXml = $this->readModelPart($zip); $this->assertWithinDeadline(); $document = $this->parseSafeXml($modelXml); $this->assertWithinDeadline(); [$objects, $baseMaterials] = $this->parseResources($document); $buildItems = $this->parseBuildItems($document); $triangles = []; foreach ($buildItems as $item) { $this->resolveObjectInstance( $item['objectid'], $item['transform'], $objects, $baseMaterials, [], 0, $triangles, ); } if ($triangles === []) { throw new RuntimeException('3MF model contains no renderable geometry.'); } return [ 'triangles' => $triangles, 'triangle_count' => count($triangles), 'instance_count' => count($buildItems), ]; } finally { $zip->close(); } } private function readModelPart(ZipArchive $zip): string { $count = $zip->count(); if ($count > self::MAX_ENTRIES) { throw new RuntimeException('3MF archive has too many entries.'); } $totalUncompressed = 0; $seenNames = []; $hasModelPart = false; for ($i = 0; $i < $count; $i++) { $this->assertWithinDeadline(); $stat = $zip->statIndex($i); if ($stat === false) { throw new RuntimeException('Unable to read 3MF archive entry.'); } $name = (string) $stat['name']; if (isset($seenNames[$name])) { throw new RuntimeException("3MF archive contains a duplicate entry path: {$name}."); } $seenNames[$name] = true; $this->assertSafeZipEntryName($name); $this->assertNotSymlinkEntry($zip, $i, $name); if (($stat['encryption_method'] ?? ZipArchive::EM_NONE) !== ZipArchive::EM_NONE) { throw new RuntimeException("3MF archive contains an encrypted entry: {$name}."); } $uncompressed = (int) $stat['size']; $compressed = (int) $stat['comp_size']; $totalUncompressed += $uncompressed; if ($compressed > 0 && ($uncompressed / $compressed) > self::MAX_RATIO) { throw new RuntimeException('3MF entry has a suspicious compression ratio.'); } if ($compressed === 0 && $uncompressed > 10 * 1024 * 1024) { throw new RuntimeException('3MF entry has a suspicious compression ratio.'); } if ($name === '3D/3dmodel.model') { $hasModelPart = true; } } if ($totalUncompressed > self::MAX_TOTAL_UNCOMPRESSED) { throw new RuntimeException('3MF archive exceeds the maximum uncompressed size.'); } if (! $hasModelPart) { throw new RuntimeException('3MF archive is missing the required 3D model part.'); } $budget = self::MAX_TOTAL_PARSED_BYTES; return $this->readBoundedZipEntry($zip, '3D/3dmodel.model', self::MAX_MODEL_PART_BYTES, $budget); } private function readBoundedZipEntry(ZipArchive $zip, string $name, int $perEntryCap, int &$remainingBudget): string { $stream = $zip->getStream($name); if ($stream === false) { throw new RuntimeException("Unable to read 3MF archive entry: {$name}."); } $buffer = ''; $total = 0; try { while (! feof($stream)) { $this->assertWithinDeadline(); $chunk = fread($stream, 65536); if ($chunk === false) { throw new RuntimeException("Unable to read 3MF archive entry: {$name}."); } $total += strlen($chunk); if ($total > $perEntryCap) { throw new RuntimeException("3MF archive entry exceeds its size cap: {$name}."); } $remainingBudget -= strlen($chunk); if ($remainingBudget < 0) { throw new RuntimeException('3MF archive exceeds the total parse budget.'); } $buffer .= $chunk; } } finally { fclose($stream); } return $buffer; } private function parseSafeXml(string $xml): DOMDocument { libxml_use_internal_errors(true); libxml_clear_errors(); libxml_set_external_entity_loader(static fn (): mixed => null); try { $document = new DOMDocument; $loaded = $document->loadXML($xml, LIBXML_NONET | LIBXML_NOERROR | LIBXML_NOWARNING); if ($loaded === false || $document->documentElement === null) { throw new RuntimeException('3MF model part is not well-formed XML.'); } if ($document->doctype !== null) { throw new RuntimeException('3MF model part contains a disallowed DOCTYPE declaration.'); } $root = $document->documentElement; if ($root->localName !== 'model' || ! str_starts_with((string) $root->namespaceURI, self::MODEL_NAMESPACE_PREFIX)) { throw new RuntimeException('3MF model part does not have a valid root element.'); } return $document; } finally { libxml_clear_errors(); libxml_set_external_entity_loader(null); } } /** * @return array{0: array, triangles: list}, components: list}>, 1: array>} */ private function parseResources(DOMDocument $document): array { $resources = null; foreach ($document->documentElement->childNodes as $child) { if ($child instanceof DOMElement && $child->localName === 'resources') { $resources = $child; break; } } if ($resources === null) { throw new RuntimeException('3MF model part is missing a element.'); } $objects = []; $baseMaterials = []; $objectCount = 0; foreach ($resources->childNodes as $node) { $this->assertWithinDeadline(); if (! $node instanceof DOMElement) { continue; } if (stripos($node->localName, 'texture') !== false) { throw new RuntimeException('3MF model declares texture resources, which are not supported by this renderer.'); } if ($node->localName === 'basematerials') { $id = $this->intAttribute($node, 'id'); $baseMaterials[$id] = $this->parseBaseMaterials($node); continue; } if ($node->localName === 'colorgroup') { $id = $this->intAttribute($node, 'id'); $baseMaterials[$id] = $this->parseColorGroup($node); continue; } if ($node->localName === 'object') { $objectCount++; if ($objectCount > self::MAX_OBJECTS) { throw new RuntimeException('3MF model declares too many objects.'); } $id = $this->intAttribute($node, 'id'); $objects[$id] = $this->parseObject($node); } } return [$objects, $baseMaterials]; } /** * @return array */ private function parseBaseMaterials(DOMElement $node): array { $colors = []; $index = 0; foreach ($node->childNodes as $child) { $this->assertWithinDeadline(); if (! $child instanceof DOMElement || $child->localName !== 'base') { continue; } $hex = $child->getAttribute('displaycolor'); $color = $this->parseHexColor($hex); if ($color !== null) { $colors[$index] = $color; } $index++; } return $colors; } /** * The Materials and Properties extension's , resolved the * same way as : each child * is indexed by position so a pid/pindex pair can reference it * identically regardless of which group type it came from. * * @return array */ private function parseColorGroup(DOMElement $node): array { $colors = []; $index = 0; foreach ($node->childNodes as $child) { $this->assertWithinDeadline(); if (! $child instanceof DOMElement || $child->localName !== 'color') { continue; } $color = $this->parseHexColor($child->getAttribute('color')); if ($color !== null) { $colors[$index] = $color; } $index++; } return $colors; } /** * @return Color|null */ private function parseHexColor(string $hex): ?array { $hex = ltrim($hex, '#'); if (! preg_match('/^[0-9A-Fa-f]{6}([0-9A-Fa-f]{2})?$/', $hex)) { return null; } $r = hexdec(substr($hex, 0, 2)); $g = hexdec(substr($hex, 2, 2)); $b = hexdec(substr($hex, 4, 2)); $a = strlen($hex) === 8 ? hexdec(substr($hex, 6, 2)) : 255; return [$r, $g, $b, $a]; } /** * @return array{type: string, pid: ?int, pindex: ?int, mesh: ?array{vertices: list, triangles: list}, components: list} */ private function parseObject(DOMElement $object): array { $type = $object->getAttribute('type') ?: 'model'; $pid = $object->hasAttribute('pid') ? $this->intAttribute($object, 'pid') : null; $pindex = $object->hasAttribute('pindex') ? $this->intAttribute($object, 'pindex') : null; $mesh = null; $components = []; foreach ($object->childNodes as $child) { if (! $child instanceof DOMElement) { continue; } if ($child->localName === 'mesh') { $mesh = $this->parseMesh($child); } elseif ($child->localName === 'components') { foreach ($child->childNodes as $componentNode) { $this->assertWithinDeadline(); if (! $componentNode instanceof DOMElement || $componentNode->localName !== 'component') { continue; } if ($this->hasPathAttribute($componentNode)) { throw new RuntimeException('3MF model references an external model part, which is not supported.'); } $components[] = [ 'objectid' => $this->intAttribute($componentNode, 'objectid'), 'transform' => $componentNode->hasAttribute('transform') ? ThreeMfTransform::parse($componentNode->getAttribute('transform')) : ThreeMfTransform::identity(), ]; } } } return ['type' => $type, 'pid' => $pid, 'pindex' => $pindex, 'mesh' => $mesh, 'components' => $components]; } /** * @return array{vertices: list, triangles: list} */ private function parseMesh(DOMElement $mesh): array { $vertices = []; $triangles = []; foreach ($mesh->childNodes as $child) { if (! $child instanceof DOMElement) { continue; } if ($child->localName === 'vertices') { foreach ($child->childNodes as $vertexNode) { $this->assertWithinDeadline(); if (! $vertexNode instanceof DOMElement || $vertexNode->localName !== 'vertex') { continue; } if (count($vertices) >= self::MAX_VERTICES_PER_MESH) { throw new RuntimeException('3MF mesh exceeds the maximum vertex count.'); } if (++$this->totalParsedVertices > self::MAX_TOTAL_PARSED_VERTICES) { throw new RuntimeException('3MF model exceeds the maximum total vertex count across all objects.'); } $vertices[] = [ $this->floatAttribute($vertexNode, 'x'), $this->floatAttribute($vertexNode, 'y'), $this->floatAttribute($vertexNode, 'z'), ]; } } elseif ($child->localName === 'triangles') { foreach ($child->childNodes as $triangleNode) { $this->assertWithinDeadline(); if (! $triangleNode instanceof DOMElement || $triangleNode->localName !== 'triangle') { continue; } if (count($triangles) >= self::MAX_TRIANGLES_PER_MESH) { throw new RuntimeException('3MF mesh exceeds the maximum triangle count.'); } if (++$this->totalParsedTriangles > self::MAX_TOTAL_PARSED_TRIANGLES) { throw new RuntimeException('3MF model exceeds the maximum total triangle count across all objects.'); } $triangles[] = [ 'v1' => $this->intAttribute($triangleNode, 'v1'), 'v2' => $this->intAttribute($triangleNode, 'v2'), 'v3' => $this->intAttribute($triangleNode, 'v3'), 'pid' => $triangleNode->hasAttribute('pid') ? $this->intAttribute($triangleNode, 'pid') : null, 'p1' => $triangleNode->hasAttribute('p1') ? $this->intAttribute($triangleNode, 'p1') : null, 'p2' => $triangleNode->hasAttribute('p2') ? $this->intAttribute($triangleNode, 'p2') : null, 'p3' => $triangleNode->hasAttribute('p3') ? $this->intAttribute($triangleNode, 'p3') : null, ]; } } } return ['vertices' => $vertices, 'triangles' => $triangles]; } /** * @return list */ private function parseBuildItems(DOMDocument $document): array { $build = null; foreach ($document->documentElement->childNodes as $child) { if ($child instanceof DOMElement && $child->localName === 'build') { $build = $child; break; } } if ($build === null) { throw new RuntimeException('3MF model part is missing a element.'); } $items = []; foreach ($build->childNodes as $child) { $this->assertWithinDeadline(); if (! $child instanceof DOMElement || $child->localName !== 'item') { continue; } if ($this->hasPathAttribute($child)) { throw new RuntimeException('3MF build references an external model part, which is not supported.'); } if (count($items) >= self::MAX_BUILD_ITEMS) { throw new RuntimeException('3MF build plate has too many items.'); } $items[] = [ 'objectid' => $this->intAttribute($child, 'objectid'), 'transform' => $child->hasAttribute('transform') ? ThreeMfTransform::parse($child->getAttribute('transform')) : ThreeMfTransform::identity(), ]; } return $items; } /** * Recursively resolves one placed instance of an object (a build item, or * a component nested inside another object) into world-space triangles, * composing transforms down the chain and bounding depth, cycles and the * total resolved triangle count incrementally. * * @param array, triangles: list}, components: list}> $objects * @param array> $baseMaterials * @param list $ancestry * @param list $triangles */ private function resolveObjectInstance( int $objectId, array $transform, array $objects, array $baseMaterials, array $ancestry, int $depth, array &$triangles, ): void { $this->assertWithinDeadline(); if ($depth > self::MAX_COMPONENT_DEPTH) { throw new RuntimeException('3MF model nests components too deeply.'); } if (in_array($objectId, $ancestry, true)) { throw new RuntimeException('3MF model contains a circular component reference.'); } if (! isset($objects[$objectId])) { throw new RuntimeException("3MF build references an object id that does not exist: {$objectId}."); } $object = $objects[$objectId]; $ancestry[] = $objectId; if ($object['mesh'] !== null) { $this->resolveColor($baseMaterials, $object['pid'], $object['pindex']); $determinant = $transform[0] * ($transform[4] * $transform[8] - $transform[5] * $transform[7]) - $transform[1] * ($transform[3] * $transform[8] - $transform[5] * $transform[6]) + $transform[2] * ($transform[3] * $transform[7] - $transform[4] * $transform[6]); foreach ($object['mesh']['triangles'] as $triangle) { $this->assertWithinDeadline(); if ($determinant < 0) { [$triangle['v2'], $triangle['v3']] = [$triangle['v3'], $triangle['v2']]; } if (count($triangles) >= self::MAX_TOTAL_RESOLVED_TRIANGLES) { throw new RuntimeException('3MF model resolves to too many triangles once instances are expanded.'); } $vertices = $object['mesh']['vertices']; if (! isset($vertices[$triangle['v1']], $vertices[$triangle['v2']], $vertices[$triangle['v3']])) { throw new RuntimeException('3MF triangle references a vertex index that does not exist.'); } $color = $this->resolveColor( $baseMaterials, $triangle['pid'] ?? $object['pid'], $this->resolveTriangleVertexIndex($triangle, $object['pindex']), ); $triangles[] = [ 'v' => [ ThreeMfTransform::apply($transform, $vertices[$triangle['v1']]), ThreeMfTransform::apply($transform, $vertices[$triangle['v2']]), ThreeMfTransform::apply($transform, $vertices[$triangle['v3']]), ], 'color' => $color, ]; } } foreach ($object['components'] as $component) { $this->resolveObjectInstance( $component['objectid'], ThreeMfTransform::compose($transform, $component['transform']), $objects, $baseMaterials, $ancestry, $depth + 1, $triangles, ); } } /** * No silent grey fallback: a pid/pindex pair is only ever absent when no * colour was specified at all (the legitimate default-colour case). Once * a pid is specified, the referenced property group and index must * actually resolve, or this throws rather than quietly rendering the * wrong colour. * * @param array> $baseMaterials * @return Color */ private function resolveColor(array $baseMaterials, ?int $pid, ?int $pindex): array { if ($pid === null && $pindex === null) { return self::DEFAULT_COLOR; } if ($pid === null || $pindex === null) { throw new RuntimeException('3MF model specifies only one of pid/pindex; both are required to resolve a colour.'); } if (! isset($baseMaterials[$pid])) { throw new RuntimeException("3MF model references an unsupported or unknown property group (pid={$pid}); only and colours are supported."); } if (! isset($baseMaterials[$pid][$pindex])) { throw new RuntimeException("3MF model references an unresolved property index (pid={$pid}, pindex={$pindex})."); } return $baseMaterials[$pid][$pindex]; } /** * A triangle's p1/p2/p3 attributes let each of its 3 vertices reference * a different property index, producing an interpolated gradient across * the face. This renderer only paints one flat colour per triangle, so a * triangle that actually declares differing per-vertex indices is * rejected explicitly rather than silently picking p1 and discarding the * rest of the declared colour data. The property group and first index * inherit independently from object pid and pindex; omitted p2/p3 use p1. * * @param array{v1:int,v2:int,v3:int,pid:?int,p1:?int,p2:?int,p3:?int} $triangle */ private function resolveTriangleVertexIndex(array $triangle, ?int $objectPindex): ?int { $p1 = $triangle['p1'] ?? $objectPindex; $p2 = $triangle['p2'] ?? $p1; $p3 = $triangle['p3'] ?? $p1; if ($p2 !== $p1 || $p3 !== $p1) { throw new RuntimeException('3MF triangle declares interpolated per-vertex colours (p1/p2/p3 differ), which this renderer cannot faithfully render as a single flat colour.'); } return $p1; } private function assertWithinDeadline(): void { if (microtime(true) > $this->deadline) { throw new RuntimeException('3MF parsing exceeded its time budget.'); } } private function hasPathAttribute(DOMElement $element): bool { foreach ($element->attributes ?? [] as $attribute) { if ($attribute instanceof \DOMAttr && $attribute->localName === 'path') { return true; } } return false; } private function intAttribute(DOMElement $element, string $name): int { $value = $element->getAttribute($name); if (! ctype_digit($value) && ! (str_starts_with($value, '-') && ctype_digit(substr($value, 1)))) { throw new RuntimeException("3MF model has a non-integer `{$name}` attribute."); } return (int) $value; } private function floatAttribute(DOMElement $element, string $name): float { $value = $element->getAttribute($name); if (! is_numeric($value)) { throw new RuntimeException("3MF model has a non-numeric `{$name}` attribute."); } $float = (float) $value; if (! is_finite($float)) { throw new RuntimeException("3MF model has a non-finite `{$name}` attribute."); } return $float; } private function assertSafeZipEntryName(string $name): void { if ($name === '' || str_contains($name, '..') || str_starts_with($name, '/') || str_contains($name, '\\') || preg_match('/^[A-Za-z]:/', $name) === 1) { throw new RuntimeException('3MF archive contains an unsafe entry path.'); } } private function assertNotSymlinkEntry(ZipArchive $zip, int $index, string $name): void { $external = $zip->getExternalAttributesIndex($index, $opsys, $attr) ? $attr : null; if ($external === null) { return; } $unixMode = ($external >> 16) & 0xFFFF; if (($unixMode & 0xF000) === 0xA000) { throw new RuntimeException("3MF archive contains a symlink entry: {$name}."); } } }