From c313f1c60edb0bb3cbef04971daa7a5aa9fdbef8 Mon Sep 17 00:00:00 2001 From: Fernando Date: Sun, 10 May 2026 00:00:36 -0400 Subject: [PATCH 1/3] feat(materials): texture channel packing (Phase 5 slice G) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pack 1-4 grayscale source images into the RGBA channels of a single output texture. Common indie game-dev pattern — Unity ORM (Occlusion R / Roughness G / Metallic B), Unreal MR (Metallic R / Roughness G / unused B), per-engine PBR conventions. Pure-data packer (src/TextureChannelPacker.{h,cpp}): - PackingSpec with per-channel `path` (sampled as Rec.601 luminance) / `constantValue` / `invert`, plus overall outputWidth/Height + includeAlpha. - pack() returns a QImage; packToFile() writes PNG/TGA/JPG/BMP via QImageWriter. - Smaller sources are bilinear-scaled up to the largest source; all-constants → 256x256 default. Tests (src/TextureChannelPacker_test.cpp): all-constants default, ORM 3-channel pack, invert (rough→gloss), mismatched-size scaling, missing-file error, explicit output size, RGB888 vs RGBA8888, file write round-trip, empty path, unsupported extension. CLI subcommand `qtmesh pack-textures`: qtmesh pack-textures --r ao.png --g rough.png --b metal.png -o orm.png qtmesh pack-textures --r metal.png --g rough.png --bc 0 --no-alpha -o mr.png qtmesh pack-textures --r rough.png --invert-r -o gloss.png MCP tool `pack_textures` with full schema (paths, constants, inverts, width/height, include_alpha, output) registered alongside the existing material tools. Material Mode Inspector hook: "Pack Texture Channels…" button in the Mode Tools tab right after "Open Material Editor". Opens a top-level modal Window styled with the same Inspector primitives (Rectangle + Text + MouseArea over PropertiesPanelController.* colours) used throughout the rest of the inspector — InspectorButton, InspectorLabel, InspectorReadOnlyField, InspectorTextField, InspectorPercentField with up/down arrows (TransformField idiom), InspectorCheckBox. Documentation: CLAUDE.md CLI examples, recognised-subcommand list, architecture entry for TextureChannelPacker. Co-Authored-By: Claude Opus 4.7 (1M context) --- CLAUDE.md | 8 +- qml/PropertiesPanel.qml | 57 +++ qml/TextureChannelPackerDialog.qml | 571 +++++++++++++++++++++++++++++ qml/qmldir | 1 + src/CLIPipeline.cpp | 69 ++++ src/CLIPipeline.h | 4 + src/CMakeLists.txt | 2 + src/MCPServer.cpp | 97 ++++- src/MCPServer.h | 3 + src/MaterialEditorQML.cpp | 61 +++ src/MaterialEditorQML.h | 22 ++ src/TextureChannelPacker.cpp | 187 ++++++++++ src/TextureChannelPacker.h | 54 +++ src/TextureChannelPacker_test.cpp | 190 ++++++++++ src/main.cpp | 2 +- src/qml_resources.qrc | 1 + 16 files changed, 1325 insertions(+), 4 deletions(-) create mode 100644 qml/TextureChannelPackerDialog.qml create mode 100644 src/TextureChannelPacker.cpp create mode 100644 src/TextureChannelPacker.h create mode 100644 src/TextureChannelPacker_test.cpp diff --git a/CLAUDE.md b/CLAUDE.md index c3804b5d0..a25f27865 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -71,9 +71,12 @@ qtmesh scan ./assets --sarif report.sarif # write SARIF report to file qtmesh scan ./assets --fix --dry-run # preview auto-fixes qtmesh scan ./assets --include "*.fbx,*.glb" # filter by extension qtmesh scan ./assets --fail-on warning # exit 1 on warnings or errors +qtmesh pack-textures --r ao.png --g rough.png --b metal.png -o orm.png # pack 3 grayscale maps into RGB (Unity ORM) +qtmesh pack-textures --r metal.png --g rough.png --bc 0 --no-alpha -o mr.png # Unreal MR (constant blue) +qtmesh pack-textures --r rough.png --invert-r -o gloss.png # invert: roughness → glossiness ``` -CLI mode is activated by: (1) invoking via the `qtmesh` symlink, (2) passing `--cli`, or (3) using a recognized subcommand (`info`, `fix`, `convert`, `anim`, `validate`, `lod`, `pose`, `scan`, `material`) as the first argument. Use `--verbose` to see Ogre/engine debug output. Use `--no-telemetry` to permanently opt out of anonymous usage data collection. +CLI mode is activated by: (1) invoking via the `qtmesh` symlink, (2) passing `--cli`, or (3) using a recognized subcommand (`info`, `fix`, `convert`, `anim`, `validate`, `lod`, `pose`, `scan`, `material`, `pack-textures`) as the first argument. Use `--verbose` to see Ogre/engine debug output. Use `--no-telemetry` to permanently opt out of anonymous usage data collection. If Xcode SDK is updated, clear CMake cache (`rm build_local/CMakeCache.txt`) and reconfigure. @@ -159,6 +162,7 @@ Three singletons manage core state. All run on the main thread. Access via `Clas - **BatchExporter** (`src/BatchExporter.h/cpp`): Multi-file conversion wrapping CLIPipeline. Supports progress reporting. - **MaterialPresetLibrary** (`src/MaterialPresetLibrary.h/cpp`): QML_SINGLETON providing one-click material presets (Plastic, Metal, Wood, Glass, Unlit, Wireframe). +- **TextureChannelPacker** (`src/TextureChannelPacker.h/cpp`, slice G): pure-data packer that takes 1-4 grayscale source images (or constants) and writes a single packed RGBA texture (PNG/TGA/JPG). Each output channel is sampled via Rec.601 luminance from its source image, with an optional invert flag (useful for roughness↔glossiness). Smaller sources are bilinear-scaled up to match the largest input. Surfaced via the `qtmesh pack-textures` CLI subcommand, the `pack_textures` MCP tool, and the "Pack Channels…" button in the Material Editor. ### MCP Server @@ -171,7 +175,7 @@ Three singletons manage core state. All run on the main thread. Access via `Clas ### CLI Pipeline - **CLIPipeline** (`src/CLIPipeline.h/cpp`): Headless command-line interface for mesh operations. All static methods — entry point is `CLIPipeline::run(argc, argv)`. -- Subcommands: `info`, `fix`, `convert`, `anim` (list/rename/merge), `validate`, `lod`, `pose`, `scan`. +- Subcommands: `info`, `fix`, `convert`, `anim` (list/rename/merge), `validate`, `lod`, `pose`, `scan`, `material`, `pack-textures`. - Activated via `qtmesh` symlink (created at build time), `--cli` flag, or recognized subcommand as first arg. - Redirects stdout to stderr (Ogre/Qt noise) and writes CLI output to the original stdout fd. Uses `_exit()` to avoid Ogre static destructor crashes on macOS. - **AnimationMerger** (`src/AnimationMerger.h/cpp`): Public `renameAnimation()` static method used by both CLI and GUI for animation renaming. diff --git a/qml/PropertiesPanel.qml b/qml/PropertiesPanel.qml index c7d27425d..98b8566ca 100644 --- a/qml/PropertiesPanel.qml +++ b/qml/PropertiesPanel.qml @@ -4,6 +4,7 @@ import QtQuick.Layouts import PropertiesPanel 1.0 import AnimationControl 1.0 import EditorMode 1.0 +import MaterialEditorQML 1.0 Rectangle { id: root @@ -1694,6 +1695,62 @@ Rectangle { onClicked: PropertiesPanelController.triggerMaterialEditor() } } + + // Slice G: Texture Channel Packer — utility for combining + // grayscale source images into a single packed RGBA texture + // (e.g. ORM = AO+Roughness+Metallic). Lives in Mode Tools + // because it operates on PNG/TGA files on disk, not on the + // currently-selected submesh's TUS. + Rectangle { + width: Math.min(parent.width - 16, packLabel.implicitWidth + 16) + height: 26 + radius: 3 + color: packMa.containsMouse + ? PropertiesPanelController.highlightColor + : PropertiesPanelController.headerColor + border.color: PropertiesPanelController.borderColor + border.width: 1 + + Text { + id: packLabel + anchors.centerIn: parent + text: "Pack Texture Channels…" + color: PropertiesPanelController.textColor + font.pixelSize: 11 + } + MouseArea { + id: packMa + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: root.openTextureChannelPackerDialog() + ToolTip.visible: containsMouse + ToolTip.delay: 500 + ToolTip.text: "Pack 1–4 grayscale source images into a single RGBA texture (e.g. Unity ORM = AO+Roughness+Metallic, Unreal MR)." + } + } + } + } + + // The dialog is loaded by URL (rather than as a typed component) + // because the Properties Panel's QML engine doesn't have the + // MaterialEditorQML module's import path set up — only its singleton + // C++ type. Using a Loader with a qrc:// source bypasses module + // resolution while keeping the dialog as a top-level child so it + // overlays the viewport correctly. + Loader { + id: textureChannelPackerLoader + active: false + anchors.centerIn: parent + source: "qrc:/MaterialEditorQML/TextureChannelPackerDialog.qml" + onLoaded: if (item && item.open) item.open() + } + + function openTextureChannelPackerDialog() { + if (!textureChannelPackerLoader.active) { + textureChannelPackerLoader.active = true + } else if (textureChannelPackerLoader.item) { + textureChannelPackerLoader.item.open() } } diff --git a/qml/TextureChannelPackerDialog.qml b/qml/TextureChannelPackerDialog.qml new file mode 100644 index 000000000..5b4ea33f8 --- /dev/null +++ b/qml/TextureChannelPackerDialog.qml @@ -0,0 +1,571 @@ +import QtQuick +import QtQuick.Layouts +import QtQuick.Window +import MaterialEditorQML 1.0 +import PropertiesPanel 1.0 + +// Top-level Window so the dialog has its own OS-level frame and isn't +// constrained by the docked PropertiesPanel widget. Slice G surfaces +// this from Material Mode → Mode Tools → "Pack Texture Channels…". +// +// Styled to match the Inspector look (Rectangle + Text + MouseArea +// primitives over PropertiesPanelController.* colors), not the Material +// Editor's QtQuick.Controls look. +Window { + id: dialog + title: "Pack Texture Channels" + width: 580 + height: 380 + minimumWidth: 480 + minimumHeight: 350 + flags: Qt.Dialog + modality: Qt.ApplicationModal + color: PropertiesPanelController.panelColor + + // Per-channel state. + property string redPath: "" + property string greenPath: "" + property string bluePath: "" + property string alphaPath: "" + property real redConstant: 0.0 + property real greenConstant: 0.0 + property real blueConstant: 0.0 + property real alphaConstant: 1.0 + property bool invertRed: false + property bool invertGreen: false + property bool invertBlue: false + property bool invertAlpha: false + property bool includeAlpha: true + property string outputPath: "" + + function open() { + dialog.show() + dialog.raise() + dialog.requestActivate() + } + + // ── Inline styled primitives ─────────────────────────────────── + + component InspectorButton: Rectangle { + id: btn + property string label: "" + property bool buttonEnabled: true + signal clicked() + height: 24 + radius: 3 + color: btnMa.containsMouse && buttonEnabled + ? PropertiesPanelController.highlightColor + : PropertiesPanelController.headerColor + border.color: PropertiesPanelController.borderColor + border.width: 1 + opacity: buttonEnabled ? 1.0 : 0.45 + Text { + anchors.centerIn: parent + text: btn.label + color: PropertiesPanelController.textColor + font.pixelSize: 11 + } + MouseArea { + id: btnMa + anchors.fill: parent + hoverEnabled: true + enabled: btn.buttonEnabled + cursorShape: btn.buttonEnabled ? Qt.PointingHandCursor : Qt.ForbiddenCursor + onClicked: btn.clicked() + } + } + + component InspectorLabel: Text { + color: PropertiesPanelController.textColor + font.pixelSize: 11 + } + + // Read-only path field with focus highlight. + component InspectorReadOnlyField: Rectangle { + property alias text: t.text + property string placeholderText: "" + property bool fieldEnabled: true + height: 24 + color: PropertiesPanelController.inputColor + border.color: PropertiesPanelController.borderColor + border.width: 1 + radius: 3 + opacity: fieldEnabled ? 1.0 : 0.5 + Text { + id: t + anchors.fill: parent + anchors.leftMargin: 6 + anchors.rightMargin: 6 + color: PropertiesPanelController.textColor + font.pixelSize: 11 + verticalAlignment: Text.AlignVCenter + elide: Text.ElideMiddle + } + Text { + text: parent.placeholderText + visible: t.text.length === 0 + anchors.fill: parent + anchors.leftMargin: 6 + color: PropertiesPanelController.textColor + opacity: 0.45 + font.pixelSize: 11 + verticalAlignment: Text.AlignVCenter + } + } + + // Editable text field (used for the output path). + component InspectorTextField: Rectangle { + id: tfRoot + property alias text: input.text + property string placeholderText: "" + signal editedText(string newText) + height: 24 + color: PropertiesPanelController.inputColor + border.color: input.activeFocus + ? PropertiesPanelController.highlightColor + : PropertiesPanelController.borderColor + border.width: 1 + radius: 3 + TextInput { + id: input + anchors.fill: parent + anchors.leftMargin: 6 + anchors.rightMargin: 6 + color: PropertiesPanelController.textColor + font.pixelSize: 11 + verticalAlignment: TextInput.AlignVCenter + selectByMouse: true + clip: true + onTextEdited: tfRoot.editedText(text) + } + Text { + text: tfRoot.placeholderText + visible: input.text.length === 0 && !input.activeFocus + anchors.fill: parent + anchors.leftMargin: 6 + color: PropertiesPanelController.textColor + opacity: 0.45 + font.pixelSize: 11 + verticalAlignment: Text.AlignVCenter + } + } + + // Integer percent field with up/down arrows. Same idiom as + // TransformField but simpler (0..100 only, no decimals). + component InspectorPercentField: Rectangle { + id: pctRoot + property int value: 0 + property bool fieldEnabled: true + signal newValue(int v) + height: 24 + color: PropertiesPanelController.inputColor + border.color: pctInput.activeFocus + ? PropertiesPanelController.highlightColor + : PropertiesPanelController.borderColor + border.width: 1 + radius: 3 + opacity: fieldEnabled ? 1.0 : 0.5 + + function clamp(v) { return Math.max(0, Math.min(100, v)) } + + TextInput { + id: pctInput + anchors.left: parent.left + anchors.right: arrows.left + anchors.top: parent.top + anchors.bottom: parent.bottom + anchors.leftMargin: 6 + anchors.rightMargin: 4 + text: pctRoot.value + color: PropertiesPanelController.textColor + font.pixelSize: 11 + verticalAlignment: TextInput.AlignVCenter + horizontalAlignment: TextInput.AlignRight + selectByMouse: true + readOnly: !pctRoot.fieldEnabled + validator: IntValidator { bottom: 0; top: 100 } + onEditingFinished: { + const v = pctRoot.clamp(parseInt(text) || 0) + text = v + pctRoot.newValue(v) + } + Keys.onUpPressed: { const v = pctRoot.clamp(pctRoot.value + 5); pctRoot.newValue(v) } + Keys.onDownPressed: { const v = pctRoot.clamp(pctRoot.value - 5); pctRoot.newValue(v) } + } + + Text { + visible: pctRoot.fieldEnabled + anchors.right: arrows.left + anchors.verticalCenter: parent.verticalCenter + anchors.rightMargin: 1 + text: "%" + color: PropertiesPanelController.textColor + font.pixelSize: 10 + opacity: 0.55 + } + + Column { + id: arrows + anchors.right: parent.right + anchors.top: parent.top + anchors.bottom: parent.bottom + width: 14 + + Rectangle { + width: parent.width + height: parent.height / 2 + color: upMa.containsMouse && pctRoot.fieldEnabled + ? Qt.lighter(PropertiesPanelController.panelColor, 1.2) + : PropertiesPanelController.panelColor + border.color: PropertiesPanelController.borderColor + border.width: 1 + Text { + anchors.centerIn: parent + text: "▲" + color: PropertiesPanelController.textColor + font.pixelSize: 6 + } + MouseArea { + id: upMa + anchors.fill: parent + hoverEnabled: true + enabled: pctRoot.fieldEnabled + onClicked: pctRoot.newValue(pctRoot.clamp(pctRoot.value + 5)) + } + } + + Rectangle { + width: parent.width + height: parent.height / 2 + color: downMa.containsMouse && pctRoot.fieldEnabled + ? Qt.lighter(PropertiesPanelController.panelColor, 1.2) + : PropertiesPanelController.panelColor + border.color: PropertiesPanelController.borderColor + border.width: 1 + Text { + anchors.centerIn: parent + text: "▼" + color: PropertiesPanelController.textColor + font.pixelSize: 6 + } + MouseArea { + id: downMa + anchors.fill: parent + hoverEnabled: true + enabled: pctRoot.fieldEnabled + onClicked: pctRoot.newValue(pctRoot.clamp(pctRoot.value - 5)) + } + } + } + } + + component InspectorCheckBox: Rectangle { + id: cbRoot + property bool checked: false + property string label: "" + property bool boxEnabled: true + signal toggled(bool newChecked) + // Hit area for both the box and label. + height: 22 + color: "transparent" + // When there's a label, the box+label sits at the left edge so + // labels read naturally. When there's no label (table column + // usage), the box is centered horizontally. + Row { + spacing: 6 + anchors.left: cbRoot.label.length > 0 ? parent.left : undefined + anchors.verticalCenter: parent.verticalCenter + anchors.horizontalCenter: cbRoot.label.length === 0 ? parent.horizontalCenter : undefined + Rectangle { + width: 16; height: 16 + anchors.verticalCenter: parent.verticalCenter + color: cbRoot.checked && cbRoot.boxEnabled + ? PropertiesPanelController.highlightColor + : PropertiesPanelController.inputColor + border.color: PropertiesPanelController.borderColor + border.width: 1 + radius: 2 + opacity: cbRoot.boxEnabled ? 1.0 : 0.45 + Text { + anchors.centerIn: parent + visible: cbRoot.checked + text: "✓" + color: PropertiesPanelController.textColor + font.pixelSize: 12 + font.bold: true + } + } + Text { + visible: cbRoot.label.length > 0 + anchors.verticalCenter: parent.verticalCenter + text: cbRoot.label + color: PropertiesPanelController.textColor + font.pixelSize: 11 + opacity: cbRoot.boxEnabled ? 1.0 : 0.45 + } + } + MouseArea { + anchors.fill: parent + hoverEnabled: true + enabled: cbRoot.boxEnabled + cursorShape: cbRoot.boxEnabled ? Qt.PointingHandCursor : Qt.ForbiddenCursor + onClicked: cbRoot.toggled(!cbRoot.checked) + } + } + + // ── Layout ──────────────────────────────────────────────────── + + ColumnLayout { + anchors.fill: parent + anchors.margins: 16 + spacing: 10 + + InspectorLabel { + text: "Pack 1–4 grayscale source images into a single RGBA texture. " + + "Each output channel takes either a source image (sampled as luminance) " + + "or a constant 0–100% value when no path is set." + wrapMode: Text.WordWrap + Layout.fillWidth: true + opacity: 0.85 + } + + // Header row labels — column widths must match the rows below. + RowLayout { + spacing: 8 + Layout.fillWidth: true + InspectorLabel { text: "Ch."; Layout.preferredWidth: 24; font.bold: true } + InspectorLabel { text: "Source"; Layout.fillWidth: true; font.bold: true } + Item { Layout.preferredWidth: 80 } + InspectorLabel { text: "Const"; Layout.preferredWidth: 92; horizontalAlignment: Text.AlignHCenter; font.bold: true } + InspectorLabel { text: "Invert"; Layout.preferredWidth: 56; horizontalAlignment: Text.AlignHCenter; font.bold: true } + } + + // Red row + RowLayout { + spacing: 8 + Layout.fillWidth: true + Rectangle { + width: 24; height: 22 + radius: 2 + color: "#ee7777" + Layout.preferredWidth: 24 + Text { anchors.centerIn: parent; text: "R"; color: "white"; font.pixelSize: 11; font.bold: true } + } + InspectorReadOnlyField { + Layout.fillWidth: true + text: dialog.redPath + placeholderText: "(empty → use constant)" + } + InspectorButton { + label: "Browse…" + Layout.preferredWidth: 80 + onClicked: { + const picked = MaterialEditorQML.openFileDialog() + if (picked && picked.length > 0) dialog.redPath = picked + } + } + InspectorPercentField { + Layout.preferredWidth: 92 + value: Math.round(dialog.redConstant * 100) + fieldEnabled: dialog.redPath === "" + onNewValue: dialog.redConstant = v / 100.0 + } + InspectorCheckBox { + Layout.preferredWidth: 56 + checked: dialog.invertRed + onToggled: dialog.invertRed = newChecked + } + } + + // Green row + RowLayout { + spacing: 8 + Layout.fillWidth: true + Rectangle { + width: 24; height: 22 + radius: 2 + color: "#77cc77" + Layout.preferredWidth: 24 + Text { anchors.centerIn: parent; text: "G"; color: "white"; font.pixelSize: 11; font.bold: true } + } + InspectorReadOnlyField { + Layout.fillWidth: true + text: dialog.greenPath + placeholderText: "(empty → use constant)" + } + InspectorButton { + label: "Browse…" + Layout.preferredWidth: 80 + onClicked: { + const picked = MaterialEditorQML.openFileDialog() + if (picked && picked.length > 0) dialog.greenPath = picked + } + } + InspectorPercentField { + Layout.preferredWidth: 92 + value: Math.round(dialog.greenConstant * 100) + fieldEnabled: dialog.greenPath === "" + onNewValue: dialog.greenConstant = v / 100.0 + } + InspectorCheckBox { + Layout.preferredWidth: 56 + checked: dialog.invertGreen + onToggled: dialog.invertGreen = newChecked + } + } + + // Blue row + RowLayout { + spacing: 8 + Layout.fillWidth: true + Rectangle { + width: 24; height: 22 + radius: 2 + color: "#7799ee" + Layout.preferredWidth: 24 + Text { anchors.centerIn: parent; text: "B"; color: "white"; font.pixelSize: 11; font.bold: true } + } + InspectorReadOnlyField { + Layout.fillWidth: true + text: dialog.bluePath + placeholderText: "(empty → use constant)" + } + InspectorButton { + label: "Browse…" + Layout.preferredWidth: 80 + onClicked: { + const picked = MaterialEditorQML.openFileDialog() + if (picked && picked.length > 0) dialog.bluePath = picked + } + } + InspectorPercentField { + Layout.preferredWidth: 92 + value: Math.round(dialog.blueConstant * 100) + fieldEnabled: dialog.bluePath === "" + onNewValue: dialog.blueConstant = v / 100.0 + } + InspectorCheckBox { + Layout.preferredWidth: 56 + checked: dialog.invertBlue + onToggled: dialog.invertBlue = newChecked + } + } + + // Alpha row + RowLayout { + spacing: 8 + Layout.fillWidth: true + Rectangle { + width: 24; height: 22 + radius: 2 + color: PropertiesPanelController.headerColor + border.color: PropertiesPanelController.borderColor + border.width: 1 + Layout.preferredWidth: 24 + opacity: dialog.includeAlpha ? 1.0 : 0.5 + Text { anchors.centerIn: parent; text: "A"; color: PropertiesPanelController.textColor; font.pixelSize: 11; font.bold: true } + } + InspectorReadOnlyField { + Layout.fillWidth: true + text: dialog.alphaPath + placeholderText: "(empty → use constant)" + fieldEnabled: dialog.includeAlpha + } + InspectorButton { + label: "Browse…" + Layout.preferredWidth: 80 + buttonEnabled: dialog.includeAlpha + onClicked: { + const picked = MaterialEditorQML.openFileDialog() + if (picked && picked.length > 0) dialog.alphaPath = picked + } + } + InspectorPercentField { + Layout.preferredWidth: 92 + value: Math.round(dialog.alphaConstant * 100) + fieldEnabled: dialog.includeAlpha && dialog.alphaPath === "" + onNewValue: dialog.alphaConstant = v / 100.0 + } + InspectorCheckBox { + Layout.preferredWidth: 56 + checked: dialog.invertAlpha + boxEnabled: dialog.includeAlpha + onToggled: dialog.invertAlpha = newChecked + } + } + + // Include-alpha master toggle + InspectorCheckBox { + Layout.fillWidth: true + checked: dialog.includeAlpha + label: "Include alpha channel (RGBA8) — uncheck for RGB888" + onToggled: dialog.includeAlpha = newChecked + } + + Rectangle { Layout.fillWidth: true; height: 1; color: PropertiesPanelController.borderColor } + + // Output path + RowLayout { + spacing: 8 + Layout.fillWidth: true + InspectorLabel { + text: "Output:" + Layout.preferredWidth: 64 + font.bold: true + } + InspectorTextField { + Layout.fillWidth: true + text: dialog.outputPath + placeholderText: "/path/to/packed.png" + onEditedText: dialog.outputPath = newText + } + InspectorButton { + label: "Save As…" + Layout.preferredWidth: 80 + onClicked: { + const picked = MaterialEditorQML.savePackedTextureDialog() + if (picked && picked.length > 0) dialog.outputPath = picked + } + } + } + + InspectorLabel { + id: statusLabel + wrapMode: Text.WordWrap + Layout.fillWidth: true + } + + Item { Layout.fillHeight: true } + + // Action buttons + RowLayout { + spacing: 8 + Layout.fillWidth: true + Item { Layout.fillWidth: true } + InspectorButton { + label: "Close" + Layout.preferredWidth: 80 + onClicked: dialog.close() + } + InspectorButton { + label: "Pack" + Layout.preferredWidth: 80 + buttonEnabled: dialog.outputPath !== "" + onClicked: { + const err = MaterialEditorQML.packTextureChannels( + dialog.redPath, dialog.greenPath, dialog.bluePath, dialog.alphaPath, + dialog.redConstant, dialog.greenConstant, dialog.blueConstant, dialog.alphaConstant, + dialog.invertRed, dialog.invertGreen, dialog.invertBlue, dialog.invertAlpha, + dialog.includeAlpha, dialog.outputPath) + if (err.length > 0) { + statusLabel.text = "Error: " + err + statusLabel.color = "#ee5555" + } else { + statusLabel.text = "Saved to " + dialog.outputPath + statusLabel.color = "#55cc55" + } + } + } + } + } +} diff --git a/qml/qmldir b/qml/qmldir index f0359a5f0..320c0f7d8 100644 --- a/qml/qmldir +++ b/qml/qmldir @@ -3,6 +3,7 @@ MaterialEditorWindow 1.0 MaterialEditorWindow.qml PassPropertiesPanel 1.0 PassPropertiesPanel.qml TexturePropertiesPanel 1.0 TexturePropertiesPanel.qml AISettingsDialog 1.0 AISettingsDialog.qml +TextureChannelPackerDialog 1.0 TextureChannelPackerDialog.qml MaterialListModal 1.0 MaterialListModal.qml ThemedButton 1.0 ThemedButton.qml ThemedComboBox 1.0 ThemedComboBox.qml diff --git a/src/CLIPipeline.cpp b/src/CLIPipeline.cpp index acced6244..adf8b91ce 100644 --- a/src/CLIPipeline.cpp +++ b/src/CLIPipeline.cpp @@ -10,6 +10,7 @@ #include "ScanEngine.h" #include "FBX/FBXExporter.h" #include "MaterialPresetLibrary.h" +#include "TextureChannelPacker.h" #include "QtMeshCloudClient.h" #include #include @@ -958,6 +959,7 @@ int CLIPipeline::run(int argc, char* argv[]) else if (cmd == "pose") rc = cmdPose(argc, argv); else if (cmd == "scan") rc = cmdScan(argc, argv); else if (cmd == "material") rc = cmdMaterial(argc, argv); + else if (cmd == "pack-textures") rc = cmdPackTextures(argc, argv); if (rc < 0) { err() << "Error: Unknown command '" << cmd << "'" << Qt::endl; @@ -2597,6 +2599,73 @@ int CLIPipeline::cmdMaterial(int argc, char* argv[]) return 0; } +int CLIPipeline::cmdPackTextures(int argc, char* argv[]) +{ + // Parse: + // pack-textures --r ao.png --g rough.png --b metal.png [--a path] + // [--rc <0..1>] [--gc <0..1>] [--bc <0..1>] [--ac <0..1>] + // [--invert-r] [--invert-g] [--invert-b] [--invert-a] + // [--width N] [--height N] [--no-alpha] -o out.png + TextureChannelPacker::PackingSpec spec; + QString outputPath; + + auto setPath = [](TextureChannelPacker::ChannelSource& dst, const QString& v) { + dst.path = v; + }; + auto setConst = [](TextureChannelPacker::ChannelSource& dst, const QString& v) { + dst.constantValue = v.toFloat(); + }; + + for (int i = 1; i < argc; ++i) { + QString arg(argv[i]); + if (arg == "pack-textures" || arg == "--cli") continue; + if ((arg == "--r" || arg == "--red") && i + 1 < argc) { setPath(spec.red, QString(argv[++i])); continue; } + if ((arg == "--g" || arg == "--green") && i + 1 < argc) { setPath(spec.green, QString(argv[++i])); continue; } + if ((arg == "--b" || arg == "--blue") && i + 1 < argc) { setPath(spec.blue, QString(argv[++i])); continue; } + if ((arg == "--a" || arg == "--alpha") && i + 1 < argc) { setPath(spec.alpha, QString(argv[++i])); continue; } + if (arg == "--rc" && i + 1 < argc) { setConst(spec.red, QString(argv[++i])); continue; } + if (arg == "--gc" && i + 1 < argc) { setConst(spec.green, QString(argv[++i])); continue; } + if (arg == "--bc" && i + 1 < argc) { setConst(spec.blue, QString(argv[++i])); continue; } + if (arg == "--ac" && i + 1 < argc) { setConst(spec.alpha, QString(argv[++i])); continue; } + if (arg == "--invert-r") { spec.red.invert = true; continue; } + if (arg == "--invert-g") { spec.green.invert = true; continue; } + if (arg == "--invert-b") { spec.blue.invert = true; continue; } + if (arg == "--invert-a") { spec.alpha.invert = true; continue; } + if (arg == "--width" && i + 1 < argc) { spec.outputWidth = QString(argv[++i]).toInt(); continue; } + if (arg == "--height" && i + 1 < argc) { spec.outputHeight = QString(argv[++i]).toInt(); continue; } + if (arg == "--no-alpha") { spec.includeAlpha = false; continue; } + if ((arg == "-o" || arg == "--output") && i + 1 < argc) { + outputPath = QString(argv[++i]); + continue; + } + } + + if (outputPath.isEmpty()) { + err() << "Error: missing -o/--output." << Qt::endl; + err() << "Usage: qtmesh pack-textures --r [--g --b --a ]" << Qt::endl; + err() << " [--rc/--gc/--bc/--ac <0..1>]" << Qt::endl; + err() << " [--invert-r/g/b/a]" << Qt::endl; + err() << " [--width N --height N] [--no-alpha]" << Qt::endl; + err() << " -o " << Qt::endl; + return 2; + } + + SentryReporter::addBreadcrumb("cli.pack-textures", + QString("Pack textures -> %1").arg(QFileInfo(outputPath).fileName())); + + auto r = TextureChannelPacker::packToFile(spec, outputPath); + if (!r.ok) { + err() << "Error: " << r.error << Qt::endl; + return 1; + } + + cliWrite(QString("Packed %1x%2 -> %3\n") + .arg(r.usedWidth) + .arg(r.usedHeight) + .arg(QFileInfo(outputPath).fileName())); + return 0; +} + int CLIPipeline::cmdScan(int argc, char* argv[]) { // Parse: scan [path] [options] diff --git a/src/CLIPipeline.h b/src/CLIPipeline.h index 1de7bfcb5..55f04035b 100644 --- a/src/CLIPipeline.h +++ b/src/CLIPipeline.h @@ -77,6 +77,10 @@ class CLIPipeline { static int cmdPose(int argc, char* argv[]); static int cmdScan(int argc, char* argv[]); static int cmdMaterial(int argc, char* argv[]); + /// Slice G: pack 1-4 grayscale source images into a single RGBA + /// output texture. Headless / scriptable equivalent of the GUI + /// "Pack Channels…" dialog. + static int cmdPackTextures(int argc, char* argv[]); /// Map file extension to MeshImporterExporter format string. static QString formatForExtension(const QString& path); diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 9a2ef851c..461f5bd8a 100755 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -76,6 +76,7 @@ ThemeManager.cpp BatchExporter.cpp MaterialPresetLibrary.cpp MeshLodController.cpp +TextureChannelPacker.cpp MeshValidator.cpp AIChatManager.cpp WelcomeScreenController.cpp @@ -159,6 +160,7 @@ ThemeManager.h BatchExporter.h MaterialPresetLibrary.h MeshLodController.h +TextureChannelPacker.h MeshValidator.h AIChatManager.h WelcomeScreenController.h diff --git a/src/MCPServer.cpp b/src/MCPServer.cpp index 2cf70cab6..d426d69c8 100644 --- a/src/MCPServer.cpp +++ b/src/MCPServer.cpp @@ -3,6 +3,7 @@ #include "Manager.h" #include "MaterialEditorQML.h" #include "MaterialPresetLibrary.h" +#include "TextureChannelPacker.h" #include "PrimitiveObject.h" #include "SelectionSet.h" #include "TransformOperator.h" @@ -442,7 +443,8 @@ const QMap& MCPServer::toolHandlers() {QStringLiteral("ungroup_node"), &MCPServer::toolUngroupNode}, {QStringLiteral("reparent_node"), &MCPServer::toolReparentNode}, {QStringLiteral("set_pivot_mode"), &MCPServer::toolSetPivotMode}, - {QStringLiteral("get_pivot_mode"), &MCPServer::toolGetPivotMode} + {QStringLiteral("get_pivot_mode"), &MCPServer::toolGetPivotMode}, + {QStringLiteral("pack_textures"), &MCPServer::toolPackTextures} }; return handlers; } @@ -3347,6 +3349,51 @@ QJsonObject MCPServer::toolGetPivotMode(const QJsonObject &args) return result; } +QJsonObject MCPServer::toolPackTextures(const QJsonObject &args) +{ + SentryReporter::addBreadcrumb("ai.tool_call", "pack_textures"); + + TextureChannelPacker::PackingSpec spec; + auto fillChan = [&args](TextureChannelPacker::ChannelSource& dst, + const QString& pathKey, + const QString& constKey, + const QString& invertKey) { + if (args.contains(pathKey)) + dst.path = args.value(pathKey).toString(); + if (args.contains(constKey)) + dst.constantValue = static_cast(args.value(constKey).toDouble()); + if (args.contains(invertKey)) + dst.invert = args.value(invertKey).toBool(); + }; + fillChan(spec.red, "red", "red_constant", "invert_red"); + fillChan(spec.green, "green", "green_constant", "invert_green"); + fillChan(spec.blue, "blue", "blue_constant", "invert_blue"); + fillChan(spec.alpha, "alpha", "alpha_constant", "invert_alpha"); + if (args.contains("width")) + spec.outputWidth = args.value("width").toInt(); + if (args.contains("height")) + spec.outputHeight = args.value("height").toInt(); + if (args.contains("include_alpha")) + spec.includeAlpha = args.value("include_alpha").toBool(); + + const QString outPath = args.value("output").toString(); + if (outPath.isEmpty()) + return makeErrorResult("Error: missing required 'output' argument"); + + auto r = TextureChannelPacker::packToFile(spec, outPath); + if (!r.ok) + return makeErrorResult(QString("Error: %1").arg(r.error)); + + QJsonObject result; + result["content"] = QJsonArray{QJsonObject{ + {"type", "text"}, + {"text", QString("Packed %1x%2 -> %3").arg(r.usedWidth).arg(r.usedHeight).arg(outPath)}}}; + result["width"] = r.usedWidth; + result["height"] = r.usedHeight; + result["output"] = outPath; + return result; +} + QJsonArray MCPServer::buildToolsList() { QJsonArray tools; @@ -4162,6 +4209,54 @@ QJsonArray MCPServer::buildToolsList() ); } + // pack_textures (slice G) + { + QJsonObject props; + props["red"] = QJsonObject{ + {"type", "string"}, + {"description", "Path to grayscale source for the R channel (sampled as Rec.601 luminance). Optional — leave empty to use 'red_constant' instead."}}; + props["green"] = QJsonObject{ + {"type", "string"}, + {"description", "Path to grayscale source for the G channel."}}; + props["blue"] = QJsonObject{ + {"type", "string"}, + {"description", "Path to grayscale source for the B channel."}}; + props["alpha"] = QJsonObject{ + {"type", "string"}, + {"description", "Path to grayscale source for the A channel."}}; + props["red_constant"] = QJsonObject{ + {"type", "number"}, + {"description", "Constant 0..1 to fill the R channel when no path is given."}}; + props["green_constant"] = QJsonObject{{"type", "number"}}; + props["blue_constant"] = QJsonObject{{"type", "number"}}; + props["alpha_constant"] = QJsonObject{{"type", "number"}}; + props["invert_red"] = QJsonObject{{"type", "boolean"}, + {"description", "Invert the R channel (1 - value). Useful for roughness ↔ glossiness conversion."}}; + props["invert_green"] = QJsonObject{{"type", "boolean"}}; + props["invert_blue"] = QJsonObject{{"type", "boolean"}}; + props["invert_alpha"] = QJsonObject{{"type", "boolean"}}; + props["width"] = QJsonObject{{"type", "integer"}, + {"description", "Optional output width. Defaults to the largest source width (256 if all channels are constants)."}}; + props["height"] = QJsonObject{{"type", "integer"}}; + props["include_alpha"] = QJsonObject{{"type", "boolean"}, + {"description", "Default true — output is RGBA8. Set false to write RGB888."}}; + props["output"] = QJsonObject{ + {"type", "string"}, + {"description", "Output file path. Extension determines format (PNG/TGA/JPG/BMP)."}}; + QJsonArray required; + required.append("output"); + appendTool( + "pack_textures", + "Pack 1-4 grayscale source images into a single RGBA output texture. " + "Useful for authoring channel-packed PBR maps (e.g. Unity ORM = AO+Roughness+Metallic, " + "Unreal MR = Metallic+Roughness). Each output channel takes either a source image (sampled " + "as luminance) or a constant 0..1 value. Smaller sources are bilinear-scaled to match the " + "largest input. Returns the output dimensions on success.", + props, + required + ); + } + return tools; } diff --git a/src/MCPServer.h b/src/MCPServer.h index 0093d03e5..5e4370578 100644 --- a/src/MCPServer.h +++ b/src/MCPServer.h @@ -180,6 +180,9 @@ private slots: QJsonObject toolReparentNode(const QJsonObject &args); QJsonObject toolSetPivotMode(const QJsonObject &args); QJsonObject toolGetPivotMode(const QJsonObject &args); + /// Slice G: pack 1-4 grayscale source images into a single RGBA + /// output texture (e.g. ORM = AO+Roughness+Metallic). + QJsonObject toolPackTextures(const QJsonObject &args); // Animation struct NodeAnimation { diff --git a/src/MaterialEditorQML.cpp b/src/MaterialEditorQML.cpp index 0b3564ff4..dd26dcdf5 100644 --- a/src/MaterialEditorQML.cpp +++ b/src/MaterialEditorQML.cpp @@ -9,6 +9,7 @@ #include "QMLMaterialHighlighter.h" #include "ModelDownloader.h" #include "RTShaderHelper.h" +#include "TextureChannelPacker.h" #include "PS1/PS1TIM.h" #include #include @@ -2807,6 +2808,66 @@ QString MaterialEditorQML::testConnection() return "C++ method called successfully!"; } +QString MaterialEditorQML::savePackedTextureDialog() +{ + QString texturesPath = "./media/materials/textures"; + QDir texturesDir(texturesPath); + QString startDir = texturesDir.exists() ? texturesDir.absolutePath() : QDir::currentPath(); + + QApplication::processEvents(); + if (QWidget *activeWin = QApplication::activeWindow()) { + activeWin->raise(); + activeWin->activateWindow(); + } + QApplication::processEvents(); + + QString selectedFile = QFileDialog::getSaveFileName( + QApplication::activeWindow(), + "Save Packed Texture", + startDir + "/packed.png", + "PNG (*.png);;TGA (*.tga);;JPEG (*.jpg *.jpeg);;BMP (*.bmp)", + nullptr, + QFileDialog::DontUseNativeDialog | QFileDialog::DontUseCustomDirectoryIcons + ); + return selectedFile; +} + +QString MaterialEditorQML::packTextureChannels(const QString& redPath, + const QString& greenPath, + const QString& bluePath, + const QString& alphaPath, + double redConstant, + double greenConstant, + double blueConstant, + double alphaConstant, + bool invertRed, + bool invertGreen, + bool invertBlue, + bool invertAlpha, + bool includeAlpha, + const QString& outputPath) +{ + SentryReporter::addBreadcrumb("ui.action", "Pack texture channels"); + + TextureChannelPacker::PackingSpec spec; + spec.red.path = redPath; + spec.red.constantValue = static_cast(redConstant); + spec.red.invert = invertRed; + spec.green.path = greenPath; + spec.green.constantValue = static_cast(greenConstant); + spec.green.invert = invertGreen; + spec.blue.path = bluePath; + spec.blue.constantValue = static_cast(blueConstant); + spec.blue.invert = invertBlue; + spec.alpha.path = alphaPath; + spec.alpha.constantValue = static_cast(alphaConstant); + spec.alpha.invert = invertAlpha; + spec.includeAlpha = includeAlpha; + + auto r = TextureChannelPacker::packToFile(spec, outputPath); + return r.ok ? QString() : r.error; +} + // Add a helper method to check if Ogre is available bool MaterialEditorQML::isOgreAvailable() const { diff --git a/src/MaterialEditorQML.h b/src/MaterialEditorQML.h index 6de12e22b..788becded 100644 --- a/src/MaterialEditorQML.h +++ b/src/MaterialEditorQML.h @@ -382,6 +382,28 @@ public slots: Q_INVOKABLE QString openMaterialExportDialog(const QString &materialName = ""); Q_INVOKABLE QString showNativeFileDialog(QObject *parentWindow); Q_INVOKABLE QString testConnection(); + + /// Slice G: open a native save-as dialog for the packed-textures + /// output path. Returns the chosen path or empty if cancelled. + Q_INVOKABLE QString savePackedTextureDialog(); + + /// Slice G: pack 1-4 grayscale source images into a single RGBA + /// texture and write it to disk. Returns an empty string on success + /// or an error message on failure. + Q_INVOKABLE QString packTextureChannels(const QString& redPath, + const QString& greenPath, + const QString& bluePath, + const QString& alphaPath, + double redConstant, + double greenConstant, + double blueConstant, + double alphaConstant, + bool invertRed, + bool invertGreen, + bool invertBlue, + bool invertAlpha, + bool includeAlpha, + const QString& outputPath); // Color picker void openColorPicker(const QString &colorType); diff --git a/src/TextureChannelPacker.cpp b/src/TextureChannelPacker.cpp new file mode 100644 index 000000000..f254f3690 --- /dev/null +++ b/src/TextureChannelPacker.cpp @@ -0,0 +1,187 @@ +#include "TextureChannelPacker.h" + +#include +#include +#include +#include + +namespace TextureChannelPacker { + +namespace { + +constexpr int kDefaultSize = 256; + +struct LoadedSource { + bool present = false; // true when an image was loaded + QImage img; + float constantValue = 0.0f; + bool invert = false; +}; + +LoadedSource loadSource(const ChannelSource& src, QString* errOut) +{ + LoadedSource out; + out.constantValue = std::clamp(src.constantValue, 0.0f, 1.0f); + out.invert = src.invert; + if (src.path.isEmpty()) return out; + QImageReader reader(src.path); + QImage img = reader.read(); + if (img.isNull()) { + if (errOut) + *errOut = QStringLiteral("failed to read '%1': %2") + .arg(src.path, reader.errorString()); + return out; + } + // Convert to RGBA8 once so per-pixel sampling is uniform. + out.img = img.convertToFormat(QImage::Format_RGBA8888); + out.present = true; + return out; +} + +// Rec.601 luminance in [0..255], converted from RGBA8888 pixel. +inline uint8_t luminance(QRgb px) +{ + const int r = qRed(px); + const int g = qGreen(px); + const int b = qBlue(px); + // 0.299 R + 0.587 G + 0.114 B, fixed-point. + return static_cast((r * 77 + g * 150 + b * 29 + 128) >> 8); +} + +inline uint8_t sampleChannel(const LoadedSource& src, int x, int y) +{ + if (!src.present) { + // Constant value path. + const uint8_t v = static_cast(std::clamp( + std::round(src.constantValue * 255.0f), 0.0f, 255.0f)); + return src.invert ? static_cast(255 - v) : v; + } + const QRgb px = src.img.pixel(x, y); + const uint8_t v = luminance(px); + return src.invert ? static_cast(255 - v) : v; +} + +// Resolve output dimensions from the largest source. If every source is +// constant-only, fall back to kDefaultSize so we still produce a usable +// flat texture. +QSize resolveOutputSize(const PackingSpec& spec, + const std::array& srcs) +{ + if (spec.outputWidth > 0 && spec.outputHeight > 0) + return {spec.outputWidth, spec.outputHeight}; + + int w = 0, h = 0; + for (const auto& s : srcs) { + if (!s.present) continue; + w = std::max(w, s.img.width()); + h = std::max(h, s.img.height()); + } + if (w == 0 || h == 0) + return {kDefaultSize, kDefaultSize}; + return {w, h}; +} + +// Bilinearly scale a source image up/down to the target dimensions. We +// rescale once up-front so the per-pixel pack loop is a simple lookup. +void normaliseToSize(LoadedSource& src, QSize target) +{ + if (!src.present) return; + if (src.img.size() == target) return; + src.img = src.img.scaled(target, Qt::IgnoreAspectRatio, + Qt::SmoothTransformation); + if (src.img.format() != QImage::Format_RGBA8888) + src.img = src.img.convertToFormat(QImage::Format_RGBA8888); +} + +} // namespace + +PackResult pack(const PackingSpec& spec) +{ + PackResult res; + + QString loadErr; + std::array sources{ + loadSource(spec.red, &loadErr), + loadSource(spec.green, &loadErr), + loadSource(spec.blue, &loadErr), + loadSource(spec.alpha, &loadErr), + }; + if (!loadErr.isEmpty()) { + res.error = loadErr; + return res; + } + + const QSize outSize = resolveOutputSize(spec, sources); + res.usedWidth = outSize.width(); + res.usedHeight = outSize.height(); + + for (auto& s : sources) + normaliseToSize(s, outSize); + + const QImage::Format fmt = spec.includeAlpha + ? QImage::Format_RGBA8888 + : QImage::Format_RGB888; + QImage out(outSize, fmt); + if (out.isNull()) { + res.error = QStringLiteral("failed to allocate %1x%2 output image") + .arg(outSize.width()) + .arg(outSize.height()); + return res; + } + + // Per-pixel pack. We use scanline pointers for speed on large images. + const int W = outSize.width(); + const int H = outSize.height(); + if (spec.includeAlpha) { + for (int y = 0; y < H; ++y) { + uchar* row = out.scanLine(y); + for (int x = 0; x < W; ++x) { + row[x*4 + 0] = sampleChannel(sources[0], x, y); // R + row[x*4 + 1] = sampleChannel(sources[1], x, y); // G + row[x*4 + 2] = sampleChannel(sources[2], x, y); // B + row[x*4 + 3] = sampleChannel(sources[3], x, y); // A + } + } + } else { + for (int y = 0; y < H; ++y) { + uchar* row = out.scanLine(y); + for (int x = 0; x < W; ++x) { + row[x*3 + 0] = sampleChannel(sources[0], x, y); + row[x*3 + 1] = sampleChannel(sources[1], x, y); + row[x*3 + 2] = sampleChannel(sources[2], x, y); + } + } + } + + res.ok = true; + res.image = std::move(out); + return res; +} + +PackResult packToFile(const PackingSpec& spec, const QString& outPath) +{ + PackResult r = pack(spec); + if (!r.ok) return r; + + if (outPath.isEmpty()) { + r.ok = false; + r.error = QStringLiteral("output path is empty"); + return r; + } + + QImageWriter writer(outPath); + if (!writer.canWrite()) { + r.ok = false; + r.error = QStringLiteral("cannot write '%1': format unsupported") + .arg(QFileInfo(outPath).suffix()); + return r; + } + if (!writer.write(r.image)) { + r.ok = false; + r.error = QStringLiteral("write failed: %1").arg(writer.errorString()); + return r; + } + return r; +} + +} // namespace TextureChannelPacker diff --git a/src/TextureChannelPacker.h b/src/TextureChannelPacker.h new file mode 100644 index 000000000..8e99f5f77 --- /dev/null +++ b/src/TextureChannelPacker.h @@ -0,0 +1,54 @@ +#pragma once + +#include +#include +#include + +/// Slice G: pack 1-4 grayscale source images into the RGBA channels of a +/// single output texture. Common indie game-dev pattern — Unity ORM +/// (Occlusion R / Roughness G / Metallic B), Unreal MR (Metallic R / +/// Roughness G / unused B), Lumberyard, etc. +/// +/// Each output channel takes either a source image (sampled as +/// luminance) or a constant value. Output size defaults to the largest +/// source dimensions; smaller sources are bilinear-scaled to match. +namespace TextureChannelPacker { + +/// One output channel's source. Either a path to a PNG/TGA/JPG/BMP +/// (sampled as Rec.601 luminance) or a constant 0..1 value when path +/// is empty. +struct ChannelSource { + QString path; // empty → use constantValue + float constantValue = 0.0f; + bool invert = false; // useful for converting roughness ↔ glossiness +}; + +struct PackingSpec { + ChannelSource red; + ChannelSource green; + ChannelSource blue; + ChannelSource alpha; + int outputWidth = 0; // 0 → max of source widths (defaults to 256 if all constant) + int outputHeight = 0; + bool includeAlpha = true; +}; + +struct PackResult { + bool ok = false; + QString error; + QImage image; // RGBA8 (or RGB888 if !includeAlpha) — empty on failure + int usedWidth = 0; + int usedHeight = 0; +}; + +/// Pack the four channels into a single QImage. Pure-data; safe to call +/// without Ogre. Empty paths use constantValue. Bilinear scales smaller +/// sources up to the output dimensions. +PackResult pack(const PackingSpec& spec); + +/// Convenience: pack and write to a PNG/TGA/JPG file at `outPath`. The +/// extension determines the format. Returns the same PackResult; check +/// `ok` for success and `error` for the message on failure. +PackResult packToFile(const PackingSpec& spec, const QString& outPath); + +} // namespace TextureChannelPacker diff --git a/src/TextureChannelPacker_test.cpp b/src/TextureChannelPacker_test.cpp new file mode 100644 index 000000000..c10222436 --- /dev/null +++ b/src/TextureChannelPacker_test.cpp @@ -0,0 +1,190 @@ +#include + +#include +#include +#include + +#include "TextureChannelPacker.h" + +using namespace TextureChannelPacker; + +namespace { + +// Write a constant-grey N×M PNG to disk so the packer has a real source. +QString writeGreyPng(const QTemporaryDir& dir, + const QString& name, + int w, int h, int grey) +{ + QImage img(w, h, QImage::Format_RGBA8888); + img.fill(qRgba(grey, grey, grey, 255)); + const QString path = dir.filePath(name); + [&]() { ASSERT_TRUE(img.save(path, "PNG")) << path.toStdString(); }(); + return path; +} + +uint8_t pixel(const QImage& img, int x, int y, int channel) +{ + QRgb p = img.pixel(x, y); + switch (channel) { + case 0: return qRed(p); + case 1: return qGreen(p); + case 2: return qBlue(p); + case 3: return qAlpha(p); + } + return 0; +} + +} // namespace + +TEST(TextureChannelPackerTest, AllConstantsProducesSolidColour) { + PackingSpec spec; + spec.red.constantValue = 1.0f; // 255 + spec.green.constantValue = 0.5f; // ~128 + spec.blue.constantValue = 0.0f; // 0 + spec.alpha.constantValue = 1.0f; // 255 + + PackResult r = pack(spec); + ASSERT_TRUE(r.ok) << r.error.toStdString(); + ASSERT_FALSE(r.image.isNull()); + EXPECT_EQ(r.usedWidth, 256); // default size when no sources + EXPECT_EQ(r.usedHeight, 256); + + // Spot-check a pixel. + EXPECT_EQ(pixel(r.image, 10, 10, 0), 255); + EXPECT_NEAR(pixel(r.image, 10, 10, 1), 128, 1); + EXPECT_EQ(pixel(r.image, 10, 10, 2), 0); + EXPECT_EQ(pixel(r.image, 10, 10, 3), 255); +} + +TEST(TextureChannelPackerTest, ORM_PacksThreeGreyImagesIntoRGB) { + QTemporaryDir tmp; + ASSERT_TRUE(tmp.isValid()); + + // Simulate Unity ORM: AO=64 → R, Roughness=200 → G, Metallic=10 → B. + const QString aoPath = writeGreyPng(tmp, "ao.png", 32, 32, 64); + const QString roughPath = writeGreyPng(tmp, "roughness.png", 32, 32, 200); + const QString metalPath = writeGreyPng(tmp, "metallic.png", 32, 32, 10); + + PackingSpec spec; + spec.red.path = aoPath; + spec.green.path = roughPath; + spec.blue.path = metalPath; + spec.alpha.constantValue = 1.0f; + + PackResult r = pack(spec); + ASSERT_TRUE(r.ok) << r.error.toStdString(); + EXPECT_EQ(r.usedWidth, 32); + EXPECT_EQ(r.usedHeight, 32); + + // Every pixel of the output should match the source greys (Rec.601 + // luminance of a fully-grey pixel is the grey value itself). + EXPECT_EQ(pixel(r.image, 0, 0, 0), 64); + EXPECT_EQ(pixel(r.image, 0, 0, 1), 200); + EXPECT_EQ(pixel(r.image, 0, 0, 2), 10); + EXPECT_EQ(pixel(r.image, 0, 0, 3), 255); +} + +TEST(TextureChannelPackerTest, InvertFlagFlipsChannel) { + QTemporaryDir tmp; + ASSERT_TRUE(tmp.isValid()); + const QString src = writeGreyPng(tmp, "rough.png", 8, 8, 200); + + // Roughness=200 inverted → Glossiness=55. Common Specular-Glossiness + // workflow conversion. + PackingSpec spec; + spec.red.path = src; + spec.red.invert = true; + PackResult r = pack(spec); + ASSERT_TRUE(r.ok); + EXPECT_EQ(pixel(r.image, 4, 4, 0), 55); +} + +TEST(TextureChannelPackerTest, MismatchedSizesAreScaledToLargest) { + QTemporaryDir tmp; + ASSERT_TRUE(tmp.isValid()); + const QString small = writeGreyPng(tmp, "small.png", 16, 16, 100); + const QString big = writeGreyPng(tmp, "big.png", 64, 64, 200); + + PackingSpec spec; + spec.red.path = small; + spec.green.path = big; + PackResult r = pack(spec); + ASSERT_TRUE(r.ok); + EXPECT_EQ(r.usedWidth, 64); + EXPECT_EQ(r.usedHeight, 64); + // Centre pixel should still be ~100 (small was scaled up uniformly). + EXPECT_NEAR(pixel(r.image, 32, 32, 0), 100, 2); + EXPECT_NEAR(pixel(r.image, 32, 32, 1), 200, 2); +} + +TEST(TextureChannelPackerTest, MissingFileReturnsError) { + PackingSpec spec; + spec.red.path = "/nonexistent/path/does_not_exist.png"; + + PackResult r = pack(spec); + EXPECT_FALSE(r.ok); + EXPECT_FALSE(r.error.isEmpty()); +} + +TEST(TextureChannelPackerTest, ExplicitOutputSizeIsRespected) { + PackingSpec spec; + spec.red.constantValue = 0.5f; + spec.outputWidth = 128; + spec.outputHeight = 64; + PackResult r = pack(spec); + ASSERT_TRUE(r.ok); + EXPECT_EQ(r.usedWidth, 128); + EXPECT_EQ(r.usedHeight, 64); + EXPECT_EQ(r.image.width(), 128); + EXPECT_EQ(r.image.height(), 64); +} + +TEST(TextureChannelPackerTest, IncludeAlphaFalseProducesRGB888) { + PackingSpec spec; + spec.red.constantValue = 1.0f; + spec.includeAlpha = false; + PackResult r = pack(spec); + ASSERT_TRUE(r.ok); + EXPECT_EQ(r.image.format(), QImage::Format_RGB888); +} + +TEST(TextureChannelPackerTest, PackToFileWritesPng) { + QTemporaryDir tmp; + ASSERT_TRUE(tmp.isValid()); + const QString src = writeGreyPng(tmp, "src.png", 8, 8, 128); + + PackingSpec spec; + spec.red.path = src; + spec.green.path = src; + spec.blue.path = src; + const QString outPath = tmp.filePath("packed.png"); + PackResult r = packToFile(spec, outPath); + ASSERT_TRUE(r.ok) << r.error.toStdString(); + + // Re-read and verify the saved file actually contains the packed data. + QImage reloaded(outPath); + ASSERT_FALSE(reloaded.isNull()); + EXPECT_EQ(reloaded.width(), 8); + EXPECT_EQ(reloaded.height(), 8); + QRgb px = reloaded.pixel(4, 4); + EXPECT_EQ(qRed(px), 128); + EXPECT_EQ(qGreen(px), 128); + EXPECT_EQ(qBlue(px), 128); +} + +TEST(TextureChannelPackerTest, PackToFile_EmptyPathFails) { + PackingSpec spec; + spec.red.constantValue = 0.5f; + PackResult r = packToFile(spec, ""); + EXPECT_FALSE(r.ok); + EXPECT_TRUE(r.error.contains("output path", Qt::CaseInsensitive)); +} + +TEST(TextureChannelPackerTest, PackToFile_UnsupportedExtensionFails) { + QTemporaryDir tmp; + ASSERT_TRUE(tmp.isValid()); + PackingSpec spec; + spec.red.constantValue = 0.5f; + PackResult r = packToFile(spec, tmp.filePath("nope.weirdext")); + EXPECT_FALSE(r.ok); +} diff --git a/src/main.cpp b/src/main.cpp index 5a99346db..df3dedc2a 100755 --- a/src/main.cpp +++ b/src/main.cpp @@ -87,7 +87,7 @@ int main(int argc, char *argv[]) continue; // skip flags like --verbose if (arg == "info" || arg == "fix" || arg == "convert" || arg == "anim" || arg == "validate" || arg == "lod" || arg == "pose" - || arg == "scan" || arg == "material") + || arg == "scan" || arg == "material" || arg == "pack-textures") cliMode = true; break; // first non-flag arg determines mode } diff --git a/src/qml_resources.qrc b/src/qml_resources.qrc index b82645b00..b4275b1fa 100644 --- a/src/qml_resources.qrc +++ b/src/qml_resources.qrc @@ -5,6 +5,7 @@ ../qml/PassPropertiesPanel.qml ../qml/TexturePropertiesPanel.qml ../qml/AISettingsDialog.qml + ../qml/TextureChannelPackerDialog.qml ../qml/qmldir ../qml/ThemedButton.qml ../qml/ThemedComboBox.qml From bf26cf0104b54830740a42b920e3afb7e60b2b93 Mon Sep 17 00:00:00 2001 From: Fernando Date: Sun, 10 May 2026 00:06:26 -0400 Subject: [PATCH 2/3] test(slice-G): cover CLI / MCP / QML pack_textures surfaces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five tests in each of the three integration entry points so the new slice G surfaces are verified end-to-end on Linux CI: CLIPipeline_test.cpp: - MissingOutputFails (usage error 2) - AllConstantsWritesPng (32x32 default-format png) - MissingSourceFileReturnsRuntimeError (exit 1) - NoAlphaProducesRgbPng (--no-alpha branch) - InvertFlagFlipsConstantSource (constant 1.0 + --invert-r → 0) MCPServer_test.cpp: - MissingOutputReturnsError - AllConstantsWritesPng (asserts width/height in result JSON) - MissingSourceReportsError - InvertFlagAppliesToConstant - AppearsInToolList (regression guard for tools/list registration) MaterialEditorQML_test.cpp: - AllConstantsWritesPng (Q_INVOKABLE wrapper happy path) - MissingPathReturnsError - EmptyOutputPathReturnsError - InvertFlagFlipsConstant Co-Authored-By: Claude Opus 4.7 (1M context) --- src/CLIPipeline_test.cpp | 78 +++++++++++++++++++++++++++++++++ src/MCPServer_test.cpp | 80 ++++++++++++++++++++++++++++++++++ src/MaterialEditorQML_test.cpp | 57 ++++++++++++++++++++++++ 3 files changed, 215 insertions(+) diff --git a/src/CLIPipeline_test.cpp b/src/CLIPipeline_test.cpp index 6c4529fa1..051052b32 100644 --- a/src/CLIPipeline_test.cpp +++ b/src/CLIPipeline_test.cpp @@ -2,6 +2,7 @@ #include #include #include +#include #include #include #include @@ -2959,3 +2960,80 @@ TEST(CLIPipelineCmdMaterial, ListPresetsExitsZero) // load needed, so this works without Ogre headless init. EXPECT_EQ(CLIPipeline::cmdMaterial(args.argc(), args.argv()), 0); } + +// -- cmdPackTextures (slice G) -- + +TEST(CLIPipelineCmdPackTextures, MissingOutputFails) +{ + // No -o → usage error (2). Constants alone are fine; only the + // output path is required. + TestArgv args({"qtmesh", "pack-textures", "--rc", "0.5"}); + EXPECT_EQ(CLIPipeline::cmdPackTextures(args.argc(), args.argv()), 2); +} + +TEST(CLIPipelineCmdPackTextures, AllConstantsWritesPng) +{ + QTemporaryDir tmp; + ASSERT_TRUE(tmp.isValid()); + const QByteArray outPath = tmp.filePath("flat.png").toUtf8(); + + TestArgv args({"qtmesh", "pack-textures", + "--rc", "1.0", "--gc", "0.5", "--bc", "0.0", + "--width", "32", "--height", "32", + "-o", outPath.constData()}); + EXPECT_EQ(CLIPipeline::cmdPackTextures(args.argc(), args.argv()), 0); + + // Verify the file actually exists and decodes to the requested size. + QImage img(outPath); + ASSERT_FALSE(img.isNull()); + EXPECT_EQ(img.width(), 32); + EXPECT_EQ(img.height(), 32); +} + +TEST(CLIPipelineCmdPackTextures, MissingSourceFileReturnsRuntimeError) +{ + QTemporaryDir tmp; + ASSERT_TRUE(tmp.isValid()); + const QByteArray outPath = tmp.filePath("never.png").toUtf8(); + TestArgv args({"qtmesh", "pack-textures", + "--r", "/nonexistent/definitely_missing_for_test.png", + "-o", outPath.constData()}); + EXPECT_EQ(CLIPipeline::cmdPackTextures(args.argc(), args.argv()), 1); +} + +TEST(CLIPipelineCmdPackTextures, NoAlphaProducesRgbPng) +{ + QTemporaryDir tmp; + ASSERT_TRUE(tmp.isValid()); + const QByteArray outPath = tmp.filePath("rgb.png").toUtf8(); + TestArgv args({"qtmesh", "pack-textures", + "--rc", "1.0", "--gc", "0.5", "--bc", "0.25", + "--width", "16", "--height", "16", + "--no-alpha", + "-o", outPath.constData()}); + EXPECT_EQ(CLIPipeline::cmdPackTextures(args.argc(), args.argv()), 0); + + // QImage normalises to ARGB32 on load even from RGB888 PNGs, so we + // can't introspect the underlying format here; the smoke check is + // that the file decodes and is the expected size. + QImage img(outPath); + ASSERT_FALSE(img.isNull()); + EXPECT_EQ(img.width(), 16); + EXPECT_EQ(img.height(), 16); +} + +TEST(CLIPipelineCmdPackTextures, InvertFlagFlipsConstantSource) +{ + // Constant 1.0 with --invert-r should produce ~0 in the R channel. + QTemporaryDir tmp; + ASSERT_TRUE(tmp.isValid()); + const QByteArray outPath = tmp.filePath("inv.png").toUtf8(); + TestArgv args({"qtmesh", "pack-textures", + "--rc", "1.0", "--invert-r", + "--width", "8", "--height", "8", + "-o", outPath.constData()}); + EXPECT_EQ(CLIPipeline::cmdPackTextures(args.argc(), args.argv()), 0); + QImage img(outPath); + ASSERT_FALSE(img.isNull()); + EXPECT_EQ(qRed(img.pixel(4, 4)), 0); +} diff --git a/src/MCPServer_test.cpp b/src/MCPServer_test.cpp index d64c764c7..1dcde91ff 100644 --- a/src/MCPServer_test.cpp +++ b/src/MCPServer_test.cpp @@ -9,6 +9,7 @@ #include #include #include +#include #include #include #include @@ -5823,3 +5824,82 @@ TEST_F(MCPServerTest, ResampleAnimation_Valid) // May succeed or fail depending on skeleton state, but should not crash EXPECT_FALSE(getResultText(result).isEmpty()); } + +// ========================================================================== +// SLICE G: pack_textures tool +// ========================================================================== + +TEST_F(MCPServerTest, PackTextures_MissingOutputReturnsError) +{ + QJsonObject args; + args["red_constant"] = 0.5; + QJsonObject result = server->callTool("pack_textures", args); + EXPECT_TRUE(isError(result)); + EXPECT_TRUE(getResultText(result).contains("output", Qt::CaseInsensitive)); +} + +TEST_F(MCPServerTest, PackTextures_AllConstantsWritesPng) +{ + QTemporaryDir tmp; + ASSERT_TRUE(tmp.isValid()); + const QString outPath = tmp.filePath("packed_constants.png"); + + QJsonObject args; + args["red_constant"] = 1.0; + args["green_constant"] = 0.5; + args["blue_constant"] = 0.0; + args["alpha_constant"] = 1.0; + args["width"] = 32; + args["height"] = 32; + args["output"] = outPath; + QJsonObject result = server->callTool("pack_textures", args); + EXPECT_FALSE(isError(result)); + EXPECT_EQ(result["width"].toInt(), 32); + EXPECT_EQ(result["height"].toInt(), 32); + + QImage img(outPath); + ASSERT_FALSE(img.isNull()); + EXPECT_EQ(img.width(), 32); +} + +TEST_F(MCPServerTest, PackTextures_MissingSourceReportsError) +{ + QTemporaryDir tmp; + ASSERT_TRUE(tmp.isValid()); + QJsonObject args; + args["red"] = "/nonexistent/should_not_resolve_for_test.png"; + args["output"] = tmp.filePath("nope.png"); + QJsonObject result = server->callTool("pack_textures", args); + EXPECT_TRUE(isError(result)); +} + +TEST_F(MCPServerTest, PackTextures_InvertFlagAppliesToConstant) +{ + QTemporaryDir tmp; + ASSERT_TRUE(tmp.isValid()); + const QString outPath = tmp.filePath("inv.png"); + QJsonObject args; + args["red_constant"] = 1.0; + args["invert_red"] = true; + args["width"] = 8; + args["height"] = 8; + args["output"] = outPath; + QJsonObject result = server->callTool("pack_textures", args); + EXPECT_FALSE(isError(result)); + QImage img(outPath); + ASSERT_FALSE(img.isNull()); + EXPECT_EQ(qRed(img.pixel(4, 4)), 0); +} + +TEST_F(MCPServerTest, PackTextures_AppearsInToolList) +{ + QJsonArray tools = server->buildToolsList(); + bool found = false; + for (const auto& t : tools) { + if (t.toObject()["name"].toString() == "pack_textures") { + found = true; + break; + } + } + EXPECT_TRUE(found) << "pack_textures must be exposed in tools/list"; +} diff --git a/src/MaterialEditorQML_test.cpp b/src/MaterialEditorQML_test.cpp index 901bce027..09404fdb4 100644 --- a/src/MaterialEditorQML_test.cpp +++ b/src/MaterialEditorQML_test.cpp @@ -6,6 +6,7 @@ #include #include #include +#include #include #include "MaterialEditorQML.h" #include "Manager.h" @@ -2579,3 +2580,59 @@ TEST_F(MaterialEditorQMLWithOgreTest, NewTechniqueAddPassAndSelectIt) { editor->setSelectedPassIndex(0); EXPECT_TRUE(editor->lightingEnabled()); } + +// --------------------------------------------------------------------------- +// Slice G: Q_INVOKABLE wrappers for the texture channel packer +// --------------------------------------------------------------------------- + +TEST_F(MaterialEditorQMLTest, PackTextureChannels_AllConstantsWritesPng) { + QTemporaryDir tmp; + ASSERT_TRUE(tmp.isValid()); + const QString outPath = tmp.filePath("packed_const.png"); + + QString err = editor->packTextureChannels( + QString(), QString(), QString(), QString(), + 1.0, 0.5, 0.0, 1.0, + false, false, false, false, + true, outPath); + EXPECT_TRUE(err.isEmpty()) << err.toStdString(); + EXPECT_TRUE(QFile::exists(outPath)); +} + +TEST_F(MaterialEditorQMLTest, PackTextureChannels_MissingPathReturnsError) { + QTemporaryDir tmp; + ASSERT_TRUE(tmp.isValid()); + const QString outPath = tmp.filePath("never.png"); + + QString err = editor->packTextureChannels( + "/nonexistent/missing_for_test.png", QString(), QString(), QString(), + 0.0, 0.0, 0.0, 1.0, + false, false, false, false, + true, outPath); + EXPECT_FALSE(err.isEmpty()); + EXPECT_FALSE(QFile::exists(outPath)); +} + +TEST_F(MaterialEditorQMLTest, PackTextureChannels_EmptyOutputPathReturnsError) { + QString err = editor->packTextureChannels( + QString(), QString(), QString(), QString(), + 0.5, 0.0, 0.0, 1.0, + false, false, false, false, + true, QString()); + EXPECT_FALSE(err.isEmpty()); +} + +TEST_F(MaterialEditorQMLTest, PackTextureChannels_InvertFlagFlipsConstant) { + QTemporaryDir tmp; + ASSERT_TRUE(tmp.isValid()); + const QString outPath = tmp.filePath("inv.png"); + QString err = editor->packTextureChannels( + QString(), QString(), QString(), QString(), + 1.0, 0.0, 0.0, 1.0, + /*invertR=*/true, false, false, false, + true, outPath); + EXPECT_TRUE(err.isEmpty()); + QImage img(outPath); + ASSERT_FALSE(img.isNull()); + EXPECT_EQ(qRed(img.pixel(0, 0)), 0); +} From ecb4031d1fe90569c95af85e15cca78829197e03 Mon Sep 17 00:00:00 2001 From: Fernando Date: Sun, 10 May 2026 00:34:00 -0400 Subject: [PATCH 3/3] fix(tests): include TextureChannelPacker.cpp in MaterialEditorQML_* test sources MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Linux CI MaterialEditorQML_test (and the parallel _perf, _qml, _runner targets) duplicate the src/ source list at tests/CMakeLists.txt:13–95, and that list was missing TextureChannelPacker.cpp. The link failed with undefined references to TextureChannelPacker::packToFile from both MCPServer.cpp::toolPackTextures and CLIPipeline.cpp::cmdPackTextures. Co-Authored-By: Claude Opus 4.7 (1M context) --- tests/CMakeLists.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index d304d4228..445532774 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -85,6 +85,7 @@ if(BUILD_TESTS) ${CMAKE_CURRENT_SOURCE_DIR}/../src/ThemeManager.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../src/BatchExporter.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../src/MaterialPresetLibrary.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/../src/TextureChannelPacker.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../src/MeshLodController.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../src/MeshValidator.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../src/AIChatManager.cpp