GコードファイルにJPEG形式のサムネイル画像を埋め込んで3Dプリンタやエクスプローラで表示する仕組みは既にあります。
このJPEG画像は背景色が黒で保存されており、ダークモードの方は気にならないかもしれませんが、通常の配色ではエクスプローラでサムネイル表示やプレビュー表示をしたときに背景の黒が目立ってしまいます。
ということで、今回はエクスプローラで.gcodeファイルのサムネイルやプレビュー表示を綺麗に表示してみます。
黒背景を消すには、JPEGではなく背景を透過したPNG形式でサムネイル画像を作成します。
JPEG形式のサムネイル画像作成の仕組みを流用し、CuraでGコードファイルを出力する際にPNG形式のサムネイルを出力するスクリプトを作成します。
Gコードファイルには「JPEG形式のサムネイル」と「PNG形式のサムネイル」の両方が埋め込まれることになります。
3Dプリンタではこれまで通りJPEGのサムネイルを使ってプレビューが表示されます。
なお、サムネイルはGコードファイルのコメント行として出力するので、3Dプリンタ側への影響は気にしなくてよいです。
→ ① CURA用Pythonスクリプト作成(後述)
Gコードファイル内のPNGのサムネイルをエクスプローラで表示するアドインを作成します。
既存のJPEG画像のみ埋め込まれた.gcodeファイルは、黒背景を白背景に色変換して表示するよう対応してみます。
→ ② エクスプローラのアドインを作成(後述)
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を再起動して、
メニュー → → から作成したスクリプトを追加します。
右側の width, height でで生成するサムネイル画像のサイズを設定できます。
プレビューを表示する場合は、多少大きい値でもいいですね (ただしGコードファイルサイズは増えます)。
私は 400 x 400 にしてみました。 ※サイズが大きいとサムネイル画像が出力されない場合がありました → 300 x 300 に下げて様子見中。
エクスプローラは一度生成したサムネイルはキャッシュするので、表示するたびにサムネイル画像を生成するわけではなく、サムネイルサイズを増やすことによるPC負荷はそれほど気にしなくて良いです。
上図のようにPNGの項目を上側に移動して保存してください。
ここの一覧で下にある項目ほどGコードファイル内ではファイルの先頭側に記録されるようで、もしPNGの項目が下にあってPNGのサイズが大きい場合、JPEGのプレビューがだいぶ下の方に記録されることになり、プリンタがJPEGプレビューの検索を途中で諦めるのかプリンタの画面でプレビューが表示されませんでした。
GcodeThumbnailExtensionというアドインのCgodeThumbnailHandler.csに以下の改造を行います。
3mfのサムネイルはモデルが黄色で、Curaが出力するものも黄色だったため、区別しやすくするためGコードファイルはオレンジ色にしました。
作成したものを載せておきます → ダウンロード
※オリジナルのdllと差し替えて、管理者権限で registre.bat を実行すればインストールできます。
エクスプローラを開いて、①のCuraスクリプトで出力させた.gcodeファイルを選択します。
プレビューペインを表示するにはリボンメニューの「表示」の「ペイン」にある「プレビューウィンドウ」を有効にします。
背景を透過し、オレンジ色のモデルをサムネイルとプレビューに表示できました。
JPEGのサムネイルしか埋め込まれていない.gcodeファイルも問題なく表示できました。