ブラウザの Javascript を有効にして下さい。
This site requires your browser to be JavaScript enabled.

Gコードファイルのサムネイル表示

GコードファイルにJPEG形式のサムネイル画像を埋め込んで3Dプリンタやエクスプローラで表示する仕組みは既にあります。
このJPEG画像は背景色が黒で保存されており、ダークモードの方は気にならないかもしれませんが、通常の配色ではエクスプローラでサムネイル表示やプレビュー表示をしたときに背景の黒が目立ってしまいます。

ということで、今回はエクスプローラで.gcodeファイルのサムネイルやプレビュー表示を綺麗に表示してみます。

表示までの流れ

黒背景を消すには、JPEGではなく背景を透過したPNG形式でサムネイル画像を作成します。

①サムネイル画像作成スクリプト

JPEG形式のサムネイル画像作成の仕組みを流用し、CuraでGコードファイルを出力する際にPNG形式のサムネイルを出力するスクリプトを作成します。

Gコードファイルには「JPEG形式のサムネイル」と「PNG形式のサムネイル」の両方が埋め込まれることになります。
3Dプリンタではこれまで通りJPEGのサムネイルを使ってプレビューが表示されます。
なお、サムネイルはGコードファイルのコメント行として出力するので、3Dプリンタ側への影響は気にしなくてよいです。
→ ① CURA用Pythonスクリプト作成(後述)

②エクスプローラのアドイン

Gコードファイル内のPNGのサムネイルをエクスプローラで表示するアドインを作成します。

既存のJPEG画像のみ埋め込まれた.gcodeファイルは、黒背景を白背景に色変換して表示するよう対応してみます。
→ ② エクスプローラのアドインを作成(後述)

①Cura用Pythonスクリプト

Ender3V2S1のHow to generate a gcode previewにあるスクリプトを参考に、PNG出力版スクリプト CreatePNGThumbnail.py を作成し、CreateJPEGThumbnail.py と同じ場所に保存します。

JPEGのサムネイルは「thumbnail begin」~「thumbnail end」の間にBASE64エンコードした文字を埋め込んでいますが、PNGでは「thumbnail(png) begin」~「thumbnail(png) end」の間に埋め込みます。


import base64
import json
from typing import Callable, TypeVar, Optional
from enum import Enum, auto

from UM.Logger import Logger
from cura.Snapshot import Snapshot
from cura.CuraVersion import CuraVersion

from ..Script import Script

T = TypeVar("T")

class Ordering(Enum):
    LESS = auto()
    EQUAL = auto()
    GREATER = auto()

def binary_search(list: list[T], compare: Callable[[T], Ordering]) -> Optional[T]:
    left: int = 0
    right: int = len(list) - 1
    while left <= right:
        middle: int = (left + right) // 2

        comparison = compare(list[middle])
        if comparison == Ordering.LESS:
            left = middle + 1
        elif comparison == Ordering.GREATER:
            right = middle - 1
        else:
            return middle
    return None

class QualityFinder:
    compute_image_size: Callable[[int], int]
    # Keep track of which quality value produced the closest match to the target_size
    closest_match: Optional[tuple[int, float]]
    # The size that the image should have
    target_size: int
    # A lower bound for the acceptable image size in percent
    # For example when the acceptable_bound is 0.9 and a value produces an image that
    # has a size of target_size * 94%, then the value is accepted, because 0.94 >= 0.9
    acceptable_bound: float

    def __init__(self, compute_image_size: Callable[[int], int], target_size: int, acceptable_bound: float = 0.9) -> None:
        self.compute_image_size = compute_image_size
        self.closest_match = None
        self.target_size = target_size
        self.acceptable_bound = acceptable_bound

    def __get_ratio(self, quality: int) -> float:
        # calculate the size of the image with the specified quality:
        current_size: int = self.compute_image_size(quality)

        # check if the new image size is closer to 100% than the previous one (but ideally less than 1.0)
        ratio = float(current_size) / float(self.target_size)
        if self.closest_match is None:
            self.closest_match = (quality, ratio)
        else:
            (_, best_ratio) = self.closest_match
            if best_ratio > 1.0 and ratio <= 1.0:
                self.closest_match = (quality, ratio)
            elif ratio >= self.acceptable_bound and abs(1.0 - ratio) < abs(1.0 - best_ratio):
                self.closest_match = (quality, ratio)

        return ratio

    def compare_quality(self, value: int) -> Ordering:
        ratio = self.__get_ratio(value)
        Logger.log("d", f"Trying quality {value}, which is {ratio * 100.0:.2f}% of {self.target_size}")

        # check if the image is too large
        if ratio > 1.0:
            return Ordering.GREATER

        if ratio >= self.acceptable_bound:
            # check if the next quality would produce an even better result
            next_ratio: float = self.__get_ratio(value + 1)
            if next_ratio <= 1.0 and next_ratio > ratio:
                return Ordering.LESS

            return Ordering.EQUAL
        else:
            return Ordering.LESS

