diff --git a/app/Services/ThreeMfGeometryExtractor.php b/app/Services/ThreeMfGeometryExtractor.php
index 96b39a4..db90551 100644
--- a/app/Services/ThreeMfGeometryExtractor.php
+++ b/app/Services/ThreeMfGeometryExtractor.php
@@ -561,7 +561,7 @@ class ThreeMfGeometryExtractor
$ancestry[] = $objectId;
if ($object['mesh'] !== null) {
- $defaultColor = $this->resolveColor($baseMaterials, $object['pid'], $object['pindex']);
+ $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]);
@@ -578,9 +578,11 @@ class ThreeMfGeometryExtractor
throw new RuntimeException('3MF triangle references a vertex index that does not exist.');
}
- $color = $triangle['pid'] !== null
- ? $this->resolveColor($baseMaterials, $triangle['pid'], $this->resolveTriangleVertexIndex($triangle))
- : $defaultColor;
+ $color = $this->resolveColor(
+ $baseMaterials,
+ $triangle['pid'] ?? $object['pid'],
+ $this->resolveTriangleVertexIndex($triangle, $object['pindex']),
+ );
$triangles[] = [
'v' => [
@@ -640,13 +642,14 @@ class ThreeMfGeometryExtractor
* 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.
+ * 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
+ private function resolveTriangleVertexIndex(array $triangle, ?int $objectPindex): ?int
{
- $p1 = $triangle['p1'];
+ $p1 = $triangle['p1'] ?? $objectPindex;
$p2 = $triangle['p2'] ?? $p1;
$p3 = $triangle['p3'] ?? $p1;
diff --git a/tests/Unit/ThreeMfGeometryExtractorTest.php b/tests/Unit/ThreeMfGeometryExtractorTest.php
index 3d18b3f..060355d 100644
--- a/tests/Unit/ThreeMfGeometryExtractorTest.php
+++ b/tests/Unit/ThreeMfGeometryExtractorTest.php
@@ -2,7 +2,11 @@
namespace Tests\Unit;
+use App\Services\ManufacturingFileValidator;
+use App\Services\ProductImageRenderer;
use App\Services\ThreeMfGeometryExtractor;
+use Illuminate\Http\UploadedFile;
+use PHPUnit\Framework\Attributes\DataProvider;
use RuntimeException;
use Tests\TestCase;
use ZipArchive;
@@ -131,6 +135,91 @@ class ThreeMfGeometryExtractorTest extends TestCase
unlink($path);
}
+ private function validatedTetrahedron(string $triangleProperties): string
+ {
+ $faces = '';
+ foreach ([[0, 2, 1], [0, 1, 3], [0, 3, 2], [1, 2, 3]] as [$v1, $v2, $v3]) {
+ $faces .= '';
+ }
+ $resources = ''
+ .''
+ .'';
+ $path = $this->validArchive($this->model($resources, ' '));
+ try {
+ $validated = (new ManufacturingFileValidator)->validate(new UploadedFile($path, 'tetrahedron.3mf', test: true));
+ $this->assertSame('3mf', $validated['extension']);
+ } catch (\Throwable $exception) {
+ unlink($path);
+ throw $exception;
+ }
+
+ return $path;
+ }
+
+ /** @return array */
+ public static function flatInheritedProperties(): array
+ {
+ return [
+ 'p2 and p3 default to p1' => ['p1="1"'],
+ 'all vertex properties specified' => ['p1="1" p2="1" p3="1"'],
+ ];
+ }
+
+ #[DataProvider('flatInheritedProperties')]
+ public function test_inherited_triangle_property_group_applies_flat_override_through_png_rendering(string $properties): void
+ {
+ $path = $this->validatedTetrahedron($properties);
+ try {
+ $scene = $this->extractor->extract($path);
+ $this->assertSame(4, $scene['triangle_count']);
+ foreach ($scene['triangles'] as $triangle) {
+ $this->assertSame([0, 0, 255, 255], $triangle['color']);
+ }
+ $rendered = (new ProductImageRenderer)->render($scene['triangles']);
+ $this->assertSame('image/png', $rendered['mime']);
+ $image = imagecreatefromstring($rendered['bytes']);
+ $this->assertNotFalse($image);
+ try {
+ $pixel = imagecolorsforindex($image, imagecolorat($image, 256, 256));
+ $this->assertSame(0, $pixel['red']);
+ $this->assertSame(0, $pixel['green']);
+ $this->assertGreaterThan(0, $pixel['blue']);
+ $this->assertSame(0, $pixel['alpha']);
+ } finally {
+ imagedestroy($image);
+ }
+ } finally {
+ unlink($path);
+ }
+ }
+
+ public function test_inherited_triangle_property_group_rejects_interpolated_override(): void
+ {
+ $path = $this->validatedTetrahedron('p1="0" p2="1" p3="0"');
+ try {
+ $this->expectException(RuntimeException::class);
+ $this->expectExceptionMessageMatches('/interpolat/i');
+ (new ProductImageRenderer)->render($this->extractor->extract($path)['triangles']);
+ } finally {
+ unlink($path);
+ }
+ }
+
+ public function test_inherited_triangle_property_group_rejects_transparent_override(): void
+ {
+ $path = $this->validatedTetrahedron('p1="2"');
+ try {
+ $this->expectException(RuntimeException::class);
+ $this->expectExceptionMessageMatches('/transparen/i');
+ (new ProductImageRenderer)->render($this->extractor->extract($path)['triangles']);
+ } finally {
+ unlink($path);
+ }
+ }
+
public function test_resolves_object_level_color_from_a_colorgroup(): void
{
$resources = ''