gdAvailable = $gdAvailable ?? extension_loaded('gd'); } /** * @param list $triangles * @return array{bytes: string, mime: string, renderer: string, width: int, height: int} */ public function render(array $triangles): array { if ($triangles === []) { throw new RuntimeException('Nothing to render: no triangles were provided.'); } foreach ($triangles as $triangle) { if ($triangle['color'][3] !== 255) { throw new RuntimeException('Transparent materials are not supported by this renderer.'); } } $start = microtime(true); $visible = $this->cullAndShade($triangles, $start); if ($visible === []) { throw new RuntimeException('Nothing to render: every triangle faces away from the camera.'); } $projected = $this->project($visible, $start); return $this->gdAvailable ? $this->renderPng($projected, $start) : $this->renderSvg($projected, $start); } public function rendererName(): string { return $this->gdAvailable ? self::RENDERER_GD : self::RENDERER_SVG; } /** * Computes each triangle's face normal once, uses it to cull back-facing * triangles (normal pointing away from the camera) and to shade the * survivors by the *signed* angle to the light — trusting 3MF's * mandated outward CCW winding, rather than folding both orientations * into the same brightness via an absolute value as an earlier version * of this class did. * * @param list $triangles * @return list */ private function cullAndShade(array $triangles, float $start): array { $visible = []; foreach ($triangles as $index => $triangle) { if ($index % 2000 === 0) { $this->assertWithinBudget($start); } [$ax, $ay, $az] = $triangle['v'][0]; [$bx, $by, $bz] = $triangle['v'][1]; [$cx, $cy, $cz] = $triangle['v'][2]; $e1 = [$bx - $ax, $by - $ay, $bz - $az]; $e2 = [$cx - $ax, $cy - $ay, $cz - $az]; $normal = [ $e1[1] * $e2[2] - $e1[2] * $e2[1], $e1[2] * $e2[0] - $e1[0] * $e2[2], $e1[0] * $e2[1] - $e1[1] * $e2[0], ]; $length = sqrt($normal[0] ** 2 + $normal[1] ** 2 + $normal[2] ** 2); if ($length < 1e-9) { continue; // degenerate (zero-area) triangle } $normal = [$normal[0] / $length, $normal[1] / $length, $normal[2] / $length]; $cameraDot = $normal[0] * self::CAMERA_DIRECTION[0] + $normal[1] * self::CAMERA_DIRECTION[1] + $normal[2] * self::CAMERA_DIRECTION[2]; if ($cameraDot <= 1e-6) { continue; // back-facing (or edge-on): occluded on a manifold solid, cull it } $lightDot = $normal[0] * self::LIGHT_DIRECTION[0] + $normal[1] * self::LIGHT_DIRECTION[1] + $normal[2] * self::LIGHT_DIRECTION[2]; $brightness = max(0.15, min(1.0, 0.55 + 0.45 * $lightDot)); $color = $triangle['color']; $visible[] = [ 'v' => $triangle['v'], 'color' => [ min(255, (int) round($color[0] * $brightness)), min(255, (int) round($color[1] * $brightness)), min(255, (int) round($color[2] * $brightness)), $color[3], ], ]; } return $visible; } /** * @param list $visible * @return list */ private function project(array $visible, float $start): array { $minX = $minY = INF; $maxX = $maxY = -INF; $withScreen = []; foreach ($visible as $index => $triangle) { if ($index % 2000 === 0) { $this->assertWithinBudget($start); } $screen = []; $depths = []; foreach ($triangle['v'] as $vertex) { [$x, $y, $z] = $vertex; // Already screen-down-positive: no further vertical flip. $screenX = ($x - $y) * M_SQRT3 / 2; $screenY = ($x + $y) / 2 - $z; $screen[] = [$screenX, $screenY]; $depths[] = $x + $y + $z; $minX = min($minX, $screenX); $maxX = max($maxX, $screenX); $minY = min($minY, $screenY); $maxY = max($maxY, $screenY); } $withScreen[] = ['screen' => $screen, 'depths' => $depths, 'color' => $triangle['color']]; } $spanX = max($maxX - $minX, 1e-6); $spanY = max($maxY - $minY, 1e-6); $available = self::CANVAS_SIZE - 2 * self::PADDING; $scale = min($available / $spanX, $available / $spanY); $result = []; foreach ($withScreen as $triangle) { $points = []; foreach ($triangle['screen'] as [$screenX, $screenY]) { $points[] = self::PADDING + ($screenX - $minX) * $scale; $points[] = self::PADDING + ($screenY - $minY) * $scale; } $result[] = [ 'points' => $points, 'depths' => $triangle['depths'], 'avgDepth' => array_sum($triangle['depths']) / 3, 'color' => $triangle['color'], ]; } return $result; } /** * @param list $projected * @return array{bytes: string, mime: string, renderer: string, width: int, height: int} */ private function renderPng(array $projected, float $start): array { $size = self::CANVAS_SIZE; $image = imagecreatetruecolor($size, $size); if ($image === false) { throw new RuntimeException('Unable to create render canvas.'); } imagesavealpha($image, true); $background = imagecolorallocatealpha($image, 15, 23, 42, 0); imagefill($image, 0, 0, $background); /** @var array $depthBuffer null = untouched background pixel */ $depthBuffer = array_fill(0, $size * $size, null); /** @var array $colorCache */ $colorCache = []; foreach ($projected as $index => $triangle) { if ($index % 200 === 0) { $this->assertWithinBudget($start); } $this->rasterizeTriangle($image, $depthBuffer, $colorCache, $size, $triangle, $start); } ob_start(); imagepng($image); $bytes = ob_get_clean(); imagedestroy($image); if ($bytes === false || $bytes === '') { throw new RuntimeException('Unable to encode the rendered PNG.'); } return ['bytes' => $bytes, 'mime' => 'image/png', 'renderer' => self::RENDERER_GD, 'width' => $size, 'height' => $size]; } /** * Real per-pixel hidden-surface removal: for every candidate pixel in * the triangle's screen-space bounding box, barycentric weights decide * whether the pixel centre lies inside the triangle and, if so, * interpolate its depth; the pixel is only painted when that depth is * nearer the camera than whatever already occupies it. This is what * correctly resolves overlapping/interleaved front-facing geometry that * a single whole-triangle centroid sort cannot. * * @param array $depthBuffer * @param array $colorCache * @param array{points: array{0:float,1:float,2:float,3:float,4:float,5:float}, depths: array{0:float,1:float,2:float}, avgDepth: float, color: array{0:int,1:int,2:int,3:int}} $triangle */ private function rasterizeTriangle(\GdImage $image, array &$depthBuffer, array &$colorCache, int $size, array $triangle, float $start): void { [$x0, $y0, $x1, $y1, $x2, $y2] = $triangle['points']; [$z0, $z1, $z2] = $triangle['depths']; $denom = ($y1 - $y2) * ($x0 - $x2) + ($x2 - $x1) * ($y0 - $y2); if (abs($denom) < 1e-9) { return; // degenerate in screen space } $minPX = max(0, (int) floor(min($x0, $x1, $x2))); $maxPX = min($size - 1, (int) ceil(max($x0, $x1, $x2))); $minPY = max(0, (int) floor(min($y0, $y1, $y2))); $maxPY = min($size - 1, (int) ceil(max($y0, $y1, $y2))); if ($minPX > $maxPX || $minPY > $maxPY) { return; } $colorKey = implode(',', $triangle['color']); if (! isset($colorCache[$colorKey])) { [$r, $g, $b] = $triangle['color']; $colorCache[$colorKey] = imagecolorallocate($image, $r, $g, $b); } $color = $colorCache[$colorKey]; for ($py = $minPY; $py <= $maxPY; $py++) { if (microtime(true) - $start > self::RENDER_TIME_BUDGET_SECONDS) { throw new RuntimeException('Rendering exceeded its time budget.'); } $rowOffset = $py * $size; $cy = $py + 0.5; for ($px = $minPX; $px <= $maxPX; $px++) { $cx = $px + 0.5; $w0 = (($y1 - $y2) * ($cx - $x2) + ($x2 - $x1) * ($cy - $y2)) / $denom; $w1 = (($y2 - $y0) * ($cx - $x2) + ($x0 - $x2) * ($cy - $y2)) / $denom; $w2 = 1.0 - $w0 - $w1; if ($w0 < -1e-4 || $w1 < -1e-4 || $w2 < -1e-4) { continue; } $depth = $w0 * $z0 + $w1 * $z1 + $w2 * $z2; $offset = $rowOffset + $px; if ($depthBuffer[$offset] === null || $depth > $depthBuffer[$offset]) { $depthBuffer[$offset] = $depth; imagesetpixel($image, $px, $py, $color); } } } } /** * @param list $projected * @return array{bytes: string, mime: string, renderer: string, width: int, height: int} */ private function renderSvg(array $projected, float $start): array { // No per-pixel compositing is possible in a vector format, so this // is the best achievable approximation over the already // backface-culled set: draw the (now consistently front-facing) // triangles farthest-from-camera first. usort($projected, fn (array $a, array $b): int => $a['avgDepth'] <=> $b['avgDepth']); $size = self::CANVAS_SIZE; $parts = [ '', sprintf('', $size, $size, $size, $size), sprintf('', $size, $size), ]; foreach ($projected as $index => $triangle) { if ($index % 2000 === 0) { $this->assertWithinBudget($start); } [$r, $g, $b] = $triangle['color']; [$x1, $y1, $x2, $y2, $x3, $y3] = $triangle['points']; $parts[] = sprintf( '', $x1, $y1, $x2, $y2, $x3, $y3, $r, $g, $b, ); } $parts[] = ''; $bytes = implode('', $parts); return ['bytes' => $bytes, 'mime' => 'image/svg+xml', 'renderer' => self::RENDERER_SVG, 'width' => $size, 'height' => $size]; } private function assertWithinBudget(float $start): void { if (microtime(true) - $start > self::RENDER_TIME_BUDGET_SECONDS) { throw new RuntimeException('Rendering exceeded its time budget.'); } } }