class CreatePNGThumbnail(Script):
    def __init__(self):
        super().__init__()

    def _createSnapshot(self, width, height):
        Logger.log("d", "Creating thumbnail image...")
        try:
            return Snapshot.snapshot(width, height)
        except Exception:
            Logger.logException("w", "Failed to create snapshot image")

    def _encodeSnapshot(self, snapshot, quality=-1):

        Major=0
        Minor=0
        try:
          Major = int(CuraVersion.split(".")[0])
          Minor = int(CuraVersion.split(".")[1])
        except:
          pass

        if Major < 5 :
          from PyQt5.QtCore import QByteArray, QIODevice, QBuffer
        else :
          from PyQt6.QtCore import QByteArray, QIODevice, QBuffer

        Logger.log("d", "Encoding thumbnail image...")
        try:
            thumbnail_buffer = QBuffer()
            if Major < 5 :
              thumbnail_buffer.open(QBuffer.ReadWrite)
            else:
              thumbnail_buffer.open(QBuffer.OpenModeFlag.ReadWrite)
            thumbnail_image = snapshot
            thumbnail_image.save(thumbnail_buffer, "PNG", quality=quality)
            base64_bytes = base64.b64encode(thumbnail_buffer.data())
            base64_message = base64_bytes.decode('ascii')
            thumbnail_buffer.close()
            return base64_message
        except Exception:
            Logger.logException("w", "Failed to encode snapshot image")

    def _convertSnapshotToGcode(self, encoded_snapshot, width, height, chunk_size=78):
        gcode = []

        encoded_snapshot_length = len(encoded_snapshot)
        gcode.append(";")
        gcode.append("; thumbnail(png) begin {}x{} {}".format(
            width, height, encoded_snapshot_length))

        chunks = ["; {}".format(encoded_snapshot[i:i+chunk_size])
                  for i in range(0, len(encoded_snapshot), chunk_size)]
        gcode.extend(chunks)

        gcode.append("; thumbnail(png) end")
        gcode.append(";")
        gcode.append("")

        return gcode

    def getSettingDataString(self):
        return json.dumps({
            "name": "Create PNG Thumbnail",
            "key": "CreatePNGThumbnail",
            "metadata": {},
            "version": 2,
            "settings":
            {
                "width":
                {
                    "label": "Width",
                    "description": "Width of the generated thumbnail",
                    "unit": "px",
                    "type": "int",
                    "default_value": 200,
                    "minimum_value": "0",
                    "minimum_value_warning": "12",
                    "maximum_value_warning": "800"
                },
                "height":
                {
                    "label": "Height",
                    "description": "Height of the generated thumbnail",
                    "unit": "px",
                    "type": "int",
                    "default_value": 200,
                    "minimum_value": "0",
                    "minimum_value_warning": "12",
                    "maximum_value_warning": "600"
                }
            }
        }, indent=4)

    def execute(self, data):
        width = self.getSettingValueByKey("width")
        height = self.getSettingValueByKey("height")

        Logger.log("d", f"Options: width={width}, height={height}")

        snapshot = self._createSnapshot(width, height)
        if snapshot:
            encoded_snapshot = self._encodeSnapshot(snapshot)

            snapshot_gcode = self._convertSnapshotToGcode(
                encoded_snapshot, width, height)

            for layer in data:
                layer_index = data.index(layer)
                lines = data[layer_index].split("\n")
                for line in lines:
                    if line.startswith(";Generated with Cura"):
                        line_index = lines.index(line)
                        insert_index = line_index + 1
                        lines[insert_index:insert_index] = snapshot_gcode
                        break

                final_lines = "\n".join(lines)
                data[layer_index] = final_lines

        return data

ファイルを格納したらCuraを再起動して、
メニュー ExtensionsPost ProcessingModify G-Code から作成したスクリプトを追加します。


右側の width, height でで生成するサムネイル画像のサイズを設定できます。
プレビューを表示する場合は、多少大きい値でもいいですね (ただしGコードファイルサイズは増えます)。
私は 400 x 400 にしてみました。 ※サイズが大きいとサムネイル画像が出力されない場合がありました → 300 x 300 に下げて様子見中。

エクスプローラは一度生成したサムネイルはキャッシュするので、表示するたびにサムネイル画像を生成するわけではなく、サムネイルサイズを増やすことによるPC負荷はそれほど気にしなくて良いです。

上図のようにPNGの項目を上側に移動して保存してください。
ここの一覧で下にある項目ほどGコードファイル内ではファイルの先頭側に記録されるようで、もしPNGの項目が下にあってPNGのサイズが大きい場合、JPEGのプレビューがだいぶ下の方に記録されることになり、プリンタがJPEGプレビューの検索を途中で諦めるのかプリンタの画面でプレビューが表示されませんでした。

② エクスプローラのアドイン

GcodeThumbnailExtensionというアドインのCgodeThumbnailHandler.csに以下の改造を行います。

  • PNGサムネイルの読み込み処理を追加
  • JPEGとPNGの両方のサムネイル画像がある場合はPNGを優先
  • PNGのサムネイルがない場合は既存の動作とする(JPEGが複数ある場合は最後のJPEGを採用するロジック)
  • サムネイルのモデルを 黄色 → オレンジ色 に変換し、JPEGのサムネイルだった場合は背景色を 黒色 → 白色 に変換する

3mfのサムネイルはモデルが黄色で、Curaが出力するものも黄色だったため、区別しやすくするためGコードファイルはオレンジ色にしました。

作成したものを載せておきます → ダウンロード

※オリジナルのdllと差し替えて、管理者権限で registre.bat を実行すればインストールできます。

動作確認

エクスプローラを開いて、①のCuraスクリプトで出力させた.gcodeファイルを選択します。
プレビューペインを表示するにはリボンメニューの「表示」の「ペイン」にある「プレビューウィンドウ」を有効にします。

背景を透過し、オレンジ色のモデルをサムネイルとプレビューに表示できました。

JPEGのサムネイルしか埋め込まれていない.gcodeファイルも問題なく表示できました。