setAssetPath(self::asset_path . str_replace(__NAMESPACE__ . '\\', '', __CLASS__) . '/'); parent::__construct([ new dynamicimages_asset(self::getAssetPath(self::IMAGE_POWER_BUTTON)), new dynamicimages_asset(self::getAssetPath(self::IMAGE_PANEL_BACKGROUND)), new dynamicimages_asset(self::getAssetPath(self::IMAGE_WASH_PROGRAMS_THUMB)), new dynamicimages_asset(self::getAssetPath(self::IMAGE_WASH_PROGRAMS)), new dynamicimages_asset(self::getAssetPath(self::IMAGE_BUTTON_HIGHLIGHTED)), new dynamicimages_asset(self::getAssetPath(self::IMAGE_BUTTON_HIGHLIGHTED_BLUE)), new dynamicimages_asset(self::getAssetPath(self::IMAGE_BUTTON_HIGHLIGHTED_COMPLETED)), new dynamicimages_asset(self::getAssetPath(self::IMAGE_BUTTON_HIGHLIGHTED_GREEN)), ]); } /** * @throws \Exception */ public function setup(): void { $this->initImageCanvas(6000, 4500); // By default, the background should be shown with the programs on top and then on top of that the thumb $this->drawAsset($this->getAsset(self::IMAGE_PANEL_BACKGROUND), 0, 0); $this->drawProgramWheel(); $this->drawThumb(); $this->drawStepThumb(); $deferredStartButtons = $this->drawHighlightedButtonSequence(); $this->drawAsset($this->getAsset(self::IMAGE_POWER_BUTTON), 0, 0); $this->drawDeferredHighlightedButtons($deferredStartButtons); $this->drawCropOutLine(true); } public function drawCropOutLine(bool $crop = false): void { $line_template = [ 'x' => [ 'from' => 0, 'to' => 6000 ], 'y' => [ 'from' => 0, 'to' => 0 ], ]; // Define top crop line $top_line = [ 'x' => [ 'from' => 0, 'to' => 6000 ], 'y' => [ 'from' => 262, 'to' => 262 ], ]; // Define the left crop line $left_line = [ 'x' => [ 'from' => 194, 'to' => 194 ], 'y' => [ 'from' => 0, 'to' => 4500 ], ]; // Define the right crop line $right_line = [ 'x' => [ 'from' => 5765, 'to' => 5765 ], 'y' => [ 'from' => 0, 'to' => 4500 ], ]; // Define the bottom crop line $bottom_line = [ 'x' => [ 'from' => 0, 'to' => 6000 ], 'y' => [ 'from' => 4160, 'to' => 4160 ], ]; $lines = [ $top_line, $left_line, $right_line, $bottom_line ]; // If the crop parameter is false, just draw the outline if (!$crop) { // Draw the crop outline foreach ( $lines as $line ) { $x1 = (int)$line['x']['from'];; $y1 = (int)$line['y']['from'];; $x2 = (int)$line['x']['to'];; $y2 = (int)$line['y']['to'];; $this->drawLine($x1, $y1, $x2, $y2, 'rgba(255,0,0,0.75)', 1); } } // If the crop parameter is true, apply the crop else { $cropX = (int)$left_line['x']['from']; $cropY = (int)$top_line['y']['from']; $cropWidth = (int)6000 - $cropX - (6000 - (int)$right_line['x']['from']); $cropHeight = (int)4500 - $cropY - (4500 - (int)$bottom_line['y']['from']); $this->crop($cropWidth, $cropHeight, $cropX, $cropY); } } public function getHighlightedAssetName(): string { if ($this->button_counter == $this->current_step) { return self::IMAGE_BUTTON_HIGHLIGHTED_BLUE; } if ($this->button_counter < $this->current_step) { return self::IMAGE_BUTTON_HIGHLIGHTED_COMPLETED; } return self::IMAGE_BUTTON_HIGHLIGHTED_GREY; } private function isHighlightedButton(int|string $button): bool { foreach ($this->highlighted_buttons as $highlightedButton) { if (is_int($button)) { if (is_numeric($highlightedButton) && (int)$highlightedButton === $button) { return true; } continue; } if (is_string($highlightedButton) && strtolower(trim($highlightedButton)) === $button) { return true; } } return false; } private function normalizeHighlightedButtonToken(mixed $button): int|string|null { if (is_string($button)) { $trimmed = trim($button); $specialButton = strtolower($trimmed); if ($specialButton === self::BUTTON_RESET || $specialButton === self::BUTTON_START) { return $specialButton; } if ($trimmed !== '' && ctype_digit($trimmed)) { $button = (int)$trimmed; } } if (is_int($button)) { return $button >= 0 && $button < ($this->button_rows * $this->button_columns) ? $button : null; } if (is_numeric($button) && (int)$button == $button) { $button = (int)$button; return $button >= 0 && $button < ($this->button_rows * $this->button_columns) ? $button : null; } return null; } private function getOrderedHighlightedButtonTokens(): array { $tokens = []; foreach ($this->highlighted_buttons as $button) { $token = $this->normalizeHighlightedButtonToken($button); if ($token !== null) { $tokens[] = $token; } } return $tokens; } private function getRegularButtonCoordinates(int $buttonIndex): ?array { if ($buttonIndex < 0 || $buttonIndex >= ($this->button_rows * $this->button_columns)) { return null; } $row = intdiv($buttonIndex, $this->button_columns); $col = $buttonIndex % $this->button_columns; $x = 2815 + ($col * ($this->button_highlight_size + $this->button_columns_spacing)); $y = 1265 + ($row * ($this->button_highlight_size + $this->button_rows_spacing)); if ($row === $this->button_rows - 1) { $y += $this->button_last_row_spacing_buffer; } return [ 'x' => $x, 'y' => $y, 'size' => $this->button_highlight_size, ]; } private function getHighlightedButtonCoordinates(int|string $button): ?array { if ($button === self::BUTTON_RESET) { return [ 'x' => 1736, 'y' => 599, 'size' => $this->reset_button_highlight_size, ]; } if ($button === self::BUTTON_START) { return [ 'x' => 4965, 'y' => 1980, 'size' => $this->start_button_highlight_size, ]; } return is_int($button) ? $this->getRegularButtonCoordinates($button) : null; } /** * @throws \Exception */ private function buildHighlightedButtonDraw(int|string $button): ?array { $coordinates = $this->getHighlightedButtonCoordinates($button); if ($coordinates === null) { return null; } $tmp = new dynamicimages_asset($this->getAssetPath(self::getHighlightedAssetName())); $tmp->resize($coordinates['size'], $coordinates['size']); $tmp = $this->drawStepCounterOnButton($tmp); if ($this->only_generate_current_step && $this->button_counter != $this->current_step + 1) { if (method_exists($tmp, 'clearMemoryImage')) { $tmp->clearMemoryImage(); } return null; } return [ 'asset' => $tmp, 'x' => $coordinates['x'], 'y' => $coordinates['y'], ]; } /** * @throws \Exception */ private function drawHighlightedButtonDraw(array $draw): void { $tmp = $draw['asset']; $this->drawAsset($tmp, (int)$draw['x'], (int)$draw['y']); if (method_exists($tmp, 'clearMemoryImage')) { $tmp->clearMemoryImage(); } } /** * @return array * @throws \Exception */ public function drawHighlightedButtonSequence(): array { $deferredStartButtons = []; foreach ($this->getOrderedHighlightedButtonTokens() as $button) { $draw = $this->buildHighlightedButtonDraw($button); if ($draw === null) { continue; } if ($button === self::BUTTON_START) { $deferredStartButtons[] = $draw; continue; } $this->drawHighlightedButtonDraw($draw); } return $deferredStartButtons; } /** * @param array $deferredStartButtons * @throws \Exception */ public function drawDeferredHighlightedButtons(array $deferredStartButtons): void { foreach ($deferredStartButtons as $draw) { $this->drawHighlightedButtonDraw($draw); } } /** * @throws \Exception */ public function drawHighlightedStartButton(): void { if (!$this->isHighlightedButton(self::BUTTON_START)) { return; } $x = 4965; // X position for the start button $y = 1980; // Y position for the start button $tmp = new dynamicimages_asset($this->getAssetPath(self::getHighlightedAssetName())); $tmp->resize($this->start_button_highlight_size, $this->start_button_highlight_size); $tmp = $this->drawStepCounterOnButton($tmp); // If only generating current step, skip if not matching if ($this->only_generate_current_step && $this->button_counter != $this->current_step +1) { return; } $this->drawAsset($tmp, $x, $y); // Free memory used by temporary asset image, if any if (method_exists($tmp, 'clearMemoryImage')) { $tmp->clearMemoryImage(); } } /** * @throws \Exception */ public function drawHighlightedResetButton(): void { if (!$this->isHighlightedButton(self::BUTTON_RESET)) { return; } $x = 1736; // X position for the reset button $y = 599; // Y position for the reset button $tmp = new dynamicimages_asset($this->getAssetPath(self::getHighlightedAssetName())); $tmp->resize($this->reset_button_highlight_size, $this->reset_button_highlight_size); $tmp = $this->drawStepCounterOnButton($tmp); if ($this->only_generate_current_step && $this->button_counter != $this->current_step +1) { return; } $this->drawAsset($tmp, $x, $y); // Free memory used by temporary asset image, if any if (method_exists($tmp, 'clearMemoryImage')) { $tmp->clearMemoryImage(); } } public function drawStepCounterOnButton(dynamicimages_asset $buttonAsset): dynamicimages_asset { // Draw a centered step counter number on the button $font = $this->getFontPath('AlfaSlabOne-Regular.ttf'); $imgW = $buttonAsset->getWidth(); $imgH = $buttonAsset->getHeight(); // Virtual badge used only to size text well (no circle is drawn) $badgeDiameter = 0.40 * min($imgW, $imgH); $badgeRadius = $badgeDiameter / 2.0; // Badge center equals image center $cx = $imgW / 2.0; $cy = $imgH / 2.0; // With center/center alignment, offsets are deltas from image center $xOffset = (int)round($cx - ($imgW / 2.0)); $yOffset = (int)round($cy - ($imgH / 2.0)); // Dynamic font sizing to fit up to 2 digits inside the virtual badge $targetFrac = 0.72; // 72% of diameter $maxBox = $badgeDiameter * $targetFrac; $fontSize = (int)max(1, round($badgeDiameter * 0.6)); try { $im = $buttonAsset->getMemoryImage(); if ($im instanceof \Imagick) { $draw = new \ImagickDraw(); $draw->setFont($font); for ($i = 0; $i < 6; $i++) { $draw->setFontSize($fontSize); $metrics = $im->queryFontMetrics($draw, (string)$this->getNextButtonStepCount(), false); $this->button_counter--; // undo increment from metrics call $textW = (int)ceil($metrics['textWidth'] ?? 0); $textH = (int)ceil(($metrics['ascender'] ?? 0) - ($metrics['descender'] ?? 0)); if ($textW > $maxBox || $textH > $maxBox) { $fontSize = (int)max(1, floor($fontSize * 0.9)); } else { $next = (int)ceil($fontSize * 1.05); if ($next === $fontSize) { break; } $fontSize = $next; } } // Safety: ensure we don't exceed the box $draw->setFontSize($fontSize); $metrics = $im->queryFontMetrics($draw, (string)$this->getNextButtonStepCount(), false); $this->button_counter--; // undo increment from metrics call $textW = (int)ceil($metrics['textWidth'] ?? 0); $textH = (int)ceil(($metrics['ascender'] ?? 0) - ($metrics['descender'] ?? 0)); if ($textW > $maxBox || $textH > $maxBox) { $fontSize = (int)max(1, floor($fontSize * 0.95)); } } } catch (\Throwable $e) { // Fallback to initial estimate } $angle = 0; $color = [255, 255, 255]; $text = (string)$this->getNextButtonStepCount(); // If the step has been completed, do not draw the number if ($this->button_counter <= $this->current_step) { $text = ''; } $buttonAsset->addTextOverlay($text, [ 'font_size' => $fontSize, 'font_color' => $color, 'font_path' => $font, 'x_offset' => $xOffset, 'y_offset' => $yOffset, 'angle' => $angle, 'align' => 'center', 'valign' => 'center', ]); return $buttonAsset; } public function drawStepCounterOnButtonTopRight(dynamicimages_asset $buttonAsset): dynamicimages_asset { $font = $this->getFontPath('AlfaSlabOne-Regular.ttf'); // The blue badge circle occupies 25% of the button width/height (diameter) $imgW = $buttonAsset->getWidth(); $imgH = $buttonAsset->getHeight(); $badgeDiameter = 0.25 * min($imgW, $imgH); $badgeRadius = $badgeDiameter / 2.0; // Center of the badge is near the top-right corner; apply inner inset to match actual asset layout // Final calibrated insets: 50% of badge diameter inward from right and downward from top $centerXNudgeIn = 0.50 * $badgeDiameter; // inward from right edge (~50% of diameter) $centerYNudgeIn = 0.50 * $badgeDiameter; // downward from top edge (~50% of diameter) $cx = $imgW - $badgeRadius - $centerXNudgeIn; // adjusted circle center X $cy = $badgeRadius + $centerYNudgeIn; // adjusted circle center Y // We use center/center alignment and offset from the image center to the badge center $baseCenterXOffset = (int)round($cx - ($imgW / 2.0)); $baseCenterYOffset = (int)round($cy - ($imgH / 2.0)); $xOffset = $baseCenterXOffset; $yOffset = $baseCenterYOffset; // Temporarily disable optical nudge while calibrating true badge center $xNudge = 0.00 * $badgeDiameter; $yNudge = 0.00 * $badgeDiameter; $xOffset -= (int)round($xNudge); $yOffset += (int)round($yNudge); // Optical nudge remains disabled; visual centering confirmed with calibrated center. // Compute a dynamic font size that fits up to 2 digits comfortably inside the badge // Target occupied size inside the circle (padding from edges) $targetFrac = 0.72; // 72% of diameter for both width and height $maxBox = $badgeDiameter * $targetFrac; // Start with an optimistic font size close to the target box $fontSize = (int)max(1, round($badgeDiameter * 0.6)); try { // Try to refine using Imagick metrics if available $im = $buttonAsset->getMemoryImage(); if ($im instanceof \Imagick) { $draw = new \ImagickDraw(); $draw->setFont($font); // Iterate a few times to converge for ($i = 0; $i < 6; $i++) { $draw->setFontSize($fontSize); $metrics = $im->queryFontMetrics($draw, (string)$this->getNextButtonStepCount(), false); // We queried getNextButtonStepCount() for metrics; revert increment afterwards $this->button_counter--; // undo increment from metrics call $textW = (int)ceil($metrics['textWidth'] ?? 0); $textH = (int)ceil(($metrics['ascender'] ?? 0) - ($metrics['descender'] ?? 0)); // If it exceeds maxBox in either dimension, reduce; otherwise increase slightly if ($textW > $maxBox || $textH > $maxBox) { $fontSize = (int)max(1, floor($fontSize * 0.9)); } else { // Try to expand a bit to approach the limit $next = (int)ceil($fontSize * 1.05); if ($next === $fontSize) { break; } $fontSize = $next; } } // Final safety cap not to exceed the box $draw->setFontSize($fontSize); $metrics = $im->queryFontMetrics($draw, (string)$this->getNextButtonStepCount(), false); $this->button_counter--; // undo increment from metrics call $textW = (int)ceil($metrics['textWidth'] ?? 0); $textH = (int)ceil(($metrics['ascender'] ?? 0) - ($metrics['descender'] ?? 0)); if ($textW > $maxBox || $textH > $maxBox) { $fontSize = (int)max(1, floor($fontSize * 0.95)); } } } catch (\Throwable $e) { // Fallback: keep the initial estimate on any error } $angle = 0; $color = [255, 255, 255]; // White color for the text $text = (string)$this->getNextButtonStepCount(); // If the step has been completed, do not draw the number if ($this->button_counter <= $this->current_step) { $text = ''; } $buttonAsset->addTextOverlay($text, [ 'font_size' => $fontSize, 'font_color' => $color, 'font_path' => $font, 'x_offset' => $xOffset, 'y_offset' => $yOffset, 'angle' => $angle, 'align' => 'center', 'valign' => 'center', ]); // Debug guides removed after calibration return $buttonAsset; } /** * @throws \Exception */ public function drawHighlightedButtons(): void { $startX = 2815; // Starting X position for the first button $startY = 1265; // Starting Y position for the first button for ($row = 0; $row < $this->button_rows; $row++) { for ($col = 0; $col < $this->button_columns; $col++) { // If the current button position is not in the highlighted buttons array, skip it $buttonIndex = $row * $this->button_columns + $col; if (!$this->isHighlightedButton($buttonIndex)) { continue; } // Calculate the position for the current button $x = $startX + ($col * ($this->button_highlight_size + $this->button_columns_spacing)); $y = $startY + ($row * ($this->button_highlight_size + $this->button_rows_spacing)); // Last row adjustment if ($row === $this->button_rows - 1) { $y += $this->button_last_row_spacing_buffer; } $tmp = new dynamicimages_asset($this->getAssetPath(self::getHighlightedAssetName())); $tmp->resize($this->button_highlight_size, $this->button_highlight_size); $tmp = $this->drawStepCounterOnButton($tmp); if ($this->only_generate_current_step && $this->button_counter != $this->current_step +1) { continue; } $this->drawAsset($tmp, $x, $y); // Free memory used by temporary asset image, if any if (method_exists($tmp, 'clearMemoryImage')) { $tmp->clearMemoryImage(); } } } } /** * @return int */ public function getNextButtonStepCount(): int { $this->button_counter++; return $this->button_counter; } /** * @throws \Exception */ public function drawProgramWheel(): void { // Match the wheel center to the thumb's visual center $thumbX = 650; $thumbY = 1290; $buffer = 600; // Pixels to increase the wheel size beyond the thumb edges $thumbX -= $buffer / 2; $thumbY -= $buffer / 2; $thumbWidth = $this->calculateThumbWidth($this->thumb_size) + $buffer; $thumbHeight = $this->thumb_size + $buffer; $tmp = new dynamicimages_asset($this->getAssetPath(self::IMAGE_WASH_PROGRAMS)); $tmp->resize($thumbWidth, $thumbHeight); $this->drawAsset($tmp, $thumbX, $thumbY); // Free memory used by temporary asset image, if any if (method_exists($tmp, 'clearMemoryImage')) { $tmp->clearMemoryImage(); } } public function getRotationThumbPosition(): float { // Each position represents a 30 degree increment (12 positions in total for 360 degrees) $degrees = -90; return ($this->thumb_position) * 30 + $degrees; } public function calculateThumbWidth(int $height): int { $originalWidth = $this->getAsset(self::IMAGE_WASH_PROGRAMS_THUMB)->getWidth(); $originalHeight = $this->getAsset(self::IMAGE_WASH_PROGRAMS_THUMB)->getHeight(); return (int)round(($originalWidth / $originalHeight) * $height); } /** * @return void * @throws \Exception */ public function drawThumb(): void { // Thumb parameters $thumbX = 650; $thumbY = 1290; // Base resized thumb to compute the visual pivot (center coordinates) $baseThumb = $this->getAsset(self::IMAGE_WASH_PROGRAMS_THUMB)->resize($this->calculateThumbWidth($this->thumb_size), $this->thumb_size); $centerX = $thumbX + (int)floor($baseThumb->getWidth() / 2); $centerY = $thumbY + (int)floor($baseThumb->getHeight() / 2); $tmp = new dynamicimages_asset($this->getAssetPath(self::IMAGE_WASH_PROGRAMS_THUMB)); $tmp->resize($this->calculateThumbWidth($this->thumb_size), $this->thumb_size) ->rotate($this->getRotationThumbPosition()); $this->drawAssetCentered($tmp, $centerX, $centerY); // Free memory used by temporary asset image, if any if (method_exists($tmp, 'clearMemoryImage')) { $tmp->clearMemoryImage(); } } /** * @throws \Exception */ public function drawStepThumb(): void { // Draw the blue step indicator on the thumb $thumbX = 650; $thumbY = 1290; $stepSize = 350; // Size of the step indicator // Base resized thumb to compute the visual pivot (center coordinates) $baseThumb = $this->getAsset(self::IMAGE_WASH_PROGRAMS_THUMB)->resize($this->calculateThumbWidth($this->thumb_size), $this->thumb_size); $centerX = $thumbX + (int)floor($baseThumb->getWidth() / 2); $centerY = $thumbY + (int)floor($baseThumb->getHeight() / 2); $angle = $this->getRotationThumbPosition(); // Calculate position to draw the step indicator $radius = ($this->thumb_size / 2) - ($stepSize / 2) - 150; // Radius from center to position the step indicator $rad = deg2rad($angle); $stepX = $centerX + (int)round($radius * cos($rad)) - (int)floor($stepSize / 2); $stepY = $centerY + (int)round($radius * sin($rad)) - (int)floor($stepSize / 2); $tmp = new dynamicimages_asset($this->getAssetPath(self::IMAGE_BUTTON_HIGHLIGHTED_BLUE)); $tmp->resize($stepSize, $stepSize); $this->drawAsset($tmp, $stepX, $stepY); // Free memory used by temporary asset image, if any if (method_exists($tmp, 'clearMemoryImage')) { $tmp->clearMemoryImage(); } } }