372 lines
15 KiB
PHP
372 lines
15 KiB
PHP
<?php
|
|
|
|
namespace App\Services;
|
|
|
|
use RuntimeException;
|
|
|
|
/**
|
|
* Renders already-extracted, bounded 3MF geometry (see ThreeMfGeometryExtractor)
|
|
* into a flat-shaded isometric product preview — a real numeric projection and
|
|
* rasterisation of the actual mesh, never a placeholder thumbnail or a
|
|
* fabricated photo.
|
|
*
|
|
* Z is treated as the "up" axis (the 3D-printing convention: the build plate
|
|
* is the XY plane). Projection uses the standard isometric screen-space
|
|
* formulas screenX = (x - y) * cos(30°), screenY = (x + y) * sin(30°) - z —
|
|
* these already produce a screen-down-positive Y coordinate directly, so no
|
|
* further vertical flip is applied; adding one (as an earlier version of
|
|
* this class did) inverts Z and renders the model upside down.
|
|
*
|
|
* The 3MF Core Specification requires mesh triangles to be consistently
|
|
* wound counter-clockwise as seen from outside a manifold solid. This class
|
|
* trusts that requirement (the same way ManufacturingFileValidator trusts
|
|
* well-formed input beyond its own structural checks) to do real backface
|
|
* culling: a triangle whose outward normal points away from a camera at the
|
|
* (1,1,1) direction is skipped entirely, rather than drawn and left to an
|
|
* approximate depth sort to hide. The surviving front-facing triangles are
|
|
* shaded by the *signed* angle between that same normal and a fixed,
|
|
* asymmetric light direction, so faces at different angles (e.g. a cube's
|
|
* top vs. its left vs. its right face) render as visibly different
|
|
* brightnesses rather than one flat fill.
|
|
*
|
|
* Renders to PNG via GD when available. The PNG path rasterises every
|
|
* surviving triangle against a real per-pixel depth (z-)buffer — barycentric
|
|
* interpolation decides, pixel by pixel, which triangle is actually nearest
|
|
* the camera — so overlapping or interleaved geometry is resolved correctly
|
|
* regardless of triangle draw order, unlike a whole-triangle centroid
|
|
* painter's-algorithm sort. When GD is not available this falls back to a
|
|
* fixed-content, numeric-only SVG (polygon coordinates and fill colours
|
|
* only — no text, scripts, strokes or external references); since SVG has
|
|
* no per-pixel compositing, that path keeps a depth-sorted painter's
|
|
* algorithm over the same backface-culled triangle set as its best
|
|
* achievable approximation.
|
|
*
|
|
* Every phase (culling/shading, projection, and PNG/SVG rasterisation) is
|
|
* bounded by one shared wall-clock render budget, checked throughout — not
|
|
* only inside the final draw loop — so a pathological but in-bounds triangle
|
|
* count can't quietly run unbounded before that loop is ever reached.
|
|
*
|
|
* @phpstan-import-type Triangle from ThreeMfGeometryExtractor
|
|
*/
|
|
class ProductImageRenderer
|
|
{
|
|
public const CANVAS_SIZE = 512;
|
|
|
|
public const RENDERER_GD = 'gd';
|
|
|
|
public const RENDERER_SVG = 'svg';
|
|
|
|
private const PADDING = 32;
|
|
|
|
private const RENDER_TIME_BUDGET_SECONDS = 5.0;
|
|
|
|
/** Camera looks toward the origin from the (1,1,1) direction — matches the isometric projection below. */
|
|
private const CAMERA_DIRECTION = [0.5773502691896258, 0.5773502691896258, 0.5773502691896258];
|
|
|
|
/** Asymmetric so a cube's top/left/right faces render as visibly different brightnesses. */
|
|
private const LIGHT_DIRECTION = [0.4082482904638631, -0.4082482904638631, 0.8164965809277261];
|
|
|
|
private readonly bool $gdAvailable;
|
|
|
|
public function __construct(?bool $gdAvailable = null)
|
|
{
|
|
$this->gdAvailable = $gdAvailable ?? extension_loaded('gd');
|
|
}
|
|
|
|
/**
|
|
* @param list<Triangle> $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<Triangle> $triangles
|
|
* @return list<Triangle>
|
|
*/
|
|
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<Triangle> $visible
|
|
* @return list<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}}>
|
|
*/
|
|
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<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}}> $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<int, float|null> $depthBuffer null = untouched background pixel */
|
|
$depthBuffer = array_fill(0, $size * $size, null);
|
|
/** @var array<string, int> $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<int, float|null> $depthBuffer
|
|
* @param array<string, int> $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<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}}> $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 = [
|
|
'<?xml version="1.0" encoding="UTF-8"?>',
|
|
sprintf('<svg xmlns="http://www.w3.org/2000/svg" width="%d" height="%d" viewBox="0 0 %d %d">', $size, $size, $size, $size),
|
|
sprintf('<rect width="%d" height="%d" fill="#0f172a"/>', $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(
|
|
'<polygon points="%.2f,%.2f %.2f,%.2f %.2f,%.2f" fill="#%02x%02x%02x"/>',
|
|
$x1, $y1, $x2, $y2, $x3, $y3, $r, $g, $b,
|
|
);
|
|
}
|
|
$parts[] = '</svg>';
|
|
|
|
$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.');
|
|
}
|
|
}
|
|
}
|