feat: M3 import & indexing engine delivery layer with durable collision handling, space checking and console
This commit is contained in:
1 parent
93d991703c
commit
9d277d9e72
48 files changed
+5147
-4
No files matched your search
@@ -0,0 +1,204 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace MeshVault\Indexer;
|
||||
|
||||
use Closure;
|
||||
use MeshVault\Indexer\Filesystem\PathSafety;
|
||||
|
||||
/**
|
||||
* Structural validation, not manifoldness, winding or non-degeneracy validation.
|
||||
* Empty meshes are rejected in both encodings. Exact binary length wins over a
|
||||
* "solid" header. Binary attributes are opaque; all 12 floats must be finite.
|
||||
* ASCII uses lowercase, one statement per LF/CRLF line (4096 bytes excluding LF),
|
||||
* optional blank lines and printable names (opening/closing names need not match).
|
||||
* One solid only; no comments, BOM, bare-CR lines or trailing non-whitespace.
|
||||
* O(1) auxiliary memory, O(file size) time. Callers must prevent source mutation;
|
||||
* PathSafety checks and final size checks do not provide a race-proof snapshot.
|
||||
*/
|
||||
final readonly class StlValidator
|
||||
{
|
||||
/** @param null|Closure(resource, int): (string|false) $reader Test seam; like fread. */
|
||||
public function __construct(private PathSafety $pathSafety, private ?Closure $reader = null)
|
||||
{
|
||||
}
|
||||
|
||||
public function validate(string $path): void
|
||||
{
|
||||
$path = $this->pathSafety->ensureSourceSafe($path);
|
||||
if (!is_file($path) || ($stream = @fopen($path, 'rb')) === false) {
|
||||
throw new ImportException('SCAN_READ_ERROR', 'SCAN_READ_ERROR: Cannot open STL file.');
|
||||
}
|
||||
try {
|
||||
$stat = @fstat($stream);
|
||||
if ($stat === false || !is_int($stat['size']) || $stat['size'] < 0) {
|
||||
throw new ImportException('SCAN_READ_ERROR', 'Cannot determine STL size.');
|
||||
}
|
||||
$size = $stat['size'];
|
||||
$header = $this->readUpTo($stream, 84);
|
||||
$count = strlen($header) === 84 ? unpack('Vcount', substr($header, 80, 4))['count'] : 0;
|
||||
// Division avoids overflowing 84 + 50 * an untrusted uint32 count.
|
||||
$binary = $size >= 84 && ($size - 84) % 50 === 0 && intdiv($size - 84, 50) === $count;
|
||||
if ($binary) {
|
||||
if ($count === 0) {
|
||||
$this->invalid('Empty mesh.');
|
||||
}
|
||||
for ($index = 0; $index < $count; ++$index) {
|
||||
$record = $this->readUpTo($stream, 50);
|
||||
if (strlen($record) !== 50) {
|
||||
$this->invalid('Truncated binary facet.');
|
||||
}
|
||||
foreach (unpack('g12', substr($record, 0, 48)) as $number) {
|
||||
if (!is_finite($number)) {
|
||||
$this->invalid('Non-finite binary coordinate or normal.');
|
||||
}
|
||||
}
|
||||
}
|
||||
if ($this->readUpTo($stream, 1) !== '') {
|
||||
$this->invalid('Trailing binary data.');
|
||||
}
|
||||
} else {
|
||||
if (@fseek($stream, 0) !== 0) {
|
||||
throw new ImportException('SCAN_READ_ERROR', 'Cannot rewind STL file.');
|
||||
}
|
||||
$this->validateAscii($stream);
|
||||
}
|
||||
$after = @fstat($stream);
|
||||
if ($after === false || $after['size'] !== $size) {
|
||||
throw new ImportException('SCAN_READ_ERROR', 'STL size changed during validation.');
|
||||
}
|
||||
} finally {
|
||||
fclose($stream);
|
||||
}
|
||||
}
|
||||
|
||||
/** @param resource $stream */
|
||||
private function readUpTo($stream, int $length): string
|
||||
{
|
||||
$result = '';
|
||||
while (strlen($result) < $length) {
|
||||
$remaining = $length - strlen($result);
|
||||
try {
|
||||
$chunk = $this->reader === null ? @fread($stream, $remaining) : ($this->reader)($stream, $remaining);
|
||||
} catch (\Throwable $error) {
|
||||
throw new ImportException('SCAN_READ_ERROR', 'Failed reading STL stream.', $error);
|
||||
}
|
||||
if ($chunk === false || ($chunk === '' && !feof($stream)) || strlen($chunk) > $remaining) {
|
||||
throw new ImportException('SCAN_READ_ERROR', 'Failed reading STL stream.');
|
||||
}
|
||||
if ($chunk === '') {
|
||||
break;
|
||||
}
|
||||
$result .= $chunk;
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
|
||||
/** @param resource $stream @return \Generator<int, string> */
|
||||
private function lines($stream): \Generator
|
||||
{
|
||||
$pending = '';
|
||||
while (($chunk = $this->readUpTo($stream, 4096)) !== '') {
|
||||
$pending .= $chunk;
|
||||
while (($newline = strpos($pending, "\n")) !== false) {
|
||||
if ($newline > 4096) {
|
||||
$this->invalid('ASCII line exceeds 4096 bytes.');
|
||||
}
|
||||
yield substr($pending, 0, $newline);
|
||||
$pending = substr($pending, $newline + 1);
|
||||
}
|
||||
if (strlen($pending) > 4096) {
|
||||
$this->invalid('ASCII line exceeds 4096 bytes.');
|
||||
}
|
||||
}
|
||||
if ($pending !== '') {
|
||||
yield $pending;
|
||||
}
|
||||
}
|
||||
|
||||
/** @param resource $stream */
|
||||
private function validateAscii($stream): void
|
||||
{
|
||||
$state = 'solid';
|
||||
$hasFacet = false;
|
||||
foreach ($this->lines($stream) as $line) {
|
||||
$line = str_ends_with($line, "\r") ? substr($line, 0, -1) : $line;
|
||||
if (preg_match('/[^\x20-\x7e\t]/', $line)) {
|
||||
$this->invalid('Non-ASCII or control byte.');
|
||||
}
|
||||
$line = trim($line, " \t");
|
||||
if ($line === '') {
|
||||
continue;
|
||||
}
|
||||
switch ($state) {
|
||||
case 'solid':
|
||||
if (!preg_match('/^solid(?:[ \t].*)?$/D', $line)) {
|
||||
$this->invalid('Expected solid.');
|
||||
}
|
||||
$state = 'facet';
|
||||
break;
|
||||
case 'facet':
|
||||
if (preg_match('/^endsolid(?:[ \t].*)?$/D', $line)) {
|
||||
if (!$hasFacet) {
|
||||
$this->invalid('Empty mesh.');
|
||||
}
|
||||
$state = 'done';
|
||||
break;
|
||||
}
|
||||
$this->vector($line, 'facet normal');
|
||||
$state = 'outer';
|
||||
break;
|
||||
case 'outer':
|
||||
if (!preg_match('/^outer[ \t]+loop$/D', $line)) {
|
||||
$this->invalid('Expected outer loop.');
|
||||
}
|
||||
$state = 'vertex1';
|
||||
break;
|
||||
case 'vertex1':
|
||||
case 'vertex2':
|
||||
case 'vertex3':
|
||||
$this->vector($line, 'vertex');
|
||||
$state = ['vertex1' => 'vertex2', 'vertex2' => 'vertex3', 'vertex3' => 'endloop'][$state];
|
||||
break;
|
||||
case 'endloop':
|
||||
case 'endfacet':
|
||||
if ($line !== $state) {
|
||||
$this->invalid('Expected ' . $state . '.');
|
||||
}
|
||||
if ($state === 'endfacet') {
|
||||
$hasFacet = true;
|
||||
$state = 'facet';
|
||||
} else {
|
||||
$state = 'endfacet';
|
||||
}
|
||||
break;
|
||||
default:
|
||||
$this->invalid('Trailing ASCII data.');
|
||||
}
|
||||
}
|
||||
if ($state !== 'done') {
|
||||
$this->invalid('Incomplete ASCII STL.');
|
||||
}
|
||||
}
|
||||
|
||||
private function vector(string $line, string $prefix): void
|
||||
{
|
||||
$number = '([+-]?(?:[0-9]+(?:\.[0-9]*)?|\.[0-9]+)(?:[eE][+-]?[0-9]+)?)';
|
||||
$prefix = str_replace(' ', '[ \t]+', $prefix);
|
||||
$pattern = '/^' . $prefix . '[ \t]+' . $number . '[ \t]+' . $number . '[ \t]+' . $number . '$/D';
|
||||
if (!preg_match($pattern, $line, $m)) {
|
||||
$this->invalid('Expected a normal or vertex with three decimal coordinates.');
|
||||
}
|
||||
foreach (array_slice($m, 1) as $value) {
|
||||
if (!is_finite((float) $value)) {
|
||||
$this->invalid('Non-finite ASCII coordinate or normal.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function invalid(string $message): never
|
||||
{
|
||||
throw new ImportException('STL_INVALID', $message);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user