feat: M3 import & indexing engine delivery layer with durable collision handling, space checking and console

This commit is contained in:
Cline committed 2026-10-12 11:29:17 -05:00
1 parent 93d991703c
commit 9d277d9e72
48 files changed
+5147 -4

No files matched your search

+204
View File
@@ -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);
}
}