diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 917f2e9ed..e9cd400b2 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -1045,6 +1045,12 @@ jobs: echo "Found $test_name at $test_path" local exit_code=0 + local expected_tests + if [ -n "$filter" ]; then + expected_tests=$(count_listed_tests "$test_path" "$filter") + else + expected_tests=$(count_listed_tests "$test_path") + fi if [ -n "$filter" ]; then $test_path --gtest_output=xml:$output_file --gtest_filter="$filter" else @@ -1056,6 +1062,13 @@ jobs: return $exit_code fi + local actual_tests + actual_tests=$(count_testcases_in_xml "$output_file") + if [ "$actual_tests" -ne "$expected_tests" ]; then + echo "ERROR: $test_name ran $actual_tests/$expected_tests discovered test(s)." + return 1 + fi + local skipped_count skipped_count=$(count_skipped_in_xml "$output_file") if [ "$skipped_count" -gt 0 ]; then @@ -1098,6 +1111,25 @@ jobs: grep -E ']*(result="skipped"|status="notrun"|result="suppressed")' "$xml_file" | wc -l } + count_testcases_in_xml() { + local xml_file=$1 + if [ ! -f "$xml_file" ]; then + echo "0" + return 0 + fi + grep '/dev/null | grep -E '^ [^[:space:]]' | wc -l + else + $test_path --gtest_list_tests 2>/dev/null | grep -E '^ [^[:space:]]' | wc -l + fi + } + # === Run UnitTests: each test suite in its own process === # This prevents a crash in one suite (e.g. Ogre GL init segfault) # from killing all subsequent suites and losing their coverage data. @@ -1108,6 +1140,8 @@ jobs: echo "=== Running UnitTests suites in isolation ===" # Get list of test suites SUITES=$($UNIT_TEST_PATH --gtest_list_tests 2>/dev/null | grep -E '^\w' | sed 's/\.$//' | sort -u) + EXPECTED_UNIT_TESTS=$(count_listed_tests "$UNIT_TEST_PATH") + ACTUAL_UNIT_TESTS=0 for suite in $SUITES; do TOTAL_SUITES=$((TOTAL_SUITES + 1)) @@ -1115,9 +1149,17 @@ jobs: # Run without sudo so signal exit codes (e.g. 139 for SIGSEGV) propagate correctly. # sudo can mask crash exit codes, reporting 0 for segfaulted children. suite_xml="test-results-${suite}.xml" + expected_suite_tests=$(count_listed_tests "$UNIT_TEST_PATH" "${suite}.*") $UNIT_TEST_PATH --gtest_filter="${suite}.*" --gtest_output=xml:${suite_xml} 2>&1 exit_code=$? if [ $exit_code -eq 0 ]; then + actual_suite_tests=$(count_testcases_in_xml "$suite_xml") + ACTUAL_UNIT_TESTS=$((ACTUAL_UNIT_TESTS + actual_suite_tests)) + if [ "$actual_suite_tests" -ne "$expected_suite_tests" ]; then + echo "ERROR: Suite $suite ran $actual_suite_tests/$expected_suite_tests discovered test(s)." + FAILED_SUITES=$((FAILED_SUITES + 1)) + continue + fi skipped_count=$(count_skipped_in_xml "$suite_xml") if [ "$skipped_count" -gt 0 ]; then echo "ERROR: Suite $suite produced $skipped_count skipped test(s)." @@ -1141,7 +1183,12 @@ jobs: fi done + if [ "$ACTUAL_UNIT_TESTS" -ne "$EXPECTED_UNIT_TESTS" ]; then + echo "ERROR: UnitTests executed $ACTUAL_UNIT_TESTS/$EXPECTED_UNIT_TESTS discovered test(s)." + FAILED_SUITES=$((FAILED_SUITES + 1)) + fi echo "=== UnitTests summary: $PASSED_SUITES/$TOTAL_SUITES suites passed, $FAILED_SUITES failed, $CRASHED_SUITES crashed, $SKIPPED_TESTS skipped tests ===" + echo "=== UnitTests cases: $ACTUAL_UNIT_TESTS/$EXPECTED_UNIT_TESTS executed ===" else echo "ERROR: UnitTests not found!" FAILED_SUITES=$((FAILED_SUITES + 1)) diff --git a/README.md b/README.md index 402b71b34..037e00c61 100755 --- a/README.md +++ b/README.md @@ -166,10 +166,72 @@ qtmesh pose model.fbx --animation "Dance" --count 4 -o pose_%02d.stl # LOD generation qtmesh lod model.fbx --auto + +# Bake vertex colors β†’ texture (with UV-seam dilation) +qtmesh bake-vertex-colors model.fbx -o color_map.png --resolution 1024 --dilation 4 ``` --- +### 🎨 Paint Tools + +ZBrush-style polypaint and BaseColor texture painting, with a 2D preview +panel, multiple brush tools (paint, erase, fill, color picker, smudge), +texture-slot picker per submesh, and a one-click bake that turns vertex +colors into a UV-space texture. + +**Quick start (texture paint, GUI):** + +1. Switch to **Material Mode** (mode bar at the top). +2. Select a mesh entity. The Inspector's **Texture Paint** section + shows a slot picker (every paintable TUS across submeshes) and a 256Γ—256 + live preview of the active texture. +3. Click the **paint brush** button in the toolbar to enable painting. The + first click on the mesh auto-creates a paint session against the active + slot (or you can pre-create with *Create / Attach Texture*). +4. Pick a tool from the row at the top of the panel: ✏ Paint, ⌫ Erase, ⧉ Fill, + ⊰ Pick (eyedropper), ∿ Smudge. Brush color/radius/strength/falloff comes + from the shared Paint Brush section above the toolbar's brush popup. +5. Left-click-drag on **either** the 3D mesh or the 2D preview panel. Both + drive the same paint buffer; the 2D preview updates in real time and a + brush ring shows where the cursor maps on the 3D surface (and vice versa). +6. *Save…* / *Load…* round-trips the buffer to disk as PNG (or any format + QImage can write). + +**Vertex paint (GUI):** + +Same flow, but vertex paint operates in **Edit Mode** instead. Press **Tab** +on a selected mesh to enter Edit Mode, then tick *Vertex Color Preview* in +the Edit Mode Tools section to see vertex colors live. + +**Bake Vertex Colors β†’ Texture** rasterizes the active mesh's vertex colors +into a UV-space PNG via barycentric interpolation, then dilates the result +outward by N pixels to mask UV-seam bleed at MIP-map time. + +**Headless (CLI):** + +```bash +qtmesh bake-vertex-colors model.fbx -o color_map.png --resolution 1024 --dilation 4 +``` + +**Export.** Vertex colors are preserved on export to formats that support +them (glTF preferred β€” verified round-trip). + +**Notes / limitations.** + +- Brush size is in local mesh units (shared with vertex paint). For texture + paint we divide by mesh bounding-box extent and clamp to a UV-friendly + range, so 0.25 produces a sensible stamp on both a unit cube and a + 100-unit character. +- Painting auto-rebinds every TUS named `albedo` or `diffuse_map` on the + active material β€” imported PBR materials alias the diffuse texture under + both, and rebinding only one would leave the other pointing at the + original. +- Bake uses fan triangulation of n-gon faces; concave faces should be + pre-triangulated. + +--- + ### ✨ Merge Mixamo Animations in Seconds Download animations from [Mixamo](https://www.mixamo.com), drop them into QtMeshEditor, and merge into a single file β€” export as glTF, FBX, Collada, OBJ, or Ogre Mesh. @@ -198,6 +260,7 @@ Split View|Skeleton Animation Controls - **Animation resampling** β€” reduce keyframe density for game engines - **Pose export** β€” bake animation frames as static meshes (3D printing) - **LOD generation** β€” automatic level-of-detail mesh reduction +- **Paint tools** β€” vertex paint, texture paint (BaseColor), bake vertex colors to texture with seam dilation - **Material editor** β€” visual editing with AI-assisted generation - **Skeleton inspection** β€” bone weights, debug overlays, animation preview - **Scene management** β€” duplicate (Ctrl+D), group (Ctrl+G), snap, pivot modes diff --git a/qml/PropertiesPanel.qml b/qml/PropertiesPanel.qml index 0c3e61dd1..2592f8c1b 100644 --- a/qml/PropertiesPanel.qml +++ b/qml/PropertiesPanel.qml @@ -282,6 +282,21 @@ Rectangle { Component.onCompleted: content = editModeToolsComponent } + // ---- Texture Paint (Material mode) ---- + // (Brush color/radius/strength/falloff live on the toolbar + // paint-brush popup. The Inspector panel keeps only the + // paint-target switch, slot picker, and texture preview.) + + CollapsibleSection { + title: "Texture Paint" + sectionVisible: root.modeToolSectionVisible( + EditorModeController.MaterialMode, + true) + expanded: true + + Component.onCompleted: content = texturePaintComponent + } + CollapsibleSection { title: "Workspace Panels" sectionVisible: root.currentTab === root.modeToolsTab @@ -934,6 +949,521 @@ Rectangle { } } + // ---- Paint Brush Content (DEPRECATED β€” kept dormant) ---- + // Brush color/radius/strength/falloff now live exclusively on the + // toolbar paint-brush popup. The component below is no longer + // wired into any CollapsibleSection; left here only because + // removing it would churn 100+ lines of unrelated diff. + Component { + id: paintBrushComponent + + Column { + id: brushCol + width: parent ? parent.width : 200 + padding: 8 + spacing: 6 + + property color brushColor: TexturePaintController.texturePaintColor + property real brushRadius: TexturePaintController.texturePaintRadius + property real brushStrength: TexturePaintController.texturePaintStrength + property real brushFalloff: TexturePaintController.texturePaintFalloff + + Connections { + target: TexturePaintController + function onTexturePaintChanged() { + brushCol.brushColor = TexturePaintController.texturePaintColor + brushCol.brushRadius = TexturePaintController.texturePaintRadius + brushCol.brushStrength = TexturePaintController.texturePaintStrength + brushCol.brushFalloff = TexturePaintController.texturePaintFalloff + } + } + + Text { + width: parent.width - 16 + text: "Shared brush settings for Vertex Paint and Texture Paint. " + + "The toolbar Vertex Paint popup uses these same values." + color: PropertiesPanelController.textColor + font.pixelSize: 10; opacity: 0.7 + wrapMode: Text.Wrap + } + + // Color picker (native dialog) + Row { + spacing: 6 + Text { + text: "Color" + color: PropertiesPanelController.textColor; font.pixelSize: 11 + anchors.verticalCenter: parent.verticalCenter + width: 70 + } + Rectangle { + width: 28; height: 22; radius: 3 + color: brushCol.brushColor + border.color: PropertiesPanelController.borderColor; border.width: 1 + MouseArea { + anchors.fill: parent; cursorShape: Qt.PointingHandCursor + onClicked: TexturePaintController.pickBrushColorInteractive() + } + } + } + + // Radius slider + Row { + spacing: 6 + Text { + text: "Radius"; width: 70 + color: PropertiesPanelController.textColor; font.pixelSize: 11 + anchors.verticalCenter: parent.verticalCenter + } + Slider { + width: 140 + from: 0.02; to: 2.0; stepSize: 0.01 + value: brushCol.brushRadius + onMoved: TexturePaintController.setBrushRadius(value) + } + Text { + text: brushCol.brushRadius.toFixed(2) + color: PropertiesPanelController.textColor; font.pixelSize: 10 + anchors.verticalCenter: parent.verticalCenter + } + } + + // Strength slider + Row { + spacing: 6 + Text { + text: "Strength"; width: 70 + color: PropertiesPanelController.textColor; font.pixelSize: 11 + anchors.verticalCenter: parent.verticalCenter + } + Slider { + width: 140 + from: 0.0; to: 1.0; stepSize: 0.01 + value: brushCol.brushStrength + onMoved: TexturePaintController.setBrushStrength(value) + } + Text { + text: brushCol.brushStrength.toFixed(2) + color: PropertiesPanelController.textColor; font.pixelSize: 10 + anchors.verticalCenter: parent.verticalCenter + } + } + + // Falloff slider + Row { + spacing: 6 + Text { + text: "Falloff"; width: 70 + color: PropertiesPanelController.textColor; font.pixelSize: 11 + anchors.verticalCenter: parent.verticalCenter + } + Slider { + width: 140 + from: 0.0; to: 1.0; stepSize: 0.01 + value: brushCol.brushFalloff + onMoved: TexturePaintController.setBrushFalloff(value) + } + Text { + text: brushCol.brushFalloff.toFixed(2) + color: PropertiesPanelController.textColor; font.pixelSize: 10 + anchors.verticalCenter: parent.verticalCenter + } + } + } + } + + // ---- Texture Paint Content ---- + Component { + id: texturePaintComponent + + Column { + id: texPaintCol + width: parent ? parent.width : 200 + padding: 8 + spacing: 8 + + property bool paintOn: TexturePaintController.texturePaintEnabled + property bool hasSession: TexturePaintController.hasActiveSession + property int sessionRes: TexturePaintController.textureResolution + property var slots: TexturePaintController.textureSlots + property int activeSlot: TexturePaintController.activeSlotIndex + property int brushTool: TexturePaintController.brushTool + property int paintTarget: TexturePaintController.paintTarget + property string previewUri: TexturePaintController.previewDataUri + // Live hover position in UV space, fed by hoveredUVChanged. + property real hoverU: -1 + property real hoverV: -1 + + Connections { + target: TexturePaintController + function onTexturePaintChanged() { + texPaintCol.paintOn = TexturePaintController.texturePaintEnabled + } + function onSessionChanged() { + texPaintCol.hasSession = TexturePaintController.hasActiveSession + texPaintCol.sessionRes = TexturePaintController.textureResolution + } + function onSlotsChanged() { + texPaintCol.slots = TexturePaintController.textureSlots + texPaintCol.activeSlot = TexturePaintController.activeSlotIndex + } + function onPreviewChanged() { + texPaintCol.previewUri = TexturePaintController.previewDataUri + } + function onBrushToolChanged() { + texPaintCol.brushTool = TexturePaintController.brushTool + } + function onPaintTargetChanged() { + texPaintCol.paintTarget = TexturePaintController.paintTarget + } + function onHoveredUVChanged(u, v) { + texPaintCol.hoverU = u + texPaintCol.hoverV = v + } + } + + Text { + width: parent.width - 16 + text: "Paint into the model β€” either as vertex colors " + + "(polypaint, exported with the mesh) or into the " + + "BaseColor texture. Pick the target below." + color: PropertiesPanelController.textColor + font.pixelSize: 10; opacity: 0.7 + wrapMode: Text.Wrap + } + + // Paint target switch \u2014 picking a target also enables paint. + // Three states: Off / Vertex / Texture. Defaults to Vertex. + Row { + spacing: 4 + Text { + text: "Paint:" + color: PropertiesPanelController.textColor; font.pixelSize: 11 + anchors.verticalCenter: parent.verticalCenter + width: 50 + } + Repeater { + model: [ + { target: -1, label: "Off" }, + { target: 1, label: "Vertex" }, + { target: 0, label: "Texture" } + ] + Rectangle { + width: 70; height: 26; radius: 3 + property bool isActive: modelData.target === -1 + ? !texPaintCol.paintOn + : (texPaintCol.paintOn && texPaintCol.paintTarget === modelData.target) + color: isActive + ? PropertiesPanelController.highlightColor + : (tgtMa.containsMouse ? Qt.lighter(PropertiesPanelController.panelColor, 1.5) + : PropertiesPanelController.headerColor) + border.color: PropertiesPanelController.borderColor; border.width: 1 + Text { + anchors.centerIn: parent + text: modelData.label + color: PropertiesPanelController.textColor + font.pixelSize: 10 + } + MouseArea { + id: tgtMa; anchors.fill: parent; hoverEnabled: true; cursorShape: Qt.PointingHandCursor + onClicked: { + if (modelData.target === -1) { + TexturePaintController.texturePaintEnabled = false + } else { + TexturePaintController.paintTarget = modelData.target + if (!texPaintCol.paintOn) + TexturePaintController.texturePaintEnabled = true + } + } + } + } + } + } + + // Tool selector \u2014 Paint, Erase, Fill, Picker, Smudge + Row { + spacing: 4 + Repeater { + model: [ + { tool: 0, label: "Paint", glyph: "\u270f" }, + { tool: 1, label: "Erase", glyph: "\u232b" }, + { tool: 2, label: "Fill", glyph: "\u29c9" }, + { tool: 3, label: "Pick", glyph: "\u22b0" }, + { tool: 4, label: "Smudge", glyph: "\u223f" } + ] + Rectangle { + width: 52; height: 26; radius: 3 + color: texPaintCol.brushTool === modelData.tool + ? PropertiesPanelController.highlightColor + : (toolMa.containsMouse ? Qt.lighter(PropertiesPanelController.panelColor, 1.5) + : PropertiesPanelController.headerColor) + border.color: PropertiesPanelController.borderColor; border.width: 1 + Text { + anchors.centerIn: parent + text: modelData.glyph + " " + modelData.label + color: PropertiesPanelController.textColor + font.pixelSize: 10 + } + MouseArea { + id: toolMa; anchors.fill: parent; hoverEnabled: true; cursorShape: Qt.PointingHandCursor + onClicked: TexturePaintController.brushTool = modelData.tool + } + } + } + } + + // Texture slot picker \u2014 populated by selection + Row { + spacing: 6 + width: parent.width - 16 + Text { + text: "Slot:" + color: PropertiesPanelController.textColor + font.pixelSize: 11 + anchors.verticalCenter: parent.verticalCenter + width: 36 + } + ThemedComboBox { + id: slotCombo + width: 180 + enabled: texPaintCol.slots.length > 0 + model: { + const labels = [] + for (let i = 0; i < texPaintCol.slots.length; ++i) + labels.push(texPaintCol.slots[i].label || ("slot " + i)) + return labels.length === 0 ? ["(no texture slots \u2014 select a mesh)"] : labels + } + currentIndex: Math.max(0, texPaintCol.activeSlot) + onActivated: function(index) { + if (texPaintCol.slots.length > 0) + TexturePaintController.activeSlotIndex = index + } + } + // UV island overlay toggle + Rectangle { + width: 18; height: 18; radius: 3 + color: TexturePaintController.uvOverlayVisible + ? PropertiesPanelController.highlightColor + : PropertiesPanelController.controlBgColor + border.color: PropertiesPanelController.borderColor; border.width: 1 + anchors.verticalCenter: parent.verticalCenter + Text { + anchors.centerIn: parent + text: TexturePaintController.uvOverlayVisible ? "\u2713" : "" + color: "white"; font.pixelSize: 10 + } + MouseArea { + anchors.fill: parent; cursorShape: Qt.PointingHandCursor + onClicked: TexturePaintController.uvOverlayVisible = !TexturePaintController.uvOverlayVisible + } + } + Text { + text: "UV" + color: PropertiesPanelController.textColor + font.pixelSize: 11 + anchors.verticalCenter: parent.verticalCenter + } + } + + // Session info + Text { + width: parent.width - 16 + text: texPaintCol.hasSession + ? ("Active texture: " + texPaintCol.sessionRes + "\u00d7" + texPaintCol.sessionRes) + : "No texture session \u2014 enable paint or click \"Create / Attach Texture\"." + color: texPaintCol.hasSession ? "#60c060" : PropertiesPanelController.textColor + font.pixelSize: 10 + opacity: texPaintCol.hasSession ? 1.0 : 0.7 + wrapMode: Text.Wrap + } + + // ---- 2D preview / paint surface ---- + // Live image of the paint buffer; clicking and dragging + // paints into the texture in UV space. Crosshair indicator + // mirrors the 3D-mesh hover position. + Rectangle { + width: 256; height: 256 + color: "#222" + border.color: PropertiesPanelController.borderColor + border.width: 1 + visible: texPaintCol.hasSession + + Image { + id: previewImg + anchors.fill: parent + anchors.margins: 1 + source: texPaintCol.previewUri + fillMode: Image.PreserveAspectFit + smooth: false + cache: false + // Bust the cache when source string changes + onSourceChanged: previewImg.update() + } + // UV-island wireframe overlay (toggleable). + Image { + id: uvOverlayImg + anchors.fill: parent + anchors.margins: 1 + visible: TexturePaintController.uvOverlayVisible + opacity: 0.7 + source: TexturePaintController.uvOverlayDataUri + fillMode: Image.PreserveAspectFit + smooth: false + cache: false + } + + // Crosshair indicator at hover UV + Rectangle { + visible: texPaintCol.hoverU >= 0 && texPaintCol.hoverV >= 0 + width: 1; height: parent.height - 2 + color: "#ff3030" + x: 1 + Math.round(texPaintCol.hoverU * (parent.width - 2)) + y: 1 + } + Rectangle { + visible: texPaintCol.hoverU >= 0 && texPaintCol.hoverV >= 0 + width: parent.width - 2; height: 1 + color: "#ff3030" + x: 1 + y: 1 + Math.round(texPaintCol.hoverV * (parent.height - 2)) + } + + MouseArea { + id: paintArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.CrossCursor + // Stop parent layouts from stealing the drag β€” QML + // scrollers / containers may grab the press as a + // flick gesture after a few pixels of motion, + // killing the brush stroke. preventStealing keeps + // the grab locked here. + preventStealing: true + property bool dragging: false + + function toUV(mx, my) { + const W = paintArea.width + const H = paintArea.height + return Qt.point(Math.max(0, Math.min(1, mx / W)), + Math.max(0, Math.min(1, my / H))) + } + onPositionChanged: function(m) { + const uv = toUV(m.x, m.y) + // While dragging: keep painting regardless of + // pressed/buttons state. onPressed sets the + // flag; onReleased / onExited / onCanceled + // clear it. Some Qt6/macOS edge cases zero + // m.buttons mid-drag which used to break the + // stroke after the first move. + if (dragging) { + TexturePaintController.updateStrokeUV(uv.x, uv.y) + } else { + TexturePaintController.setHoveredUV(uv.x, uv.y) + } + } + onExited: { + // Don't end the stroke if we're still mid-drag β€” + // the user may drag off and back onto the panel + // in one motion. onReleased fires globally + // (mouse is grabbed by the press) so we don't + // need a defensive end-stroke here. + if (!dragging) TexturePaintController.clearHoveredUV() + } + onPressed: function(m) { + if (m.button !== Qt.LeftButton) return + const uv = toUV(m.x, m.y) + if (TexturePaintController.beginStrokeUV(uv.x, uv.y)) + dragging = true + } + onReleased: function(m) { + if (dragging) { + TexturePaintController.endStrokeUV() + dragging = false + } + } + onCanceled: { + if (dragging) { + TexturePaintController.endStrokeUV() + dragging = false + } + } + } + } + + // Action row 1: create, save, load + Flow { + width: parent.width - 16 + spacing: 4 + + // Resolution picker for fresh textures. + ThemedComboBox { + id: resCombo + width: 80 + model: ["256", "512", "1024", "2048", "4096"] + currentIndex: 2 // 1024 + } + + Rectangle { + width: 130; height: 24; radius: 3 + color: createMa.containsMouse ? Qt.lighter(PropertiesPanelController.panelColor, 1.5) : PropertiesPanelController.headerColor + border.color: PropertiesPanelController.borderColor; border.width: 1 + Text { anchors.centerIn: parent; text: "Create / Attach Texture"; color: PropertiesPanelController.textColor; font.pixelSize: 10 } + MouseArea { + id: createMa; anchors.fill: parent; hoverEnabled: true; cursorShape: Qt.PointingHandCursor + onClicked: { + const res = parseInt(resCombo.model[resCombo.currentIndex]) + TexturePaintController.ensurePaintableTexture(res) + } + } + } + + Rectangle { + width: 70; height: 24; radius: 3 + color: saveMa.containsMouse ? Qt.lighter(PropertiesPanelController.panelColor, 1.5) : PropertiesPanelController.headerColor + border.color: PropertiesPanelController.borderColor; border.width: 1 + opacity: texPaintCol.hasSession ? 1.0 : 0.4 + Text { anchors.centerIn: parent; text: "Save\u2026"; color: PropertiesPanelController.textColor; font.pixelSize: 10 } + MouseArea { + id: saveMa; anchors.fill: parent; hoverEnabled: true; cursorShape: Qt.PointingHandCursor + enabled: texPaintCol.hasSession + onClicked: TexturePaintController.savePaintBufferInteractive() + } + } + + Rectangle { + width: 70; height: 24; radius: 3 + color: loadMa.containsMouse ? Qt.lighter(PropertiesPanelController.panelColor, 1.5) : PropertiesPanelController.headerColor + border.color: PropertiesPanelController.borderColor; border.width: 1 + Text { anchors.centerIn: parent; text: "Load\u2026"; color: PropertiesPanelController.textColor; font.pixelSize: 10 } + MouseArea { + id: loadMa; anchors.fill: parent; hoverEnabled: true; cursorShape: Qt.PointingHandCursor + onClicked: TexturePaintController.loadPaintBufferInteractive() + } + } + } + + // Action row 2: bake vertex colors + Flow { + width: parent.width - 16 + spacing: 4 + + Rectangle { + width: 200; height: 24; radius: 3 + color: bakeMa.containsMouse ? Qt.lighter(PropertiesPanelController.panelColor, 1.5) : PropertiesPanelController.headerColor + border.color: PropertiesPanelController.borderColor; border.width: 1 + Text { anchors.centerIn: parent; text: "Bake Vertex Colors \u2192 Texture"; color: PropertiesPanelController.textColor; font.pixelSize: 10 } + MouseArea { + id: bakeMa; anchors.fill: parent; hoverEnabled: true; cursorShape: Qt.PointingHandCursor + onClicked: { + const res = parseInt(resCombo.model[resCombo.currentIndex]) + TexturePaintController.bakeVertexColorsToTexture(res, 4, "") + } + } + } + } + } + } + Component { id: workspacePanelsComponent diff --git a/src/CLIPipeline.cpp b/src/CLIPipeline.cpp index f6792ec11..d9de75d13 100644 --- a/src/CLIPipeline.cpp +++ b/src/CLIPipeline.cpp @@ -18,6 +18,9 @@ #include "DrawCallAnalyzer.h" #include "VertexCacheOptimizer.h" #include "MeshDecimator.h" +#include "EditableMesh.h" +#include "TexturePaintBuffer.h" +#include "VertexColorBaker.h" #include "QtMeshCloudClient.h" #include #include @@ -664,6 +667,11 @@ void CLIPipeline::printUsage() " --simplify-scale-tol S default 0.0001\n" " --simplify-preset P shorthand for the three tolerances;\n" " P = conservative | balanced | aggressive\n" + " bake-vertex-colors -o [--resolution N] [--dilation N] [--json]\n" + " Bake vertex colors to a UV-space PNG. Walks every UV-mapped\n" + " triangle, rasterizes barycentric-interpolated vertex colors,\n" + " then dilates outward by N pixels to mask seam bleed at MIP time.\n" + " Default resolution=1024, dilation=4. Output PNG is RGBA.\n" "\n" "Global options:\n" " --help, -h Show this help\n" @@ -1056,6 +1064,7 @@ int CLIPipeline::run(int argc, char* argv[]) else if (cmd == "vertex-cache") rc = cmdVertexCache(argc, argv); else if (cmd == "decimate") rc = cmdDecimate(argc, argv); else if (cmd == "optimize") rc = cmdOptimize(argc, argv); + else if (cmd == "bake-vertex-colors") rc = cmdBakeVertexColors(argc, argv); if (rc < 0) { err() << "Error: Unknown command '" << cmd << "'" << Qt::endl; @@ -4623,3 +4632,123 @@ int CLIPipeline::cmdOptimize(int argc, char* argv[]) emitOptimizeReport(fi, cmdArgs.outputPath, srcBytes, outBytes, stages, cmdArgs.jsonOutput); return 0; } + +int CLIPipeline::cmdBakeVertexColors(int argc, char* argv[]) +{ + // Parse: + // bake-vertex-colors -o + // [--resolution N] [--dilation N] [--json] + QString inputPath, outputPath; + int resolution = 1024; + int dilation = 4; + bool jsonOutput = false; + + for (int i = 1; i < argc; ++i) { + QString arg(argv[i]); + if (arg == "bake-vertex-colors" || arg == "--cli") continue; + if ((arg == "-o" || arg == "--output") && i + 1 < argc) { + outputPath = QString(argv[++i]); continue; + } + if (arg == "--resolution" && i + 1 < argc) { + bool ok = false; + const int v = QString(argv[++i]).toInt(&ok); + if (!ok || v < 16 || v > 8192) { + err() << "Error: --resolution must be an integer in [16..8192]" << Qt::endl; + return 2; + } + resolution = v; continue; + } + if (arg == "--dilation" && i + 1 < argc) { + bool ok = false; + const int v = QString(argv[++i]).toInt(&ok); + if (!ok || v < 0 || v > 64) { + err() << "Error: --dilation must be an integer in [0..64]" << Qt::endl; + return 2; + } + dilation = v; continue; + } + if (arg == "--json") { jsonOutput = true; continue; } + if (!arg.startsWith("-") && inputPath.isEmpty()) { + inputPath = arg; continue; + } + } + + if (inputPath.isEmpty() || outputPath.isEmpty()) { + err() << "Error: missing required arguments." << Qt::endl; + err() << "Usage: qtmesh bake-vertex-colors -o " << Qt::endl; + err() << " [--resolution N] [--dilation N] [--json]" << Qt::endl; + return 2; + } + + const QFileInfo fi(inputPath); + if (!fi.exists()) { + err() << "Error: File not found: " << inputPath << Qt::endl; + return 1; + } + + if (!initOgreHeadless()) return 1; + + SentryReporter::addBreadcrumb("cli.bake-vertex-colors", + QString("Bake vertex colors β†’ %1Γ—%1 PNG (dilation=%2)").arg(resolution).arg(dilation)); + SentryReporter::addBreadcrumb("file.import", + QString("Importing %1").arg(fi.absoluteFilePath())); + + MeshImporterExporter::importer({fi.absoluteFilePath()}, 0); + const auto& entities = Manager::getSingleton()->getEntities(); + if (entities.isEmpty()) { + err() << "Error: Failed to load file: " << inputPath << Qt::endl; + return 1; + } + + // Bake the first entity found. Multi-entity scenes get a warning; the + // caller can pre-split the scene if they need per-entity bakes. + Ogre::Entity* entity = nullptr; + int entityCount = 0; + for (auto* obj : entities) { + if (!obj || obj->getMovableType() != "Entity") continue; + if (!entity) entity = static_cast(obj); + ++entityCount; + } + if (!entity) { + err() << "Error: No mesh entity in: " << inputPath << Qt::endl; + return 1; + } + if (entityCount > 1) { + err() << "Note: " << entityCount + << " entities in scene; baking the first only." << Qt::endl; + } + + EditableMesh mesh; + if (!mesh.loadFromEntity(entity)) { + err() << "Error: Failed to decompose mesh for: " << inputPath << Qt::endl; + return 1; + } + + TexturePaintBuffer buffer; + VertexColorBaker::Options opts; + opts.resolution = resolution; + opts.dilationPixels = dilation; + const int painted = VertexColorBaker::bake(mesh, buffer, opts); + + if (!buffer.save(outputPath.toStdString())) { + err() << "Error: Failed to write: " << outputPath << Qt::endl; + return 1; + } + + if (jsonOutput) { + QJsonObject root; + root["input"] = inputPath; + root["output"] = outputPath; + root["resolution"] = resolution; + root["dilation"] = dilation; + root["pixels_rasterized"] = painted; + root["entity"] = QString::fromStdString(entity->getName()); + QJsonDocument doc(root); + cliWrite(QString::fromUtf8(doc.toJson(QJsonDocument::Compact)) + "\n"); + } else { + cliWrite(QStringLiteral("Baked %1 pixels into %2Γ—%2 texture: %3\n") + .arg(painted).arg(resolution).arg(outputPath)); + cliWrite(QStringLiteral(" dilation: %1 px\n").arg(dilation)); + } + return 0; +} diff --git a/src/CLIPipeline.h b/src/CLIPipeline.h index 582fca9fc..8b646652b 100644 --- a/src/CLIPipeline.h +++ b/src/CLIPipeline.h @@ -114,6 +114,11 @@ class CLIPipeline { /// per-stage before/after report (text or --json). static int cmdOptimize(int argc, char* argv[]); + /// Phase 7 paint: bake EditableMesh vertex colors into a UV-space + /// PNG texture with configurable resolution and seam dilation. + /// Surfaces VertexColorBaker via the CLI for headless asset pipelines. + static int cmdBakeVertexColors(int argc, char* argv[]); + /// Map file extension to MeshImporterExporter format string. static QString formatForExtension(const QString& path); }; diff --git a/src/CLIPipeline_test.cpp b/src/CLIPipeline_test.cpp index 99c74e799..243f6208e 100644 --- a/src/CLIPipeline_test.cpp +++ b/src/CLIPipeline_test.cpp @@ -50,6 +50,18 @@ QString writeMinimalObj(const QString& dirPath, const QString& fileName) return path; } +void clearManagerScene() +{ + if (!Manager::getSingletonPtr()) + return; + + auto nodes = Manager::getSingleton()->getSceneNodes(); + for (auto* node : nodes) { + Manager::getSingleton()->destroyAllAttachedMovableObjects(node); + Manager::getSingleton()->destroySceneNode(node); + } +} + Ogre::MeshPtr createTwoSubmeshSharedMesh(const std::string& name) { Ogre::MeshPtr mesh = Ogre::MeshManager::getSingleton().createManual( @@ -3151,3 +3163,326 @@ TEST(CLIPipelineCmdNormalFromHeight, InvertGFlipsGreenChannel) ASSERT_FALSE(img.isNull()); EXPECT_GT(qGreen(img.pixel(8, 8)), 135); } + +// -- cmdAtlas (texture atlas packing) -- + +TEST(CLIPipelineCmdAtlas, MissingInputsOrOutputFails) +{ + TestArgv noInputs({"qtmesh", "atlas", "-o", "atlas.png"}); + EXPECT_EQ(CLIPipeline::cmdAtlas(noInputs.argc(), noInputs.argv()), 2); + + TestArgv noOutput({"qtmesh", "atlas", "--inputs", "a.png"}); + EXPECT_EQ(CLIPipeline::cmdAtlas(noOutput.argc(), noOutput.argv()), 2); + + TestArgv emptyInputs({"qtmesh", "atlas", "--inputs", ", ,", "-o", "atlas.png"}); + EXPECT_EQ(CLIPipeline::cmdAtlas(emptyInputs.argc(), emptyInputs.argv()), 2); +} + +TEST(CLIPipelineCmdAtlas, MissingSourceImageReturnsRuntimeError) +{ + QTemporaryDir tmp; + ASSERT_TRUE(tmp.isValid()); + const QByteArray outPath = tmp.filePath("atlas.png").toUtf8(); + + TestArgv args({"qtmesh", "atlas", + "--inputs", "/nonexistent/missing_atlas_source.png", + "-o", outPath.constData()}); + EXPECT_EQ(CLIPipeline::cmdAtlas(args.argc(), args.argv()), 1); +} + +TEST(CLIPipelineCmdAtlas, WritesAtlasAndManifestFromCommaSeparatedInputs) +{ + QTemporaryDir tmp; + ASSERT_TRUE(tmp.isValid()); + const QString red = writeGreyPng(tmp, "red.png", 8, 8, [](int, int){ return 32; }); + const QString green = writeGreyPng(tmp, "green.png", 8, 8, [](int, int){ return 192; }); + const QByteArray inputs = QString("%1, %2").arg(red, green).toUtf8(); + const QByteArray outPath = tmp.filePath("atlas.png").toUtf8(); + const QByteArray manifestPath = tmp.filePath("atlas.json").toUtf8(); + + TestArgv args({"qtmesh", "atlas", + "--inputs", inputs.constData(), + "--width", "32", + "--height", "16", + "--padding", "1", + "--manifest", manifestPath.constData(), + "-o", outPath.constData()}); + EXPECT_EQ(CLIPipeline::cmdAtlas(args.argc(), args.argv()), 0); + + EXPECT_TRUE(QFileInfo::exists(outPath)); + EXPECT_TRUE(QFileInfo::exists(manifestPath)); + const QJsonDocument manifest = QJsonDocument::fromJson([&] { + QFile f(manifestPath); + EXPECT_TRUE(f.open(QIODevice::ReadOnly)); + return f.readAll(); + }()); + ASSERT_TRUE(manifest.isObject()); + EXPECT_TRUE(manifest.object().contains(QStringLiteral("tiles"))); +} + +TEST(CLIPipelineCmdAtlas, ManifestWriteFailureReturnsRuntimeError) +{ + QTemporaryDir tmp; + ASSERT_TRUE(tmp.isValid()); + const QString source = writeGreyPng(tmp, "source.png", 8, 8, [](int, int){ return 128; }); + const QByteArray sourceArg = source.toUtf8(); + const QByteArray outPath = tmp.filePath("atlas.png").toUtf8(); + const QByteArray manifestDir = tmp.path().toUtf8(); + + TestArgv args({"qtmesh", "atlas", + "--inputs", sourceArg.constData(), + "--manifest", manifestDir.constData(), + "-o", outPath.constData()}); + EXPECT_EQ(CLIPipeline::cmdAtlas(args.argc(), args.argv()), 1); +} + +// -- cmdAtlasApply argument validation -- + +TEST(CLIPipelineCmdAtlasApply, MissingRequiredArgumentsFails) +{ + TestArgv args({"qtmesh", "atlas-apply", "mesh.obj"}); + EXPECT_EQ(CLIPipeline::cmdAtlasApply(args.argc(), args.argv()), 2); +} + +TEST(CLIPipelineCmdAtlasApply, InvalidMatchModeFails) +{ + QTemporaryDir tmp; + ASSERT_TRUE(tmp.isValid()); + const QString mesh = writeMinimalObj(tmp.path(), "mesh.obj"); + const QString manifest = tmp.filePath("atlas.json"); + const QString atlas = writeGreyPng(tmp, "atlas.png", 8, 8, [](int, int){ return 128; }); + QFile manifestFile(manifest); + ASSERT_TRUE(manifestFile.open(QIODevice::WriteOnly)); + manifestFile.close(); + + const QByteArray meshArg = mesh.toUtf8(); + const QByteArray outArg = tmp.filePath("out.obj").toUtf8(); + const QByteArray manifestArg = manifest.toUtf8(); + const QByteArray atlasArg = atlas.toUtf8(); + TestArgv args({"qtmesh", "atlas-apply", meshArg.constData(), + "-o", outArg.constData(), + "--manifest", manifestArg.constData(), + "--atlas", atlasArg.constData(), + "--match", "filename"}); + EXPECT_EQ(CLIPipeline::cmdAtlasApply(args.argc(), args.argv()), 2); +} + +TEST(CLIPipelineCmdAtlasApply, MissingFilesReturnRuntimeErrors) +{ + QTemporaryDir tmp; + ASSERT_TRUE(tmp.isValid()); + const QByteArray outArg = tmp.filePath("out.obj").toUtf8(); + const QByteArray manifestArg = tmp.filePath("atlas.json").toUtf8(); + const QByteArray atlasArg = tmp.filePath("atlas.png").toUtf8(); + + TestArgv missingMesh({"qtmesh", "atlas-apply", "/nonexistent/source.obj", + "-o", outArg.constData(), + "--manifest", manifestArg.constData(), + "--atlas", atlasArg.constData()}); + EXPECT_EQ(CLIPipeline::cmdAtlasApply(missingMesh.argc(), missingMesh.argv()), 1); + + const QByteArray meshArg = writeMinimalObj(tmp.path(), "mesh.obj").toUtf8(); + TestArgv missingManifest({"qtmesh", "atlas-apply", meshArg.constData(), + "-o", outArg.constData(), + "--manifest", manifestArg.constData(), + "--atlas", atlasArg.constData()}); + EXPECT_EQ(CLIPipeline::cmdAtlasApply(missingManifest.argc(), missingManifest.argv()), 1); +} + +TEST(CLIPipelineCmdAtlasApply, MissingAtlasAndInvalidManifestReturnRuntimeErrors) +{ + QTemporaryDir tmp; + ASSERT_TRUE(tmp.isValid()); + const QByteArray meshArg = writeMinimalObj(tmp.path(), "mesh.obj").toUtf8(); + const QByteArray outArg = tmp.filePath("out.obj").toUtf8(); + const QString manifestPath = tmp.filePath("atlas.json"); + QFile manifestFile(manifestPath); + ASSERT_TRUE(manifestFile.open(QIODevice::WriteOnly | QIODevice::Text)); + manifestFile.write("{ not-json"); + manifestFile.close(); + const QByteArray manifestArg = manifestPath.toUtf8(); + const QByteArray missingAtlasArg = tmp.filePath("missing_atlas.png").toUtf8(); + + TestArgv missingAtlas({"qtmesh", "atlas-apply", meshArg.constData(), + "-o", outArg.constData(), + "--manifest", manifestArg.constData(), + "--atlas", missingAtlasArg.constData()}); + EXPECT_EQ(CLIPipeline::cmdAtlasApply(missingAtlas.argc(), missingAtlas.argv()), 1); + + const QString atlas = writeGreyPng(tmp, "atlas.png", 8, 8, [](int, int){ return 128; }); + const QByteArray atlasArg = atlas.toUtf8(); + TestArgv invalidManifest({"qtmesh", "atlas-apply", meshArg.constData(), + "-o", outArg.constData(), + "--manifest", manifestArg.constData(), + "--atlas", atlasArg.constData()}); + EXPECT_EQ(CLIPipeline::cmdAtlasApply(invalidManifest.argc(), invalidManifest.argv()), 1); +} + +TEST_F(CLIPipelineCmdTest, CmdAnalyzeLoadsObjInTextAndJsonModes) +{ + QTemporaryDir tmp; + ASSERT_TRUE(tmp.isValid()); + const QByteArray meshArg = writeMinimalObj(tmp.path(), "mesh.obj").toUtf8(); + + TestArgv textArgs({"qtmesh", "analyze", meshArg.constData()}); + EXPECT_EQ(CLIPipeline::cmdAnalyze(textArgs.argc(), textArgs.argv()), 0); + clearManagerScene(); + + TestArgv jsonArgs({"qtmesh", "analyze", meshArg.constData(), "--json"}); + EXPECT_EQ(CLIPipeline::cmdAnalyze(jsonArgs.argc(), jsonArgs.argv()), 0); +} + +TEST_F(CLIPipelineCmdTest, CmdMemoryLoadsObjAndHonorsExplicitBudget) +{ + QTemporaryDir tmp; + ASSERT_TRUE(tmp.isValid()); + const QByteArray meshArg = writeMinimalObj(tmp.path(), "mesh.obj").toUtf8(); + + TestArgv args({"qtmesh", "memory", meshArg.constData(), + "--budget", "1GB", + "--json", + "--no-cloud"}); + EXPECT_EQ(CLIPipeline::cmdMemory(args.argc(), args.argv()), 0); +} + +TEST_F(CLIPipelineCmdTest, CmdVertexCacheAnalyzesAndRewritesObj) +{ + QTemporaryDir tmp; + ASSERT_TRUE(tmp.isValid()); + const QByteArray meshArg = writeMinimalObj(tmp.path(), "mesh.obj").toUtf8(); + const QByteArray outArg = tmp.filePath("rewritten.obj").toUtf8(); + + TestArgv analyzeArgs({"qtmesh", "vertex-cache", meshArg.constData(), "--json"}); + EXPECT_EQ(CLIPipeline::cmdVertexCache(analyzeArgs.argc(), analyzeArgs.argv()), 0); + clearManagerScene(); + + TestArgv rewriteArgs({"qtmesh", "vertex-cache", meshArg.constData(), + "-o", outArg.constData()}); + EXPECT_EQ(CLIPipeline::cmdVertexCache(rewriteArgs.argc(), rewriteArgs.argv()), 0); + EXPECT_TRUE(QFileInfo::exists(outArg)); +} + +TEST_F(CLIPipelineCmdTest, CmdDecimateNoOpTargetStillWritesOutput) +{ + QTemporaryDir tmp; + ASSERT_TRUE(tmp.isValid()); + const QByteArray meshArg = writeMinimalObj(tmp.path(), "mesh.obj").toUtf8(); + const QByteArray outArg = tmp.filePath("decimated.obj").toUtf8(); + + TestArgv args({"qtmesh", "decimate", meshArg.constData(), + "-o", outArg.constData(), + "--target-tris", "99", + "--json"}); + EXPECT_EQ(CLIPipeline::cmdDecimate(args.argc(), args.argv()), 0); + EXPECT_TRUE(QFileInfo::exists(outArg)); +} + +TEST_F(CLIPipelineCmdTest, CmdOptimizeRunsVertexCacheOnly) +{ + QTemporaryDir tmp; + ASSERT_TRUE(tmp.isValid()); + const QByteArray meshArg = writeMinimalObj(tmp.path(), "mesh.obj").toUtf8(); + const QByteArray outArg = tmp.filePath("optimized.obj").toUtf8(); + + TestArgv args({"qtmesh", "optimize", meshArg.constData(), + "-o", outArg.constData(), + "--vertex-cache", + "--json"}); + EXPECT_EQ(CLIPipeline::cmdOptimize(args.argc(), args.argv()), 0); + EXPECT_TRUE(QFileInfo::exists(outArg)); +} + +// -- Phase 6 CLI command argument validation -- + +TEST(CLIPipelineCmdMemory, MissingInputAndInvalidBudgetFailAsUsage) +{ + TestArgv missing({"qtmesh", "memory"}); + EXPECT_EQ(CLIPipeline::cmdMemory(missing.argc(), missing.argv()), 2); + + TestArgv invalidBudget({"qtmesh", "memory", "mesh.obj", "--budget", "nope"}); + EXPECT_EQ(CLIPipeline::cmdMemory(invalidBudget.argc(), invalidBudget.argv()), 2); +} + +TEST(CLIPipelineCmdMemory, MissingFileReturnsRuntimeError) +{ + TestArgv args({"qtmesh", "memory", "/nonexistent/missing.obj", "--no-cloud"}); + EXPECT_EQ(CLIPipeline::cmdMemory(args.argc(), args.argv()), 1); +} + +TEST(CLIPipelineCmdAnalyze, MissingInputAndMissingFileFail) +{ + TestArgv missing({"qtmesh", "analyze"}); + EXPECT_EQ(CLIPipeline::cmdAnalyze(missing.argc(), missing.argv()), 2); + + TestArgv missingFile({"qtmesh", "analyze", "/nonexistent/missing.obj", "--json"}); + EXPECT_EQ(CLIPipeline::cmdAnalyze(missingFile.argc(), missingFile.argv()), 1); +} + +TEST(CLIPipelineCmdVertexCache, MissingInputAndMissingFileFail) +{ + TestArgv missing({"qtmesh", "vertex-cache"}); + EXPECT_EQ(CLIPipeline::cmdVertexCache(missing.argc(), missing.argv()), 2); + + TestArgv missingFile({"qtmesh", "vertex-cache", "/nonexistent/missing.obj", "--json"}); + EXPECT_EQ(CLIPipeline::cmdVertexCache(missingFile.argc(), missingFile.argv()), 1); +} + +TEST(CLIPipelineCmdDecimate, RejectsMissingAndAmbiguousTargets) +{ + TestArgv missing({"qtmesh", "decimate"}); + EXPECT_EQ(CLIPipeline::cmdDecimate(missing.argc(), missing.argv()), 2); + + TestArgv noOutput({"qtmesh", "decimate", "mesh.obj", "--reduction", "0.5"}); + EXPECT_EQ(CLIPipeline::cmdDecimate(noOutput.argc(), noOutput.argv()), 2); + + TestArgv ambiguous({"qtmesh", "decimate", "mesh.obj", "-o", "out.obj", + "--reduction", "0.5", "--target-tris", "10"}); + EXPECT_EQ(CLIPipeline::cmdDecimate(ambiguous.argc(), ambiguous.argv()), 2); +} + +TEST(CLIPipelineCmdDecimate, RejectsInvalidNumbersAndMissingFile) +{ + TestArgv badReduction({"qtmesh", "decimate", "mesh.obj", "-o", "out.obj", + "--reduction", "half"}); + EXPECT_EQ(CLIPipeline::cmdDecimate(badReduction.argc(), badReduction.argv()), 2); + + TestArgv badTarget({"qtmesh", "decimate", "mesh.obj", "-o", "out.obj", + "--target-verts", "lots"}); + EXPECT_EQ(CLIPipeline::cmdDecimate(badTarget.argc(), badTarget.argv()), 2); + + TestArgv missingFile({"qtmesh", "decimate", "/nonexistent/missing.obj", "-o", "out.obj", + "--target-tris", "10"}); + EXPECT_EQ(CLIPipeline::cmdDecimate(missingFile.argc(), missingFile.argv()), 1); +} + +TEST(CLIPipelineCmdOptimize, RejectsMissingOutputAndMissingFile) +{ + TestArgv missing({"qtmesh", "optimize"}); + EXPECT_EQ(CLIPipeline::cmdOptimize(missing.argc(), missing.argv()), 2); + + TestArgv noOutput({"qtmesh", "optimize", "mesh.obj", "--vertex-cache"}); + EXPECT_EQ(CLIPipeline::cmdOptimize(noOutput.argc(), noOutput.argv()), 2); + + TestArgv missingFile({"qtmesh", "optimize", "/nonexistent/missing.obj", "-o", "out.obj"}); + EXPECT_EQ(CLIPipeline::cmdOptimize(missingFile.argc(), missingFile.argv()), 1); +} + +TEST(CLIPipelineCmdOptimize, RejectsInvalidTargetsAndSimplifyPresets) +{ + TestArgv ambiguous({"qtmesh", "optimize", "mesh.obj", "-o", "out.obj", + "--reduction", "0.5", "--target-tris", "10"}); + EXPECT_EQ(CLIPipeline::cmdOptimize(ambiguous.argc(), ambiguous.argv()), 2); + + TestArgv negativeTarget({"qtmesh", "optimize", "mesh.obj", "-o", "out.obj", + "--target-verts", "-1"}); + EXPECT_EQ(CLIPipeline::cmdOptimize(negativeTarget.argc(), negativeTarget.argv()), 2); + + TestArgv unknownPreset({"qtmesh", "optimize", "mesh.obj", "-o", "out.obj", + "--simplify-preset", "maximum"}); + EXPECT_EQ(CLIPipeline::cmdOptimize(unknownPreset.argc(), unknownPreset.argv()), 2); + + TestArgv presetWithExplicitTol({"qtmesh", "optimize", "mesh.obj", "-o", "out.obj", + "--simplify-preset", "balanced", + "--simplify-scale-tol", "0.1"}); + EXPECT_EQ(CLIPipeline::cmdOptimize(presetWithExplicitTol.argc(), presetWithExplicitTol.argv()), 2); +} diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 67d13feec..be8b517fd 100755 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -79,6 +79,9 @@ MaterialPresetLibrary.cpp MeshLodController.cpp TextureChannelPacker.cpp TextureAtlasPacker.cpp +TexturePaintBuffer.cpp +TexturePaintController.cpp +VertexColorBaker.cpp ApplyAtlas.cpp EmbeddedTextureCache.cpp NormalMapGenerator.cpp @@ -173,6 +176,9 @@ MaterialPresetLibrary.h MeshLodController.h TextureChannelPacker.h TextureAtlasPacker.h +TexturePaintBuffer.h +TexturePaintController.h +VertexColorBaker.h ApplyAtlas.h EmbeddedTextureCache.h NormalMapGenerator.h diff --git a/src/TexturePaintBuffer.cpp b/src/TexturePaintBuffer.cpp new file mode 100644 index 000000000..363e239fb --- /dev/null +++ b/src/TexturePaintBuffer.cpp @@ -0,0 +1,245 @@ +#include "TexturePaintBuffer.h" + +#include +#include + +#include +#include +#include + +namespace { + +inline uint8_t floatToByte(float v) +{ + if (v <= 0.0f) return 0; + if (v >= 1.0f) return 255; + return static_cast(std::lround(v * 255.0f)); +} + +inline float byteToFloat(uint8_t v) +{ + return static_cast(v) / 255.0f; +} + +} // namespace + +TexturePaintBuffer::TexturePaintBuffer(int width, int height) +{ + resize(width, height); +} + +void TexturePaintBuffer::resize(int width, int height) +{ + m_width = std::max(0, width); + m_height = std::max(0, height); + const size_t n = static_cast(m_width) * static_cast(m_height) * 4u; + m_pixels.assign(n, 0xFF); + m_dirty = {}; +} + +void TexturePaintBuffer::clear(const Ogre::ColourValue& color) +{ + if (m_width <= 0 || m_height <= 0) return; + const uint8_t r = floatToByte(color.r); + const uint8_t g = floatToByte(color.g); + const uint8_t b = floatToByte(color.b); + const uint8_t a = floatToByte(color.a); + for (size_t i = 0; i < m_pixels.size(); i += 4) { + m_pixels[i + 0] = r; + m_pixels[i + 1] = g; + m_pixels[i + 2] = b; + m_pixels[i + 3] = a; + } + expandDirty(0, 0, m_width, m_height); +} + +Ogre::ColourValue TexturePaintBuffer::pixel(int x, int y) const +{ + if (x < 0 || y < 0 || x >= m_width || y >= m_height) + return Ogre::ColourValue(0.0f, 0.0f, 0.0f, 0.0f); + const size_t off = (static_cast(y) * static_cast(m_width) + static_cast(x)) * 4u; + return Ogre::ColourValue( + byteToFloat(m_pixels[off + 0]), + byteToFloat(m_pixels[off + 1]), + byteToFloat(m_pixels[off + 2]), + byteToFloat(m_pixels[off + 3])); +} + +void TexturePaintBuffer::setPixel(int x, int y, const Ogre::ColourValue& color) +{ + if (x < 0 || y < 0 || x >= m_width || y >= m_height) + return; + const size_t off = (static_cast(y) * static_cast(m_width) + static_cast(x)) * 4u; + m_pixels[off + 0] = floatToByte(color.r); + m_pixels[off + 1] = floatToByte(color.g); + m_pixels[off + 2] = floatToByte(color.b); + m_pixels[off + 3] = floatToByte(color.a); + expandDirty(x, y, x + 1, y + 1); +} + +void TexturePaintBuffer::uvToPixel(const Ogre::Vector2& uv, int& outX, int& outY) const +{ + // UV origin = top-left (Ogre + Qt convention). U β†’ X, V β†’ Y, both direct. + // Clamp to [0, size-1] so uv = (1.0, 1.0) maps to the last in-bounds + // texel rather than (width, height), which is out of range. Without + // this, tools that round-trip via uvToPixel (fill seed, picker, + // smudge) silently miss the right/bottom edge. + outX = std::clamp(static_cast(std::floor(uv.x * static_cast(m_width))), + 0, m_width - 1); + outY = std::clamp(static_cast(std::floor(uv.y * static_cast(m_height))), + 0, m_height - 1); +} + +Ogre::Vector2 TexturePaintBuffer::pixelToUV(int x, int y) const +{ + if (m_width <= 0 || m_height <= 0) + return Ogre::Vector2::ZERO; + const float u = (static_cast(x) + 0.5f) / static_cast(m_width); + const float v = (static_cast(y) + 0.5f) / static_cast(m_height); + return Ogre::Vector2(u, v); +} + +int TexturePaintBuffer::paintBrush(const Ogre::Vector2& uv, + float radiusUV, + const Ogre::ColourValue& color, + float strength, + float falloff) +{ + if (m_width <= 0 || m_height <= 0) return 0; + if (radiusUV <= 0.0f) return 0; + strength = std::clamp(strength, 0.0f, 1.0f); + falloff = std::clamp(falloff, 0.0f, 1.0f); + if (strength <= 0.0f) return 0; + + const float radiusXf = radiusUV * static_cast(m_width); + const float radiusYf = radiusUV * static_cast(m_height); + const float centerXf = uv.x * static_cast(m_width); + const float centerYf = uv.y * static_cast(m_height); + + int x0 = static_cast(std::floor(centerXf - radiusXf)); + int x1 = static_cast(std::ceil(centerXf + radiusXf)); + int y0 = static_cast(std::floor(centerYf - radiusYf)); + int y1 = static_cast(std::ceil(centerYf + radiusYf)); + x0 = std::max(0, x0); + y0 = std::max(0, y0); + x1 = std::min(m_width, x1); + y1 = std::min(m_height, y1); + if (x0 >= x1 || y0 >= y1) return 0; + + const float p = 1.0f + falloff * 3.0f; + const float invRx = 1.0f / std::max(radiusXf, 1e-6f); + const float invRy = 1.0f / std::max(radiusYf, 1e-6f); + int affected = 0; + int touchedX0 = x1; + int touchedX1 = x0; + int touchedY0 = y1; + int touchedY1 = y0; + + for (int y = y0; y < y1; ++y) { + const float dy = (static_cast(y) + 0.5f - centerYf) * invRy; + for (int x = x0; x < x1; ++x) { + const float dx = (static_cast(x) + 0.5f - centerXf) * invRx; + const float r2 = dx * dx + dy * dy; + if (r2 >= 1.0f) continue; + const float w = std::pow(1.0f - r2, p); + const float blend = strength * w; + if (blend <= 0.0f) continue; + const size_t off = (static_cast(y) * static_cast(m_width) + static_cast(x)) * 4u; + const float prevR = byteToFloat(m_pixels[off + 0]); + const float prevG = byteToFloat(m_pixels[off + 1]); + const float prevB = byteToFloat(m_pixels[off + 2]); + const float prevA = byteToFloat(m_pixels[off + 3]); + m_pixels[off + 0] = floatToByte(prevR + (color.r - prevR) * blend); + m_pixels[off + 1] = floatToByte(prevG + (color.g - prevG) * blend); + m_pixels[off + 2] = floatToByte(prevB + (color.b - prevB) * blend); + m_pixels[off + 3] = floatToByte(prevA + (color.a - prevA) * blend); + ++affected; + touchedX0 = std::min(touchedX0, x); + touchedY0 = std::min(touchedY0, y); + touchedX1 = std::max(touchedX1, x + 1); + touchedY1 = std::max(touchedY1, y + 1); + } + } + + if (affected > 0) + expandDirty(touchedX0, touchedY0, touchedX1, touchedY1); + return affected; +} + +int TexturePaintBuffer::floodFill(int sx, int sy, const Ogre::ColourValue& fill) +{ + if (m_width <= 0 || m_height <= 0) return 0; + if (sx < 0 || sy < 0 || sx >= m_width || sy >= m_height) return 0; + const Ogre::ColourValue seed = pixel(sx, sy); + const float eps = 4.0f / 255.0f; + auto sameColor = [&seed, eps](const Ogre::ColourValue& other) { + return std::abs(other.r - seed.r) <= eps + && std::abs(other.g - seed.g) <= eps + && std::abs(other.b - seed.b) <= eps + && std::abs(other.a - seed.a) <= eps; + }; + if (sameColor(fill)) return 0; + + std::vector> stack; + stack.push_back({sx, sy}); + std::vector visited(static_cast(m_width) * m_height, 0); + int affected = 0; + int tx0 = m_width, tx1 = 0, ty0 = m_height, ty1 = 0; + while (!stack.empty()) { + auto [x, y] = stack.back(); + stack.pop_back(); + if (x < 0 || y < 0 || x >= m_width || y >= m_height) continue; + const size_t idx = static_cast(y) * m_width + x; + if (visited[idx]) continue; + if (!sameColor(pixel(x, y))) continue; + visited[idx] = 1; + setPixel(x, y, fill); + ++affected; + tx0 = std::min(tx0, x); ty0 = std::min(ty0, y); + tx1 = std::max(tx1, x + 1); ty1 = std::max(ty1, y + 1); + stack.push_back({x + 1, y}); + stack.push_back({x - 1, y}); + stack.push_back({x, y + 1}); + stack.push_back({x, y - 1}); + } + return affected; +} + +bool TexturePaintBuffer::save(const std::string& path) const +{ + if (m_width <= 0 || m_height <= 0) return false; + QImage img(m_pixels.data(), m_width, m_height, m_width * 4, QImage::Format_RGBA8888); + if (img.isNull()) return false; + return img.save(QString::fromStdString(path)); +} + +bool TexturePaintBuffer::load(const std::string& path) +{ + QImage img(QString::fromStdString(path)); + if (img.isNull()) return false; + img = img.convertToFormat(QImage::Format_RGBA8888); + m_width = img.width(); + m_height = img.height(); + const size_t n = static_cast(m_width) * static_cast(m_height) * 4u; + m_pixels.resize(n); + for (int y = 0; y < m_height; ++y) { + const uchar* src = img.constScanLine(y); + uint8_t* dst = m_pixels.data() + static_cast(y) * static_cast(m_width) * 4u; + std::memcpy(dst, src, static_cast(m_width) * 4u); + } + expandDirty(0, 0, m_width, m_height); + return true; +} + +void TexturePaintBuffer::expandDirty(int x0, int y0, int x1, int y1) +{ + if (x0 >= x1 || y0 >= y1) return; + if (m_dirty.empty()) { + m_dirty = {x0, y0, x1, y1}; + return; + } + m_dirty.x0 = std::min(m_dirty.x0, x0); + m_dirty.y0 = std::min(m_dirty.y0, y0); + m_dirty.x1 = std::max(m_dirty.x1, x1); + m_dirty.y1 = std::max(m_dirty.y1, y1); +} diff --git a/src/TexturePaintBuffer.h b/src/TexturePaintBuffer.h new file mode 100644 index 000000000..1b97d1edb --- /dev/null +++ b/src/TexturePaintBuffer.h @@ -0,0 +1,141 @@ +#ifndef TEXTUREPAINTBUFFER_H +#define TEXTUREPAINTBUFFER_H + +#include +#include + +#include +#include +#include +#include + +/** + * @brief RGBA8 pixel buffer with dirty-rect tracking for texture painting. + * + * Owns a flat `std::vector` of size `width * height * 4` (RGBA8, + * top-left origin, UV (0,0) maps to pixel (0,0) β€” V is *not* flipped + * (Ogre + Qt convention). + * + * All mutations expand `dirtyRect()`. The dirty rect is the smallest pixel + * AABB covering every pixel mutated since the last `clearDirty()` call. It + * is empty when no mutation has happened or after `clearDirty()`. + * + * Pure data. Has no Qt or Ogre runtime dependencies β€” the only Ogre types + * referenced (`ColourValue`, `Vector2`) are header-only math. + */ +class TexturePaintBuffer +{ +public: + /// Dirty rect in pixel coordinates. [x0..x1) Γ— [y0..y1) β€” half-open. + struct DirtyRect { + int x0 = 0; + int y0 = 0; + int x1 = 0; + int y1 = 0; + bool empty() const { return x1 <= x0 || y1 <= y0; } + int width() const { return empty() ? 0 : x1 - x0; } + int height() const { return empty() ? 0 : y1 - y0; } + }; + + TexturePaintBuffer() = default; + + /// Construct with given size, all pixels initialized to opaque white. + TexturePaintBuffer(int width, int height); + + /// Resize the buffer, clearing it to opaque white. Marks no dirty rect. + void resize(int width, int height); + + /// Fill the buffer with a single color. Marks the full buffer dirty. + void clear(const Ogre::ColourValue& color); + + int width() const { return m_width; } + int height() const { return m_height; } + /// Raw RGBA8 byte buffer (row-major, top-left origin). + const std::vector& data() const { return m_pixels; } + std::vector& data() { return m_pixels; } + /// Current dirty rect (in pixel coords). Empty when no mutation pending. + const DirtyRect& dirtyRect() const { return m_dirty; } + void clearDirty() { m_dirty = {}; } + /// Expand the dirty rect manually. Used when external code mutates the + /// raw `data()` array (e.g. VertexColorBaker dilation). + void markDirty(int x0, int y0, int x1, int y1) { expandDirty(x0, y0, x1, y1); } + + /** + * @brief Flood-fill connected pixels at (sx, sy) with `fill`. + * + * 4-connected scan, tolerance Ξ΅=4/255 per channel. Stops at any + * pixel whose color differs from the seed. Returns the pixel count + * filled, 0 if the seed already matches `fill`. + */ + int floodFill(int sx, int sy, const Ogre::ColourValue& fill); + + /// Read pixel. Out-of-bounds returns black-transparent. + Ogre::ColourValue pixel(int x, int y) const; + + /// Write pixel (clamped to bounds). Expands dirty rect. + void setPixel(int x, int y, const Ogre::ColourValue& color); + + /** + * @brief Paint a circular brush stamp at UV coordinate. + * + * @param uv Center UV in [0..1]^2 (top-left origin: uv.y=0 β†’ top). + * @param radiusUV Brush radius in UV-space units. + * @param color Brush color (alpha is interpreted as flow). + * @param strength 0..1 β€” how much the brush moves the pixel toward `color`. + * @param falloff 0 = hard, 1 = full quadratic falloff at the edge. + * @return Number of pixels modified. + * + * Each pixel inside the brush footprint is lerped: + * + * out = lerp(prev, color, strength * weight) + * + * where `weight = 1` at center and falls off to `0` at the edge. The + * falloff curve is `(1 - r^2)^p` with `p = 1 + falloff * 3`, matching + * the vertex-paint brush in EditModeController. + */ + int paintBrush(const Ogre::Vector2& uv, + float radiusUV, + const Ogre::ColourValue& color, + float strength = 1.0f, + float falloff = 0.5f); + + /** + * @brief Save the buffer to disk as a PNG/JPEG/TGA/BMP/etc. + * + * Uses Qt's QImage internally. Returns false if Qt can't write the + * given format (typically a typo in the extension). + * + * @param path Output file path. Extension determines format. + * @return true on success. + */ + bool save(const std::string& path) const; + + /** + * @brief Load a PNG/JPEG/TGA/BMP/etc into the buffer. + * + * Replaces buffer contents and resizes if needed. The loaded image is + * converted to RGBA8. Mark loaded contents fully dirty so any consumer + * (e.g. an Ogre HardwarePixelBuffer mirror) re-uploads. + * + * @return true on success. + */ + bool load(const std::string& path); + + /// Convenience: map a UV to integer pixel coordinates. + /// Top-left origin (Ogre + Qt convention): uv.y=0 β†’ y=0, + /// uv.y=1 β†’ y=height-1. Result is clamped to in-bounds texels. + void uvToPixel(const Ogre::Vector2& uv, int& outX, int& outY) const; + + /// Convenience: map a pixel back to UV center. Inverse of uvToPixel. + Ogre::Vector2 pixelToUV(int x, int y) const; + +private: + void expandDirty(int x0, int y0, int x1, int y1); + + int m_width = 0; + int m_height = 0; + std::vector m_pixels; + DirtyRect m_dirty; +}; + +#endif // TEXTUREPAINTBUFFER_H diff --git a/src/TexturePaintBuffer_test.cpp b/src/TexturePaintBuffer_test.cpp new file mode 100644 index 000000000..a10b45890 --- /dev/null +++ b/src/TexturePaintBuffer_test.cpp @@ -0,0 +1,327 @@ +#include + +#include +#include + +#include "TexturePaintBuffer.h" + +namespace { + +constexpr uint8_t kFull = 255; + +uint8_t byte(int x, int y, int width, const std::vector& data, int channel) +{ + const size_t off = (static_cast(y) * static_cast(width) + static_cast(x)) * 4u; + return data[off + static_cast(channel)]; +} + +} // namespace + +TEST(TexturePaintBufferTest, DefaultInitOpaqueWhite) +{ + TexturePaintBuffer buf(4, 4); + EXPECT_EQ(buf.width(), 4); + EXPECT_EQ(buf.height(), 4); + // 4x4 RGBA = 64 bytes, all 0xFF + EXPECT_EQ(buf.data().size(), 64u); + for (uint8_t b : buf.data()) + EXPECT_EQ(b, kFull); + EXPECT_TRUE(buf.dirtyRect().empty()); +} + +TEST(TexturePaintBufferTest, ResizeClearsBufferAndDirtyRect) +{ + TexturePaintBuffer buf(2, 2); + buf.setPixel(0, 0, Ogre::ColourValue(0, 0, 0, 1)); + EXPECT_FALSE(buf.dirtyRect().empty()); + buf.resize(8, 4); + EXPECT_EQ(buf.width(), 8); + EXPECT_EQ(buf.height(), 4); + EXPECT_TRUE(buf.dirtyRect().empty()); + EXPECT_EQ(buf.pixel(0, 0).r, 1.0f); // post-resize is opaque white +} + +TEST(TexturePaintBufferTest, ClearFillsBufferAndMarksFullDirty) +{ + TexturePaintBuffer buf(4, 4); + buf.clear(Ogre::ColourValue(0.5f, 0.0f, 1.0f, 1.0f)); + EXPECT_EQ(buf.dirtyRect().x0, 0); + EXPECT_EQ(buf.dirtyRect().y0, 0); + EXPECT_EQ(buf.dirtyRect().x1, 4); + EXPECT_EQ(buf.dirtyRect().y1, 4); + EXPECT_NEAR(buf.pixel(2, 2).r, 0.5f, 0.01f); + EXPECT_NEAR(buf.pixel(2, 2).b, 1.0f, 0.01f); +} + +TEST(TexturePaintBufferTest, SetPixelExpandsDirtyRect) +{ + TexturePaintBuffer buf(8, 8); + buf.setPixel(2, 3, Ogre::ColourValue::Red); + EXPECT_EQ(buf.dirtyRect().x0, 2); + EXPECT_EQ(buf.dirtyRect().y0, 3); + EXPECT_EQ(buf.dirtyRect().x1, 3); + EXPECT_EQ(buf.dirtyRect().y1, 4); + + buf.setPixel(5, 6, Ogre::ColourValue::Blue); + EXPECT_EQ(buf.dirtyRect().x0, 2); + EXPECT_EQ(buf.dirtyRect().y0, 3); + EXPECT_EQ(buf.dirtyRect().x1, 6); + EXPECT_EQ(buf.dirtyRect().y1, 7); +} + +TEST(TexturePaintBufferTest, SetPixelOutOfBoundsIsNoop) +{ + TexturePaintBuffer buf(4, 4); + buf.setPixel(-1, -1, Ogre::ColourValue::Red); + buf.setPixel(4, 4, Ogre::ColourValue::Red); + EXPECT_TRUE(buf.dirtyRect().empty()); + // All pixels still opaque white. + for (int y = 0; y < 4; ++y) + for (int x = 0; x < 4; ++x) + EXPECT_EQ(byte(x, y, 4, buf.data(), 0), kFull); +} + +TEST(TexturePaintBufferTest, ClearDirtyResets) +{ + TexturePaintBuffer buf(4, 4); + buf.setPixel(0, 0, Ogre::ColourValue::Red); + EXPECT_FALSE(buf.dirtyRect().empty()); + buf.clearDirty(); + EXPECT_TRUE(buf.dirtyRect().empty()); +} + +TEST(TexturePaintBufferTest, PaintBrushFullStrengthFillsCenterPixel) +{ + TexturePaintBuffer buf(32, 32); + // Brush at UV center, radius covering ~6 pixels horizontally. + const int painted = buf.paintBrush(Ogre::Vector2(0.5f, 0.5f), + 0.1f, + Ogre::ColourValue::Red, + 1.0f, 0.0f); + EXPECT_GT(painted, 0); + // Center pixel must be ~red. The geometric brush peak sits at the + // continuous UV center, which is between pixels for a 32Γ—32 buffer; + // uvToPixel returns the lower-left of those four, so its sample + // center is ~0.5 px from the peak. With radius 0.1 (β‰ˆ 3.2 px) and a + // hard falloff, that's blend β‰ˆ 0.95 and a ~5% residual of the + // original white, which the tolerance below accommodates. + int cx = 0, cy = 0; + buf.uvToPixel(Ogre::Vector2(0.5f, 0.5f), cx, cy); + EXPECT_NEAR(buf.pixel(cx, cy).r, 1.0f, 0.06f); + EXPECT_NEAR(buf.pixel(cx, cy).g, 0.0f, 0.06f); + // Pixel far outside brush is untouched white. + EXPECT_EQ(byte(0, 0, 32, buf.data(), 0), kFull); + EXPECT_EQ(byte(0, 0, 32, buf.data(), 1), kFull); +} + +TEST(TexturePaintBufferTest, PaintBrushDirtyRectContainsAffectedPixels) +{ + TexturePaintBuffer buf(64, 64); + const int painted = buf.paintBrush(Ogre::Vector2(0.5f, 0.5f), + 0.1f, + Ogre::ColourValue::Green, + 1.0f, 0.5f); + EXPECT_GT(painted, 0); + const auto& d = buf.dirtyRect(); + EXPECT_FALSE(d.empty()); + // The brush is centered at ~(32, 32). The dirty rect must include it. + EXPECT_LE(d.x0, 32); + EXPECT_GE(d.x1, 32); + EXPECT_LE(d.y0, 32); + EXPECT_GE(d.y1, 32); + // And must NOT span the entire buffer. + EXPECT_LT(d.width(), 64); + EXPECT_LT(d.height(), 64); +} + +TEST(TexturePaintBufferTest, PaintBrushZeroStrengthIsNoop) +{ + TexturePaintBuffer buf(16, 16); + const int painted = buf.paintBrush(Ogre::Vector2(0.5f, 0.5f), + 0.2f, + Ogre::ColourValue::Red, + 0.0f, 0.5f); + EXPECT_EQ(painted, 0); + EXPECT_TRUE(buf.dirtyRect().empty()); +} + +TEST(TexturePaintBufferTest, PaintBrushZeroRadiusIsNoop) +{ + TexturePaintBuffer buf(16, 16); + const int painted = buf.paintBrush(Ogre::Vector2(0.5f, 0.5f), + 0.0f, + Ogre::ColourValue::Red, + 1.0f, 0.5f); + EXPECT_EQ(painted, 0); +} + +TEST(TexturePaintBufferTest, PaintBrushClampsToBufferBounds) +{ + TexturePaintBuffer buf(8, 8); + // Brush centered at UV (0, 1) β€” top-left corner pixel. + const int painted = buf.paintBrush(Ogre::Vector2(0.0f, 1.0f), + 0.5f, + Ogre::ColourValue::Red, + 1.0f, 0.0f); + EXPECT_GT(painted, 0); + // The dirty rect must lie within the buffer. + const auto& d = buf.dirtyRect(); + EXPECT_GE(d.x0, 0); + EXPECT_GE(d.y0, 0); + EXPECT_LE(d.x1, 8); + EXPECT_LE(d.y1, 8); +} + +TEST(TexturePaintBufferTest, UvToPixelRoundTrip) +{ + // UV origin = top-left (Ogre + Qt convention). + TexturePaintBuffer buf(64, 32); + int x = 0, y = 0; + buf.uvToPixel(Ogre::Vector2(0.5f, 0.5f), x, y); + EXPECT_EQ(x, 32); + EXPECT_EQ(y, 16); + buf.uvToPixel(Ogre::Vector2(0.0f, 0.0f), x, y); + EXPECT_EQ(x, 0); + EXPECT_EQ(y, 0); + buf.uvToPixel(Ogre::Vector2(1.0f, 1.0f), x, y); + // Clamped to (width-1, height-1) so right/bottom-edge UVs map to + // the last in-bounds texel rather than width/height (which would + // be out of range). + EXPECT_EQ(x, 63); + EXPECT_EQ(y, 31); +} + +TEST(TexturePaintBufferTest, SaveAndLoadRoundTripPreservesPixels) +{ + QTemporaryDir dir; + ASSERT_TRUE(dir.isValid()); + TexturePaintBuffer buf(8, 8); + buf.paintBrush(Ogre::Vector2(0.5f, 0.5f), 0.3f, Ogre::ColourValue(0.2f, 0.4f, 0.6f, 1.0f), 1.0f, 0.0f); + const std::string path = dir.filePath("paint.png").toStdString(); + ASSERT_TRUE(buf.save(path)); + + TexturePaintBuffer reloaded; + ASSERT_TRUE(reloaded.load(path)); + EXPECT_EQ(reloaded.width(), 8); + EXPECT_EQ(reloaded.height(), 8); + // Center pixel should match. Tolerance accounts for two effects: (a) + // PNG byte rounding (~1/255), and (b) the brush-peak-vs-pixel-center + // offset on an 8Γ—8 buffer (see PaintBrushFullStrengthFillsCenterPixel + // for the math) β€” for radius 0.3 at this resolution that's small but + // non-zero residual of the original white. + int cx = 0, cy = 0; + reloaded.uvToPixel(Ogre::Vector2(0.5f, 0.5f), cx, cy); + EXPECT_NEAR(reloaded.pixel(cx, cy).r, 0.2f, 0.1f); + EXPECT_NEAR(reloaded.pixel(cx, cy).g, 0.4f, 0.1f); + EXPECT_NEAR(reloaded.pixel(cx, cy).b, 0.6f, 0.1f); +} + +TEST(TexturePaintBufferTest, LoadOnNonExistentFileFails) +{ + TexturePaintBuffer buf; + EXPECT_FALSE(buf.load("/definitely/does/not/exist/asdf.png")); +} + +// ---- Erase behavior ---- +// Erase = paintBrush(color={0,0,0,0}) β€” lerps current pixel toward +// transparent. Verify by stamping red, then erasing the same spot. +TEST(TexturePaintBufferTest, EraseStampReducesAlpha) +{ + TexturePaintBuffer buf(32, 32); + buf.paintBrush(Ogre::Vector2(0.5f, 0.5f), 0.1f, + Ogre::ColourValue::Red, 1.0f, 0.0f); + int cx = 0, cy = 0; + buf.uvToPixel(Ogre::Vector2(0.5f, 0.5f), cx, cy); + // Buffer init was opaque white (a=1) and paint kept a=1, so this + // is exact regardless of brush-peak offset. + EXPECT_NEAR(buf.pixel(cx, cy).a, 1.0f, 0.02f); + // Erase: full strength, hard falloff, transparent black target. + // Same brush-peak-vs-pixel-center offset as elsewhere means a small + // residual alpha (~5%) survives after one erase pass. + buf.paintBrush(Ogre::Vector2(0.5f, 0.5f), 0.1f, + Ogre::ColourValue(0, 0, 0, 0), 1.0f, 0.0f); + EXPECT_NEAR(buf.pixel(cx, cy).a, 0.0f, 0.06f); +} + +// ---- Flood fill ---- +TEST(TexturePaintBufferTest, FloodFillReplacesContiguousRegion) +{ + TexturePaintBuffer buf(8, 8); + // Default is all opaque white. Drop a vertical green stripe down the middle. + for (int y = 0; y < 8; ++y) + buf.setPixel(4, y, Ogre::ColourValue::Green); + buf.clearDirty(); + + // Fill the left half (white) with red starting from (0,0). + const int n = buf.floodFill(0, 0, Ogre::ColourValue::Red); + EXPECT_GT(n, 30); // 8 columns Γ— 4 rows-ish = 32 + EXPECT_NEAR(buf.pixel(0, 0).r, 1.0f, 0.02f); + EXPECT_NEAR(buf.pixel(3, 3).r, 1.0f, 0.02f); + // Green stripe still green (the fill stopped at color boundary). + EXPECT_NEAR(buf.pixel(4, 3).g, 1.0f, 0.02f); + // Right half still white (separated from left by the green stripe). + EXPECT_NEAR(buf.pixel(7, 3).r, 1.0f, 0.02f); + EXPECT_NEAR(buf.pixel(7, 3).g, 1.0f, 0.02f); +} + +TEST(TexturePaintBufferTest, FloodFillSameColorIsNoop) +{ + TexturePaintBuffer buf(4, 4); + buf.clearDirty(); + const int n = buf.floodFill(1, 1, Ogre::ColourValue::White); + EXPECT_EQ(n, 0); +} + +TEST(TexturePaintBufferTest, FloodFillSeedOutOfBoundsReturnsZero) +{ + TexturePaintBuffer buf(4, 4); + EXPECT_EQ(buf.floodFill(-1, 0, Ogre::ColourValue::Red), 0); + EXPECT_EQ(buf.floodFill(0, 99, Ogre::ColourValue::Red), 0); +} + +// ---- Hard-edge fill region replacement (single brush) ---- +TEST(TexturePaintBufferTest, HardBrushReplacesPixelExactly) +{ + TexturePaintBuffer buf(8, 8); + buf.paintBrush(Ogre::Vector2(0.5f, 0.5f), 0.5f, + Ogre::ColourValue::Blue, 1.0f, 0.0f); + int cx = 0, cy = 0; + buf.uvToPixel(Ogre::Vector2(0.5f, 0.5f), cx, cy); + const auto c = buf.pixel(cx, cy); + // Tolerance covers the brush-peak-vs-pixel-center offset (see + // PaintBrushFullStrengthFillsCenterPixel comment) β€” leftover ~5% + // of the initial white isn't a bug. + EXPECT_NEAR(c.r, 0.0f, 0.06f); + EXPECT_NEAR(c.b, 1.0f, 0.06f); +} + +// ---- floodFill stops at color boundaries with the 4-pixel tolerance ---- +TEST(TexturePaintBufferTest, FloodFillRespectsTolerance) +{ + TexturePaintBuffer buf(4, 4); + // Plant a near-white pixel (slight off-white). With the 4/255 + // tolerance per channel, the fill from a white seed should treat + // it as same-color and fill it too. + buf.setPixel(2, 2, Ogre::ColourValue(254.0f/255.0f, 254.0f/255.0f, + 254.0f/255.0f, 1.0f)); + buf.clearDirty(); + const int n = buf.floodFill(0, 0, Ogre::ColourValue::Red); + EXPECT_EQ(n, 16); // entire 4x4 swept +} + +TEST(TexturePaintBufferTest, MarkDirtyExpandsRectExternally) +{ + TexturePaintBuffer buf(16, 16); + EXPECT_TRUE(buf.dirtyRect().empty()); + buf.markDirty(3, 4, 7, 9); + EXPECT_EQ(buf.dirtyRect().x0, 3); + EXPECT_EQ(buf.dirtyRect().y0, 4); + EXPECT_EQ(buf.dirtyRect().x1, 7); + EXPECT_EQ(buf.dirtyRect().y1, 9); + // Expand again β€” should grow. + buf.markDirty(0, 0, 5, 5); + EXPECT_EQ(buf.dirtyRect().x0, 0); + EXPECT_EQ(buf.dirtyRect().y0, 0); + EXPECT_EQ(buf.dirtyRect().x1, 7); + EXPECT_EQ(buf.dirtyRect().y1, 9); +} diff --git a/src/TexturePaintController.cpp b/src/TexturePaintController.cpp new file mode 100644 index 000000000..8b1eff434 --- /dev/null +++ b/src/TexturePaintController.cpp @@ -0,0 +1,1963 @@ +#include "TexturePaintController.h" + +#include "EditModeController.h" +#include "EditableMesh.h" +#include "OgreWidget.h" +#include "SelectionSet.h" +#include "SentryReporter.h" +#include "SpaceCamera.h" +#include "UndoManager.h" +#include "VertexColorBaker.h" +#include "EmbeddedTextureCache.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +TexturePaintController* TexturePaintController::s_instance = nullptr; + +namespace { + +/// Undo command for one texture-paint stroke. Snapshots the buffer +/// before/after and restores via memcpy into the Ogre texture. +class TexturePaintStrokeCommand : public QUndoCommand +{ +public: + TexturePaintStrokeCommand(TexturePaintController* controller, + std::vector before, + std::vector after, + int width, + int height, + QString textureName) + : QUndoCommand(QStringLiteral("Texture paint")) + , m_controller(controller) + , m_before(std::move(before)) + , m_after(std::move(after)) + , m_width(width) + , m_height(height) + , m_textureName(std::move(textureName)) + { + } + + void undo() override { apply(m_before); } + void redo() override + { + if (m_skipFirstRedo) { + m_skipFirstRedo = false; + return; + } + apply(m_after); + } + +private: + void apply(const std::vector& pixels) + { + if (!m_controller) return; + if (m_controller->currentTextureName() != m_textureName) return; + const auto& buf = m_controller->buffer(); + if (buf.width() != m_width || buf.height() != m_height) return; + m_controller->applyPixelSnapshot(pixels); + } + + TexturePaintController* m_controller = nullptr; + std::vector m_before; + std::vector m_after; + int m_width = 0; + int m_height = 0; + QString m_textureName; + bool m_skipFirstRedo = true; // command is pushed *after* the stroke applied +}; + +} // namespace + +TexturePaintController* TexturePaintController::instance() +{ + if (!s_instance) + s_instance = new TexturePaintController(); + return s_instance; +} + +TexturePaintController* TexturePaintController::qmlInstance(QQmlEngine*, QJSEngine*) +{ + auto* p = instance(); + QQmlEngine::setObjectOwnership(p, QQmlEngine::CppOwnership); + return p; +} + +void TexturePaintController::kill() +{ + delete s_instance; + s_instance = nullptr; +} + +TexturePaintController::TexturePaintController(QObject* parent) + : QObject(parent) +{ + // Mirror the toolbar brush settings β€” texture paint and vertex paint + // share one source of truth so the user isn't juggling two sets of + // controls. EditModeController owns the canonical values; we just + // forward its change signal so the QML "Texture Paint" panel can + // re-render the live brush preview. + if (auto* em = EditModeController::instance()) { + connect(em, &EditModeController::vertexPaintChanged, + this, &TexturePaintController::texturePaintChanged); + } + + // Refresh the texture slot list whenever the selection changes β€” + // the user expects "select a mesh β†’ see its textures" without any + // explicit refresh action. + if (auto* sel = SelectionSet::getSingleton()) { + connect(sel, &SelectionSet::selectionChanged, + this, &TexturePaintController::refreshSlots); + } +} + +TexturePaintController::~TexturePaintController() +{ + // Drop the manual objects on the scene before Ogre destructors + // race us on shutdown. Skip the cleanup entirely if Ogre's + // singletons are already gone β€” touching them post-teardown + // segfaults at process exit. + if (Ogre::Root::getSingletonPtr() == nullptr) { + m_paintMesh.reset(); + m_ogreTexture.reset(); + m_originalTexture.reset(); + m_ringNode = nullptr; + m_ringObj = nullptr; + m_paintMeshEntity = nullptr; + m_sessionEntity = nullptr; + return; + } + try { + closeSession(); + } catch (...) { + // Best-effort shutdown. + } +} + +void TexturePaintController::setTexturePaintEnabled(bool enabled) +{ + if (m_paintEnabled == enabled) return; + m_paintEnabled = enabled; + if (enabled) { + // Texture paint needs a private EditableMesh built from the + // selected entity. NB: we deliberately don't call + // EditModeController::enterEditMode() β€” that would flip the + // workspace mode to EditMode, kick the user out of Material + // Mode, and (via the visibility hooks) silently disable us. + if (auto* e = activeEntity()) { + ensureEditableMesh(e); + // Pre-create the paint session for texture target so the + // preview thumbnail populates immediately and the first + // stroke doesn't have to do the heavy setup work. + // (Skip for vertex target β€” no texture session needed.) + if (m_target == TargetTexture && !hasActiveSession()) + ensurePaintableTexture(1024); + } + refreshSlots(); + } + if (!enabled) { + if (m_strokeActive) endStroke(); + // Disabling paint also tears down any active session so the + // model's original textures snap back into the render. + if (hasActiveSession()) closeSession(); + } + SentryReporter::addBreadcrumb("ui.action", + enabled ? "Texture paint: enabled" : "Texture paint: disabled"); + emit texturePaintChanged(); +} + +void TexturePaintController::setBrushTool(int tool) +{ + BrushTool t = static_cast(tool); + if (t == m_tool) return; + m_tool = t; + m_smudgeHavePrev = false; + SentryReporter::addBreadcrumb("ui.action", + QStringLiteral("Texture paint: tool = %1").arg(tool)); + emit brushToolChanged(); +} + +void TexturePaintController::setPaintTarget(int target) +{ + PaintTarget t = static_cast(target); + if (t == m_target) return; + m_target = t; + SentryReporter::addBreadcrumb("ui.action", + QStringLiteral("Paint target = %1").arg(target == TargetVertex ? "vertex" : "texture")); + emit paintTargetChanged(); +} + +void TexturePaintController::setActiveSlotIndex(int index) +{ + if (index < 0 || index >= m_slots.size()) return; + if (m_activeSlot == index) return; + // Preserve the user's current buffer resolution across slot + // switches β€” without this, jumping slots always falls back to 1024 + // even after the user picked 2048. + const int preservedRes = m_buffer.width() > 0 ? m_buffer.width() : 1024; + m_activeSlot = index; + // Switching slots resets the buffer to the new slot's texture. + closeSession(); + ensurePaintableTexture(preservedRes); + emit slotsChanged(); +} + +QColor TexturePaintController::texturePaintColor() const +{ + auto* em = EditModeController::instance(); + return em ? em->vertexPaintColor() : QColor(255, 0, 0); +} + +double TexturePaintController::texturePaintRadius() const +{ + auto* em = EditModeController::instance(); + return em ? em->vertexPaintRadius() : 0.05; +} + +double TexturePaintController::texturePaintStrength() const +{ + auto* em = EditModeController::instance(); + return em ? em->vertexPaintStrength() : 0.75; +} + +double TexturePaintController::texturePaintFalloff() const +{ + auto* em = EditModeController::instance(); + return em ? em->vertexPaintFalloff() : 0.5; +} + +Ogre::Entity* TexturePaintController::activeEntity() const +{ + // First selected entity in Material Mode = paint target. Edit Mode + // is no longer involved β€” we keep our own EditableMesh so painting + // works regardless of workspace mode. + auto* sel = SelectionSet::getSingleton(); + if (!sel) return nullptr; + auto entities = sel->getResolvedEntities(); + return entities.isEmpty() ? nullptr : entities.first(); +} + +bool TexturePaintController::ensureEditableMesh(Ogre::Entity* entity) +{ + if (!entity) return false; + if (m_paintMeshEntity == entity && m_paintMesh) return true; + auto mesh = std::make_unique(); + if (!mesh->loadFromEntity(entity)) { + m_paintMesh.reset(); + m_paintMeshEntity = nullptr; + return false; + } + m_paintMesh = std::move(mesh); + m_paintMeshEntity = entity; + return true; +} + +Ogre::TextureUnitState* TexturePaintController::findOrCreateActiveTextureUnit(Ogre::Entity* entity) +{ + if (!entity || entity->getNumSubEntities() == 0) return nullptr; + + // If we have a slot model, use the slot's recorded submesh + + // texture name to find the matching TUS. + if (m_activeSlot >= 0 && m_activeSlot < m_slots.size()) { + auto m = m_slots.at(m_activeSlot).toMap(); + const int subIdx = m.value("submesh", -1).toInt(); + const std::string slotName = m.value("slot").toString().toStdString(); + if (subIdx >= 0 && subIdx < static_cast(entity->getNumSubEntities())) { + auto* subEnt = entity->getSubEntity(subIdx); + if (subEnt) { + Ogre::MaterialPtr mat = subEnt->getMaterial(); + if (mat && mat->getNumTechniques() > 0) { + auto* tech = mat->getTechnique(0); + if (tech && tech->getNumPasses() > 0) { + auto* pass = tech->getPass(0); + if (pass) { + for (unsigned short i = 0; i < pass->getNumTextureUnitStates(); ++i) { + auto* tus = pass->getTextureUnitState(i); + if (tus->getName() == slotName) + return tus; + } + // Slot named in model but not found on the pass + // (rare β€” model out of sync). Fall through to + // first TUS / create. + } + } + } + } + } + } + + // Fallback: first submesh, first material, first pass, prefer + // canonical diffuse slot names; otherwise first TUS or create. + auto* subEnt = entity->getSubEntity(0); + if (!subEnt) return nullptr; + Ogre::MaterialPtr mat = subEnt->getMaterial(); + if (!mat || mat->getNumTechniques() == 0) return nullptr; + auto* tech = mat->getTechnique(0); + if (!tech || tech->getNumPasses() == 0) return nullptr; + auto* pass = tech->getPass(0); + if (!pass) return nullptr; + for (unsigned short i = 0; i < pass->getNumTextureUnitStates(); ++i) { + auto* tus = pass->getTextureUnitState(i); + const std::string& n = tus->getName(); + if (n == "albedo" || n == "diffuse_map") + return tus; + } + if (pass->getNumTextureUnitStates() > 0) + return pass->getTextureUnitState(0); + return pass->createTextureUnitState(); +} + +bool TexturePaintController::ensurePaintableTexture(int resolution) +{ + auto* entity = activeEntity(); + if (!entity) { + m_sessionEntity = nullptr; + emit sessionChanged(); + return false; + } + + if (m_sessionEntity == entity && m_buffer.width() > 0 && !m_textureName.isEmpty()) { + // Active session for this entity β€” make sure m_paintMesh is + // valid (it could have been torn down by a previous + // closeSession that fired after the user's last stroke). + if (!ensureEditableMesh(entity)) { + emit sessionChanged(); + return false; + } + return true; + } + + // Reset any prior session first β€” closeSession() clears m_paintMesh, + // so we have to rebuild the EditableMesh AFTER this call. Doing it + // before would leave m_paintMesh null on return, breaking every + // hit-test (findMeshPointForUV walks m_paintMesh's submeshes). + closeSession(); + m_sessionEntity = entity; + + if (!ensureEditableMesh(entity)) { + // No mesh data β†’ can't UV-hit-test β†’ can't paint. + m_sessionEntity = nullptr; + emit sessionChanged(); + return false; + } + + auto* tu = findOrCreateActiveTextureUnit(entity); + if (!tu) { + emit sessionChanged(); + return false; + } + + QString existingTex = QString::fromStdString(tu->getTextureName()); + bool loadedExisting = false; + QString loadError; + // Track the original texture handle (not just its name) so we + // can paint directly into it via blitFromMemory and skip the + // TUS rebind entirely. This is the most reliable path on macOS + // Metal where "missing texture" fallback gives yellow and + // TU_DYNAMIC manual textures sometimes don't get uploaded. + Ogre::TexturePtr originalTex; + if (!existingTex.isEmpty()) { + try { + originalTex = Ogre::TextureManager::getSingleton().getByName( + existingTex.toStdString(), + Ogre::ResourceGroupManager::AUTODETECT_RESOURCE_GROUP_NAME); + } catch (...) {} + } + m_originalTexture = originalTex; + m_originalTextureName = existingTex; + if (!existingTex.isEmpty()) { + // Try multiple strategies because imported PBR textures can + // come from inline FBX embeds (no disk file), legacy on-disk + // files, or auto-generated render targets. Each strategy + // succeeds for a different source. + // + // 1. TextureManager β†’ convertToImage (works when Ogre keeps + // pixels in an Image buffer beside the GPU upload). + Ogre::TexturePtr existing; + try { + existing = Ogre::TextureManager::getSingleton().getByName( + existingTex.toStdString(), + Ogre::ResourceGroupManager::AUTODETECT_RESOURCE_GROUP_NAME); + } catch (...) {} + if (existing) { + try { + if (!existing->isLoaded()) existing->load(); + Ogre::Image img; + existing->convertToImage(img); + const int w = static_cast(img.getWidth()); + const int h = static_cast(img.getHeight()); + if (w > 0 && h > 0) { + m_buffer.resize(w, h); + Ogre::PixelBox srcBox = img.getPixelBox(); + Ogre::PixelBox dstBox(w, h, 1, Ogre::PF_BYTE_RGBA, + m_buffer.data().data()); + Ogre::PixelUtil::bulkPixelConversion(srcBox, dstBox); + m_buffer.clearDirty(); + loadedExisting = true; + } + } catch (const Ogre::Exception& e) { + loadError = QString::fromStdString(e.getDescription()); + } catch (...) { + loadError = QStringLiteral("convertToImage exception"); + } + } else { + loadError = QStringLiteral("texture not found in TextureManager"); + } + + // 2. Read from GPU directly via blitToMemory. Works when the + // texture is on the GPU but its source Image buffer was + // discarded post-upload (common for static textures). + // Reading in the texture's native format and letting Ogre + // convert is more reliable than asking for RGBA up front β€” + // some Metal/GL drivers refuse the format mismatch. + if (!loadedExisting && existing) { + try { + if (!existing->isLoaded()) existing->load(); + const int w = static_cast(existing->getWidth()); + const int h = static_cast(existing->getHeight()); + auto pixbuf = existing->getBuffer(); + if (w > 0 && h > 0 && pixbuf) { + const Ogre::PixelFormat srcFmt = existing->getFormat(); + SentryReporter::addBreadcrumb("ui.action", + QStringLiteral("Texture paint: blit native fmt=%1 size=%2x%3") + .arg(static_cast(srcFmt)).arg(w).arg(h)); + const size_t srcBytes = Ogre::PixelUtil::getMemorySize(w, h, 1, srcFmt); + std::vector srcBuf(srcBytes); + Ogre::PixelBox srcPb(w, h, 1, srcFmt, srcBuf.data()); + pixbuf->blitToMemory(srcPb); + m_buffer.resize(w, h); + Ogre::PixelBox dstPb(w, h, 1, Ogre::PF_BYTE_RGBA, + m_buffer.data().data()); + Ogre::PixelUtil::bulkPixelConversion(srcPb, dstPb); + m_buffer.clearDirty(); + loadedExisting = true; + loadError.clear(); + } + } catch (const Ogre::Exception& e) { + loadError = QStringLiteral("blit-native: ") + QString::fromStdString(e.getDescription()); + } catch (...) { + loadError = QStringLiteral("blit-native: unknown exception"); + } + } + + // 3. Ogre::Image::load β€” works when the texture name is also + // a filename in a registered resource location. + if (!loadedExisting) { + try { + Ogre::Image img; + img.load(existingTex.toStdString(), + Ogre::ResourceGroupManager::AUTODETECT_RESOURCE_GROUP_NAME); + const int w = static_cast(img.getWidth()); + const int h = static_cast(img.getHeight()); + if (w > 0 && h > 0) { + m_buffer.resize(w, h); + Ogre::PixelBox srcBox = img.getPixelBox(); + Ogre::PixelBox dstBox(w, h, 1, Ogre::PF_BYTE_RGBA, + m_buffer.data().data()); + Ogre::PixelUtil::bulkPixelConversion(srcBox, dstBox); + m_buffer.clearDirty(); + loadedExisting = true; + loadError.clear(); + } + } catch (const Ogre::Exception& e) { + loadError = QStringLiteral("image-load: ") + QString::fromStdString(e.getDescription()); + } catch (...) { + loadError = QStringLiteral("image-load: unknown"); + } + } + + // 4. QImage as a raw file path. Handles textures whose name + // is a relative or absolute filesystem path (some assets + // keep their texture name == on-disk path). + if (!loadedExisting) { + try { + QImage qimg(existingTex); + if (!qimg.isNull()) { + qimg = qimg.convertToFormat(QImage::Format_RGBA8888); + const int w = qimg.width(); + const int h = qimg.height(); + m_buffer.resize(w, h); + for (int y = 0; y < h; ++y) { + std::memcpy(m_buffer.data().data() + static_cast(y) * w * 4u, + qimg.constScanLine(y), + static_cast(w) * 4u); + } + m_buffer.clearDirty(); + loadedExisting = true; + loadError.clear(); + } + } catch (...) { + // All four failed. + } + } + } + + if (!loadedExisting) { + const int res = std::max(16, resolution); + m_buffer.resize(res, res); + m_buffer.clear(Ogre::ColourValue::White); + m_buffer.clearDirty(); + SentryReporter::addBreadcrumb("ui.action", + QStringLiteral("Texture paint: starting from blank %1Γ—%1 (existing tex='%2', err='%3')") + .arg(res).arg(existingTex).arg(loadError)); + } else { + SentryReporter::addBreadcrumb("ui.action", + QStringLiteral("Texture paint: loaded existing %1Γ—%2 from '%3'") + .arg(m_buffer.width()).arg(m_buffer.height()).arg(existingTex)); + } + + static unsigned int s_unique = 0; + QString hint = QStringLiteral("QMEPaint_%1_%2") + .arg(QString::fromStdString(entity->getName())) + .arg(++s_unique); + // Rebind eagerly on session create. The deferred / lazy rebind + // approach was racing with the mouse-move event stack and + // crashing mid-stroke. Doing it at session-create time happens + // BEFORE the first stroke fires, so there's no re-entrant render + // hazard. + // Create the GPU texture but DEFER the material rebind. Doing + // mat->compile()/mat->reload() on the same call stack as the + // mouse-press event raced with Ogre's render thread and + // segfaulted mid-stroke. The rebind happens on the next + // event-loop tick via the deferred-rebind path in + // doFlushDirtyToOgre. + if (!createOgreTextureFromBuffer(entity, hint, /*rebindToModel=*/false)) { + m_buffer = TexturePaintBuffer(); + m_textureName.clear(); + m_sessionEntity = nullptr; + emit sessionChanged(); + return false; + } + + SentryReporter::addBreadcrumb("ui.action", + QStringLiteral("Texture paint session: %1Γ—%2 on %3 (existing tex: %4)") + .arg(m_buffer.width()).arg(m_buffer.height()) + .arg(QString::fromStdString(entity->getName())) + .arg(loadedExisting ? "yes" : "no")); + + refreshPreviewUri(); + if (m_uvOverlayVisible) refreshUvOverlay(); + emit sessionChanged(); + return true; +} + +bool TexturePaintController::createOgreTextureFromBuffer(Ogre::Entity* entity, + const QString& nameHint, + bool rebindToModel) +{ + if (!entity || m_buffer.width() <= 0 || m_buffer.height() <= 0) return false; + auto* tu = findOrCreateActiveTextureUnit(entity); + if (!tu) return false; + + // Snapshot the original texture name on the chosen TUS *before* we + // overwrite anything. We use this to find every other TUS bound to + // the same source texture (e.g. `albedo` + `diffuse_map` aliasing + // on imported PBR materials), so all of them get rebound. + const std::string originalTexName = tu->getTextureName(); + + const std::string texName = nameHint.toStdString(); + const std::string group = Ogre::ResourceGroupManager::DEFAULT_RESOURCE_GROUP_NAME; + try { + auto& tm = Ogre::TextureManager::getSingleton(); + if (auto existing = tm.getByName(texName, group)) + tm.remove(existing); + m_ogreTexture = tm.createManual( + texName, group, Ogre::TEX_TYPE_2D, + m_buffer.width(), m_buffer.height(), 0, + Ogre::PF_BYTE_RGBA, + // Plain TU_DYNAMIC: write-only with no discard hint. The + // _DISCARDABLE variant can drop initial content on some + // drivers β€” bad for us since the initial blit IS the + // user's "current pixels" baseline. + Ogre::TU_DYNAMIC_WRITE_ONLY); + if (!m_ogreTexture) return false; + // Initial upload + auto pixbuf = m_ogreTexture->getBuffer(); + if (!pixbuf) return false; + Ogre::PixelBox pb(m_buffer.width(), m_buffer.height(), 1, + Ogre::PF_BYTE_RGBA, m_buffer.data().data()); + pixbuf->blitFromMemory(pb); + m_textureName = QString::fromStdString(texName); + + if (rebindToModel) + rebindEntityDiffuseToPaintTexture(entity); + else + SentryReporter::addBreadcrumb("ui.action", + QStringLiteral("Texture paint: GPU texture created, deferred rebind")); + return true; + } catch (const Ogre::Exception&) { + return false; + } catch (...) { + return false; + } +} + +void TexturePaintController::rebindEntityDiffuseToPaintTexture(Ogre::Entity* entity) +{ + if (!entity || m_textureName.isEmpty()) return; + // Capture the original texture name from the user's active slot + // (or the first diffuse-like TUS as fallback). + auto* tu = findOrCreateActiveTextureUnit(entity); + const std::string originalTexName = tu ? tu->getTextureName() : ""; + const std::string texName = m_textureName.toStdString(); + + SentryReporter::addBreadcrumb("ui.action", + QStringLiteral("Texture paint: rebind base = '%1' β†’ '%2'") + .arg(QString::fromStdString(originalTexName)) + .arg(QString::fromStdString(texName))); + + m_boundSlots.clear(); + std::set touched; + const int chosenSubmesh = + (m_activeSlot >= 0 && m_activeSlot < m_slots.size()) + ? m_slots.at(m_activeSlot).toMap().value("submesh", -1).toInt() + : 0; + for (unsigned int se = 0; se < entity->getNumSubEntities(); ++se) { + auto* sub = entity->getSubEntity(se); + if (!sub) continue; + Ogre::MaterialPtr mat = sub->getMaterial(); + if (!mat) continue; + bool changed = false; + const auto& techs = mat->getTechniques(); + for (size_t tIdx = 0; tIdx < techs.size(); ++tIdx) { + auto* tech = techs[tIdx]; + for (unsigned short pi = 0; pi < tech->getNumPasses(); ++pi) { + auto* p = tech->getPass(pi); + for (unsigned short ti = 0; ti < p->getNumTextureUnitStates(); ++ti) { + auto* tusN = p->getTextureUnitState(ti); + const std::string currentTex = tusN->getTextureName(); + bool shouldBind = false; + if (!originalTexName.empty()) { + shouldBind = (currentTex == originalTexName); + } else if (static_cast(se) == chosenSubmesh) { + shouldBind = (tusN == tu); + } + if (!shouldBind) continue; + BoundSlot s; + s.materialName = mat->getName(); + s.techIdx = static_cast(tIdx); + s.passIdx = pi; + s.tusIdx = ti; + s.originalTexture = currentTex; + m_boundSlots.push_back(s); + // Bind the TexturePtr directly. setTextureName + // does name resolution that can pick the wrong + // group on macOS/Metal. Direct binding never + // mis-resolves. + if (m_ogreTexture) + tusN->setTexture(m_ogreTexture); + else + tusN->setTextureName(texName); + changed = true; + } + } + } + if (changed) touched.insert(mat.get()); + } + SentryReporter::addBreadcrumb("ui.action", + QStringLiteral("Texture paint: bound %1 TUSes").arg(m_boundSlots.size())); + + for (auto* mat : touched) { + try { mat->compile(); mat->reload(); } + catch (const Ogre::Exception&) {} + } +} + +void TexturePaintController::flushDirtyToOgre() +{ + const auto& dirty = m_buffer.dirtyRect(); + if (dirty.empty()) return; + + // Debounce the GPU upload. Mouse-move fires at 100+ Hz; the + // full-buffer blit is ~4 MB at 1024Β² and CHUGS at that rate. + // Schedule a single coalesced flush per ~16 ms (one render + // tick). The dirty rect keeps accumulating in the meantime, so + // no pixels are lost β€” just batched. + if (m_gpuFlushScheduled) return; + m_gpuFlushScheduled = true; + QTimer::singleShot(16, this, [this]() { + m_gpuFlushScheduled = false; + if (m_buffer.dirtyRect().empty()) return; + doFlushDirtyToOgre(); + }); +} + +void TexturePaintController::doFlushDirtyToOgre() +{ + const auto& dirty = m_buffer.dirtyRect(); + if (dirty.empty()) return; + + // Preferred path: paint directly INTO the model's original + // texture. No rebind needed β€” the existing material binding + // continues to work, and the paint just modifies pixels in place. + // Re-resolve the handle each flush so a reloaded material + // doesn't dangle. + if (!m_originalTextureName.isEmpty()) { + try { + auto fresh = Ogre::TextureManager::getSingleton().getByName( + m_originalTextureName.toStdString(), + Ogre::ResourceGroupManager::AUTODETECT_RESOURCE_GROUP_NAME); + if (fresh) m_originalTexture = fresh; + } catch (...) {} + } + // Skip the in-place path once we've rebound the model to the + // manual paint texture β€” the original is no longer what the + // renderer samples, so blitting to it would be invisible. + if (m_boundSlots.empty() && m_originalTexture) { + try { + auto pixbuf = m_originalTexture->getBuffer(); + if (pixbuf) { + const int W = m_buffer.width(); + const int rectW = dirty.width(); + const int rectH = dirty.height(); + // Blit in the texture's NATIVE format. blitFromMemory + // does internal conversion if the source PixelBox + // format differs, but on some Metal backends the + // conversion silently produces no upload β€” so we + // convert our RGBA8 source to the texture's format + // up front and submit it raw. + const Ogre::PixelFormat dstFmt = m_originalTexture->getFormat(); + // Only handle plain uncompressed formats in-place. + // Compressed formats (DXT/BC) need real CPU encoders + // which can crash bulkPixelConversion on Metal. + const bool plainFmt = + dstFmt == Ogre::PF_BYTE_RGBA + || dstFmt == Ogre::PF_BYTE_RGB + || dstFmt == Ogre::PF_BYTE_BGRA + || dstFmt == Ogre::PF_BYTE_BGR + || dstFmt == Ogre::PF_A8R8G8B8 + || dstFmt == Ogre::PF_R8G8B8A8 + || dstFmt == Ogre::PF_A8B8G8R8 + || dstFmt == Ogre::PF_X8R8G8B8 + || dstFmt == Ogre::PF_R8G8B8; + if (!plainFmt) { + SentryReporter::addBreadcrumb("ui.action", + QStringLiteral("Texture paint: in-place skipped β€” compressed format %1") + .arg(static_cast(dstFmt))); + m_originalTexture.reset(); + // Fall through to manual-texture path. + } else { + std::vector srcRow(static_cast(rectW) * static_cast(rectH) * 4u); + const auto& src = m_buffer.data(); + for (int row = 0; row < rectH; ++row) { + const size_t srcOff = (static_cast(dirty.y0 + row) * static_cast(W) + + static_cast(dirty.x0)) * 4u; + const size_t dstOff = static_cast(row) * static_cast(rectW) * 4u; + std::memcpy(srcRow.data() + dstOff, src.data() + srcOff, + static_cast(rectW) * 4u); + } + Ogre::PixelBox srcRgba(rectW, rectH, 1, Ogre::PF_BYTE_RGBA, srcRow.data()); + if (dstFmt == Ogre::PF_BYTE_RGBA) { + Ogre::Box dst(dirty.x0, dirty.y0, dirty.x1, dirty.y1); + pixbuf->blitFromMemory(srcRgba, dst); + } else { + const size_t dstBytes = Ogre::PixelUtil::getMemorySize(rectW, rectH, 1, dstFmt); + std::vector slice(dstBytes); + Ogre::PixelBox dstPb(rectW, rectH, 1, dstFmt, slice.data()); + Ogre::PixelUtil::bulkPixelConversion(srcRgba, dstPb); + Ogre::Box dst(dirty.x0, dirty.y0, dirty.x1, dirty.y1); + pixbuf->blitFromMemory(dstPb, dst); + } + m_useOriginalTexture = true; + if (!m_loggedInPlaceBlit) { + m_loggedInPlaceBlit = true; + SentryReporter::addBreadcrumb("ui.action", + QStringLiteral("Texture paint: in-place blit fmt=%1 size=%2x%3") + .arg(static_cast(dstFmt)) + .arg(m_originalTexture->getWidth()) + .arg(m_originalTexture->getHeight())); + } + m_buffer.clearDirty(); + if (!m_previewRefreshScheduled) { + m_previewRefreshScheduled = true; + QTimer::singleShot(60, this, [this]() { + m_previewRefreshScheduled = false; + refreshPreviewUri(); + }); + } + return; + } + } + } catch (const Ogre::Exception& e) { + SentryReporter::addBreadcrumb("ui.action", + QStringLiteral("Texture paint: in-place blit FAILED β†’ fallback to manual texture (%1)") + .arg(QString::fromStdString(e.getDescription()))); + // Fall through to manual-texture path. Clear the + // original-texture handle so we don't keep trying. + m_originalTexture.reset(); + } catch (...) { + m_originalTexture.reset(); + } + } + + if (!m_ogreTexture) return; + + // Fallback path: the model's diffuse TUSes are rebound to our + // manual paint texture on first flush. Defer the rebind via a + // singleShot timer so material compile/reload doesn't run on + // the same call stack as the mouse-move event β€” that was + // racing against the active stroke and crashing. + if (m_boundSlots.empty() && m_paintMeshEntity && !m_rebindScheduled) { + m_rebindScheduled = true; + Ogre::Entity* ent = m_paintMeshEntity; + QTimer::singleShot(0, this, [this, ent]() { + m_rebindScheduled = false; + // The captured Ogre::Entity* could have been destroyed by the + // time this fires (entity removed, mesh reimport, selection + // change closing the session). Validate it's still both the + // active paint target AND a live entity SelectionSet knows + // about before dereferencing. + if (m_paintMeshEntity != ent || !m_boundSlots.empty()) return; + auto* sel = SelectionSet::getSingleton(); + if (!sel || !sel->contains(ent)) return; + rebindEntityDiffuseToPaintTexture(ent); + }); + } + try { + auto buf = m_ogreTexture->getBuffer(); + if (!buf) return; + const int W = m_buffer.width(); + const int H = m_buffer.height(); + // Upload the FULL buffer each flush. Sub-rect blits via + // blitFromMemory are unreliable on macOS Metal β€” sometimes + // they don't end up visible. Full-frame upload is heavier + // but always lands. (At 1024Β² that's ~4 MB per stroke; the + // debounce on previewDataUri amortises CPU cost separately.) + // + // Copy m_buffer.data() into a transient std::vector first. + // Ogre's Metal backend can queue the blit asynchronously; + // pointing the PixelBox at our live buffer caused + // use-after-free crashes when the next stroke modified + // pixels before the GPU finished reading. + std::vector uploadCopy(m_buffer.data().begin(), m_buffer.data().end()); + Ogre::PixelBox pb(W, H, 1, Ogre::PF_BYTE_RGBA, uploadCopy.data()); + buf->blitFromMemory(pb); + } catch (const Ogre::Exception& e) { + SentryReporter::addBreadcrumb("ui.action", + QStringLiteral("Texture paint: blit failed β€” %1") + .arg(QString::fromStdString(e.getDescription()))); + } + m_buffer.clearDirty(); + // Debounce the 2D preview refresh β€” encoding a 1024Γ—1024 PNG + + // base64 on every stroke move is ~150ms of CPU work, which makes + // dragging hitchy. Schedule one refresh per ~60ms; the buffer ↔ + // preview drift during that window is invisible to the user. + if (!m_previewRefreshScheduled) { + m_previewRefreshScheduled = true; + QTimer::singleShot(60, this, [this]() { + m_previewRefreshScheduled = false; + refreshPreviewUri(); + }); + } +} + +bool TexturePaintController::hitTestUV(const QPoint& screenPos, OgreWidget* widget, Ogre::Vector2& outUV) const +{ + if (!m_paintMesh || !m_paintMeshEntity || !widget) return false; + auto* mesh = m_paintMesh.get(); + auto* entity = m_paintMeshEntity; + + auto* spaceCam = widget->getSpaceCamera(); + auto* camera = spaceCam ? spaceCam->getCamera() : nullptr; + if (!camera) return false; + + int vw = 0, vh = 0; + widget->pixelSizeForCameraPicking(vw, vh); + if (vw <= 0 || vh <= 0) return false; + + const Ogre::Real nx = static_cast(screenPos.x()) / vw; + const Ogre::Real ny = static_cast(screenPos.y()) / vh; + const Ogre::Ray ray = camera->getCameraToViewportRay(nx, ny); + + Ogre::SceneNode* node = entity->getParentSceneNode(); + Ogre::Affine3 worldToLocal = node ? node->_getFullTransform().inverse() : Ogre::Affine3::IDENTITY; + Ogre::Vector3 localOrigin = worldToLocal * ray.getOrigin(); + Ogre::Vector3 localDir = worldToLocal.linear() * ray.getDirection(); + localDir.normalise(); + + // Walk every triangle and keep the closest hit. (We don't have + // EditModeController's optimized bbox/octree, but for typical asset + // meshes the linear walk is fine.) + Ogre::Real bestT = std::numeric_limits::infinity(); + Ogre::Vector2 bestUV(0, 0); + bool found = false; + for (const auto& sub : mesh->subMeshes()) { + for (size_t ti = 0; ti < sub.triangles.size(); ++ti) { + const auto& tri = sub.triangles[ti]; + const auto& v0 = sub.vertices[tri.indices[0]]; + const auto& v1 = sub.vertices[tri.indices[1]]; + const auto& v2 = sub.vertices[tri.indices[2]]; + // MΓΆller–Trumbore for explicit barycentric coords. + const Ogre::Vector3 e1 = v1.position - v0.position; + const Ogre::Vector3 e2 = v2.position - v0.position; + const Ogre::Vector3 pvec = localDir.crossProduct(e2); + const Ogre::Real det = e1.dotProduct(pvec); + if (std::abs(det) < 1e-8f) continue; + const Ogre::Real invDet = 1.0f / det; + const Ogre::Vector3 tvec = localOrigin - v0.position; + const Ogre::Real u = tvec.dotProduct(pvec) * invDet; + if (u < 0.0f || u > 1.0f) continue; + const Ogre::Vector3 qvec = tvec.crossProduct(e1); + const Ogre::Real v = localDir.dotProduct(qvec) * invDet; + if (v < 0.0f || u + v > 1.0f) continue; + const Ogre::Real tHit = e2.dotProduct(qvec) * invDet; + if (tHit <= 0.0f || tHit >= bestT) continue; + if (!v0.hasUV || !v1.hasUV || !v2.hasUV) continue; + const Ogre::Real w = 1.0f - u - v; + bestT = tHit; + bestUV = v0.uv * w + v1.uv * u + v2.uv * v; + found = true; + } + } + if (!found) return false; + outUV = bestUV; + return true; +} + +bool TexturePaintController::beginStroke(OgreWidget* widget, const QPoint& screenPos) +{ + if (!m_paintEnabled || m_strokeActive) { + SentryReporter::addBreadcrumb("ui.action", + QStringLiteral("Texture paint: beginStroke skipped (enabled=%1 active=%2)") + .arg(m_paintEnabled).arg(m_strokeActive)); + return false; + } + if (m_target == TargetVertex) { + // Vertex paint just needs the EditableMesh built β€” no GPU + // texture, no rebind. Build it now if it isn't ready. + if (auto* e = activeEntity()) + ensureEditableMesh(e); + if (!m_paintMesh || !m_paintMeshEntity) { + SentryReporter::addBreadcrumb("ui.action", + "Vertex paint: beginStroke aborted β€” no mesh"); + return false; + } + m_paintMesh->ensureVertexColorBuffers(m_paintMeshEntity); + } else { + // Tear down a stale session if the selection moved to a + // different entity since the session was created. Without + // this, strokes hit-test against the new mesh's geometry but + // write into the old mesh's texture/session state. Codex P1. + auto* curEntity = activeEntity(); + if (hasActiveSession() && m_sessionEntity && curEntity + && m_sessionEntity != curEntity) { + SentryReporter::addBreadcrumb("ui.action", + "Texture paint: selection changed β€” rebuilding session for new entity"); + closeSession(); + } + if (!hasActiveSession()) { + if (!ensurePaintableTexture(m_buffer.width() > 0 ? m_buffer.width() : 1024)) { + SentryReporter::addBreadcrumb("ui.action", + "Texture paint: beginStroke aborted β€” no session could be created"); + return false; + } + } + } + m_strokeActive = true; + m_strokeJustBegan = true; + m_smudgeHavePrev = false; + m_strokePreSnapshot = snapshotPixels(); + SentryReporter::addBreadcrumb("ui.action", + QStringLiteral("Paint stroke begin (target=%1 tool=%2 radius=%3 strength=%4 color=%5)") + .arg(m_target == TargetVertex ? "vertex" : "texture") + .arg(static_cast(m_tool)) + .arg(texturePaintRadius(), 0, 'f', 3) + .arg(texturePaintStrength(), 0, 'f', 3) + .arg(texturePaintColor().name(QColor::HexRgb))); + updateStroke(widget, screenPos); + return true; +} + +void TexturePaintController::updateStroke(OgreWidget* widget, const QPoint& screenPos) +{ + if (!m_strokeActive || !m_paintEnabled) return; + + if (m_target == TargetVertex) { + // Vertex paint: get local-space hit point and apply the + // vertex-color brush directly on m_paintMesh. + if (!m_paintMesh || !m_paintMeshEntity) return; + Ogre::Vector3 localPos, localNormal; + if (!hitTestLocalPoint(widget, screenPos, localPos, localNormal)) return; + drawHoverRingAt(localPos, localNormal); + const QColor c = texturePaintColor(); + const Ogre::ColourValue paint(c.redF(), c.greenF(), c.blueF(), c.alphaF()); + const bool changed = EditModeController::applyVertexColorBrush( + *m_paintMesh, localPos, + static_cast(texturePaintRadius()), + paint, + static_cast(texturePaintStrength()), + static_cast(texturePaintFalloff())); + if (changed) + m_paintMesh->commitVertexColorsToEntity(m_paintMeshEntity); + return; + } + + Ogre::Vector2 uv; + if (!hitTestUV(screenPos, widget, uv)) { + clearHoveredUV(); + return; + } + emit hoveredUVChanged(uv.x, uv.y); + // Keep the brush-ring overlay tracking the cursor during a stroke. + Ogre::Vector3 localPos, localNormal; + if (findMeshPointForUV(uv, localPos, localNormal)) + drawHoverRingAt(localPos, localNormal); + if (applyBrushAtUV(uv)) + flushDirtyToOgre(); +} + +bool TexturePaintController::applyBrushAtUV(const Ogre::Vector2& uv) +{ + if (m_buffer.width() <= 0) return false; + // The shared brush radius is in local mesh units. Map it to UV + // space by dividing by the mesh's bounding-box size β€” that's a + // reasonable first approximation when UVs are unwrapped onto a + // [0..1] square. Clamp to [0.005..1.0] so absurd sizes don't + // produce zero-pixel or whole-texture stamps. + float radius = static_cast(texturePaintRadius()); + if (m_paintMesh) { + const auto bbox = m_paintMesh->calculateBounds(); + if (bbox.isFinite()) { + const float meshExtent = bbox.getSize().length() * 0.5f; + if (meshExtent > 0.0f) { + radius = static_cast(texturePaintRadius()) / meshExtent; + } + } + } + radius = std::clamp(radius, 0.005f, 1.0f); + const float strength = static_cast(texturePaintStrength()); + const float falloff = static_cast(texturePaintFalloff()); + + switch (m_tool) { + case ToolPaint: { + const QColor c = texturePaintColor(); + const Ogre::ColourValue paint(c.redF(), c.greenF(), c.blueF(), c.alphaF()); + return m_buffer.paintBrush(uv, radius, paint, strength, falloff) > 0; + } + case ToolErase: { + // Erase = paint transparent. Strength controls how much alpha + // the stamp removes. + const Ogre::ColourValue clear(0.0f, 0.0f, 0.0f, 0.0f); + return m_buffer.paintBrush(uv, radius, clear, strength, falloff) > 0; + } + case ToolFill: { + // Fill is a single-stamp operation β€” apply once per stroke + // start, not on every move. Most paint apps work this way. + // Suppress repeats by only flooding when the stroke just began. + if (!m_strokeJustBegan) return false; + m_strokeJustBegan = false; + return floodFillAtUV(uv); + } + case ToolColorPicker: { + if (!m_strokeJustBegan) return false; + m_strokeJustBegan = false; + pickColorAtUV(uv); + return false; + } + case ToolSmudge: { + // Sample previous stamp's pixels and bias toward them. + if (!m_smudgeHavePrev) { + m_smudgePrev = uv; + m_smudgeHavePrev = true; + return false; + } + // Walk pixels in the brush footprint; for each, sample at + // (uv + (prev - uv) * smudgeAmount) and lerp toward it. + const int W = m_buffer.width(); + const int H = m_buffer.height(); + const float radiusXf = radius * W; + const float radiusYf = radius * H; + const float cxF = uv.x * W; + const float cyF = uv.y * H; + const int x0 = std::max(0, static_cast(std::floor(cxF - radiusXf))); + const int x1 = std::min(W, static_cast(std::ceil (cxF + radiusXf))); + const int y0 = std::max(0, static_cast(std::floor(cyF - radiusYf))); + const int y1 = std::min(H, static_cast(std::ceil (cyF + radiusYf))); + if (x0 >= x1 || y0 >= y1) return false; + const float dxUV = m_smudgePrev.x - uv.x; + const float dyUV = m_smudgePrev.y - uv.y; + const float p = 1.0f + falloff * 3.0f; + const float invRx = 1.0f / std::max(radiusXf, 1e-6f); + const float invRy = 1.0f / std::max(radiusYf, 1e-6f); + bool changed = false; + int touchedX0 = x1, touchedX1 = x0, touchedY0 = y1, touchedY1 = y0; + for (int y = y0; y < y1; ++y) { + for (int x = x0; x < x1; ++x) { + const float dx = (x + 0.5f - cxF) * invRx; + const float dy = (y + 0.5f - cyF) * invRy; + const float r2 = dx * dx + dy * dy; + if (r2 >= 1.0f) continue; + const float w = std::pow(1.0f - r2, p); + const float blend = strength * w; + if (blend <= 0.0f) continue; + const Ogre::Vector2 sampleUV( + (x + 0.5f) / W + dxUV, + (y + 0.5f) / H + dyUV); + int sx = 0, sy = 0; + m_buffer.uvToPixel(sampleUV, sx, sy); + const auto src = m_buffer.pixel(sx, sy); + const auto dst = m_buffer.pixel(x, y); + if (src.a <= 0.0f) continue; + const Ogre::ColourValue blended( + dst.r + (src.r - dst.r) * blend, + dst.g + (src.g - dst.g) * blend, + dst.b + (src.b - dst.b) * blend, + dst.a + (src.a - dst.a) * blend); + m_buffer.setPixel(x, y, blended); + changed = true; + touchedX0 = std::min(touchedX0, x); + touchedY0 = std::min(touchedY0, y); + touchedX1 = std::max(touchedX1, x + 1); + touchedY1 = std::max(touchedY1, y + 1); + } + } + m_smudgePrev = uv; + return changed; + } + } + return false; +} + +bool TexturePaintController::floodFillAtUV(const Ogre::Vector2& uv) +{ + int sx = 0, sy = 0; + m_buffer.uvToPixel(uv, sx, sy); + const QColor c = texturePaintColor(); + const Ogre::ColourValue fill(c.redF(), c.greenF(), c.blueF(), c.alphaF()); + return m_buffer.floodFill(sx, sy, fill) > 0; +} + +void TexturePaintController::pickColorAtUV(const Ogre::Vector2& uv) +{ + int x = 0, y = 0; + m_buffer.uvToPixel(uv, x, y); + const auto c = m_buffer.pixel(x, y); + QColor qc; + qc.setRgbF(c.r, c.g, c.b, 1.0f); + if (auto* em = EditModeController::instance()) + em->setVertexPaintColor(qc); +} + +void TexturePaintController::endStroke() +{ + if (!m_strokeActive) return; + m_strokeActive = false; + // Ensure any pending debounced GPU upload runs immediately so + // the final stroke pixels are visible before the user releases. + if (!m_buffer.dirtyRect().empty()) + doFlushDirtyToOgre(); + // If nothing changed, drop the snapshot. + auto after = snapshotPixels(); + if (after == m_strokePreSnapshot) { + m_strokePreSnapshot.clear(); + SentryReporter::addBreadcrumb("ui.action", "Texture paint stroke end (no changes)"); + return; + } + auto* cmd = new TexturePaintStrokeCommand( + this, + std::move(m_strokePreSnapshot), + std::move(after), + m_buffer.width(), m_buffer.height(), + m_textureName); + UndoManager::getSingleton()->push(cmd); + SentryReporter::addBreadcrumb("ui.action", "Texture paint stroke end (committed)"); + // Persist the painted pixels for export. We do TWO things for + // texture target: + // (a) Write the buffer back to the original texture's on-disk + // file if it lives in a registered resource location. The + // Ogre and Assimp export paths re-read textures from disk. + // (b) Push the encoded PNG bytes into EmbeddedTextureCache + // under the original texture name. The FBX exporter pulls + // from this cache for textures that were originally + // embedded in the source FBX (no disk source) β€” without + // this they'd export with the un-painted bytes. + if (m_target == TargetTexture && !m_originalTextureName.isEmpty()) { + // bakeToOriginalFile returns the on-disk path when it found and + // overwrote a registered file. When it returns empty, the + // source was embedded (no disk file) and the FBX exporter will + // pull from EmbeddedTextureCache instead β€” only do the PNG + // encode + cache write in that fallback case to avoid burning + // CPU on the common disk-backed path. + const QString writtenDisk = bakeToOriginalFile(); + if (writtenDisk.isEmpty()) { + try { + QImage img(const_cast(m_buffer.data().data()), + m_buffer.width(), m_buffer.height(), + m_buffer.width() * 4, QImage::Format_RGBA8888); + QByteArray bytes; + QBuffer qbuf(&bytes); + qbuf.open(QIODevice::WriteOnly); + if (img.save(&qbuf, "PNG")) { + std::vector v(bytes.begin(), bytes.end()); + EmbeddedTextureCache::store( + m_originalTextureName.toStdString(), v); + SentryReporter::addBreadcrumb("ui.action", + QStringLiteral("Paint: cached %1 bytes in EmbeddedTextureCache for '%2'") + .arg(bytes.size()).arg(m_originalTextureName)); + } + } catch (...) {} + } + } +} + +std::vector TexturePaintController::snapshotPixels() const +{ + return m_buffer.data(); +} + +bool TexturePaintController::savePaintBuffer(const QString& path) const +{ + if (path.isEmpty()) return false; + if (!hasActiveSession()) return false; + QFileInfo fi(path); + QDir().mkpath(fi.absolutePath()); + return m_buffer.save(path.toStdString()); +} + +bool TexturePaintController::loadPaintBuffer(const QString& path) +{ + if (path.isEmpty()) return false; + if (!m_buffer.load(path.toStdString())) return false; + auto* entity = activeEntity(); + if (entity) { + QFileInfo fi(path); + QString hint = QStringLiteral("QMEPaintLoad_%1").arg(fi.completeBaseName()); + m_sessionEntity = entity; + // Re-create the Ogre texture at the new resolution. The user + // explicitly loaded a new image β€” rebind immediately so they + // see it on the model. + m_ogreTexture.reset(); + if (!createOgreTextureFromBuffer(entity, hint, /*rebindToModel=*/true)) return false; + } + // Refresh the 2D preview panel β€” the buffer just got rewritten + // with the loaded image's pixels. + refreshPreviewUri(); + emit sessionChanged(); + return true; +} + +QString TexturePaintController::savePaintBufferInteractive() +{ + if (!hasActiveSession()) { + SentryReporter::addBreadcrumb("ui.action", + "Texture paint: save dialog skipped (no session)"); + return QString(); + } + QApplication::processEvents(); + QWidget* parent = QApplication::activeWindow(); + const QString path = QFileDialog::getSaveFileName( + parent, + QStringLiteral("Save painted texture"), + QDir::currentPath() + "/paint.png", + QStringLiteral("PNG image (*.png);;JPEG image (*.jpg *.jpeg);;TGA image (*.tga);;BMP image (*.bmp)"), + nullptr, + QFileDialog::DontUseNativeDialog | QFileDialog::DontUseCustomDirectoryIcons); + if (path.isEmpty()) return QString(); + if (!savePaintBuffer(path)) { + SentryReporter::addBreadcrumb("ui.action", + QStringLiteral("Texture paint: save failed (%1)").arg(path)); + return QString(); + } + SentryReporter::addBreadcrumb("ui.action", + QStringLiteral("Texture paint: saved to %1").arg(path)); + return path; +} + +void TexturePaintController::pickBrushColorInteractive() +{ + auto* em = EditModeController::instance(); + if (!em) return; + QApplication::processEvents(); + QWidget* parent = QApplication::activeWindow(); + const QColor picked = QColorDialog::getColor( + em->vertexPaintColor(), parent, QStringLiteral("Brush color")); + if (picked.isValid()) + em->setVertexPaintColor(picked); +} + +void TexturePaintController::setBrushRadius(double r) +{ + if (auto* em = EditModeController::instance()) em->setVertexPaintRadius(r); +} + +void TexturePaintController::setBrushStrength(double s) +{ + if (auto* em = EditModeController::instance()) em->setVertexPaintStrength(s); +} + +void TexturePaintController::setBrushFalloff(double f) +{ + if (auto* em = EditModeController::instance()) em->setVertexPaintFalloff(f); +} + +void TexturePaintController::setBrushColor(const QColor& c) +{ + if (auto* em = EditModeController::instance()) em->setVertexPaintColor(c); +} + +QString TexturePaintController::bakeToOriginalFile() +{ + if (m_originalTextureName.isEmpty()) return QString(); + if (m_buffer.width() <= 0 || m_buffer.height() <= 0) return QString(); + + // Resolve the on-disk path. The texture name might already be a + // full filename; Ogre's resource manager has it indexed under + // each registered FileSystem location. + QString diskPath; + auto& rgm = Ogre::ResourceGroupManager::getSingleton(); + const Ogre::String texName = m_originalTextureName.toStdString(); + // Try every group's listResourceLocations. + try { + for (const auto& grp : rgm.getResourceGroups()) { + auto locs = rgm.listResourceLocations(grp); + for (const auto& loc : *locs) { + QDir d(QString::fromStdString(loc)); + const QString candidate = d.filePath(m_originalTextureName); + if (QFileInfo(candidate).exists()) { + diskPath = candidate; + break; + } + } + if (!diskPath.isEmpty()) break; + } + } catch (...) {} + + if (diskPath.isEmpty()) { + SentryReporter::addBreadcrumb("ui.action", + QStringLiteral("Bake to original: no disk file for texture '%1' (embedded?)") + .arg(m_originalTextureName)); + return QString(); + } + if (!m_buffer.save(diskPath.toStdString())) { + SentryReporter::addBreadcrumb("ui.action", + QStringLiteral("Bake to original: save FAILED at %1").arg(diskPath)); + return QString(); + } + SentryReporter::addBreadcrumb("ui.action", + QStringLiteral("Bake to original: wrote %1").arg(diskPath)); + return diskPath; +} + +QString TexturePaintController::loadPaintBufferInteractive() +{ + QApplication::processEvents(); + QWidget* parent = QApplication::activeWindow(); + const QString path = QFileDialog::getOpenFileName( + parent, + QStringLiteral("Load texture into paint buffer"), + QDir::currentPath(), + QStringLiteral("Image files (*.png *.jpg *.jpeg *.tga *.bmp);;All files (*)"), + nullptr, + QFileDialog::DontUseNativeDialog | QFileDialog::DontUseCustomDirectoryIcons); + if (path.isEmpty()) return QString(); + if (!loadPaintBuffer(path)) { + SentryReporter::addBreadcrumb("ui.action", + QStringLiteral("Texture paint: load failed (%1)").arg(path)); + return QString(); + } + SentryReporter::addBreadcrumb("ui.action", + QStringLiteral("Texture paint: loaded %1").arg(path)); + return path; +} + +int TexturePaintController::bakeVertexColorsToTexture(int resolution, + int dilation, + const QString& savePath) +{ + auto* entity = activeEntity(); + if (!entity) return -1; + if (!ensureEditableMesh(entity)) return -1; + const int res = resolution > 0 ? resolution + : (m_buffer.width() > 0 ? m_buffer.width() : 1024); + VertexColorBaker::Options opts; + opts.resolution = res; + opts.dilationPixels = std::max(0, dilation); + const int painted = VertexColorBaker::bake(*m_paintMesh, m_buffer, opts); + + m_sessionEntity = entity; + m_ogreTexture.reset(); + static unsigned int s_bakeUnique = 0; + const QString hint = QStringLiteral("QMEBake_%1_%2") + .arg(QString::fromStdString(entity->getName())) + .arg(++s_bakeUnique); + // Bake is an explicit user action β€” rebind immediately so the + // baked result appears on the model. + createOgreTextureFromBuffer(entity, hint, /*rebindToModel=*/true); + + if (!savePath.isEmpty()) + m_buffer.save(savePath.toStdString()); + + SentryReporter::addBreadcrumb("ui.action", + QStringLiteral("Vertexβ†’Texture bake: %1Γ—%1 (%2 pixels, dilation=%3)") + .arg(res).arg(painted).arg(opts.dilationPixels)); + + refreshPreviewUri(); + emit sessionChanged(); + return painted; +} + +void TexturePaintController::applyPixelSnapshot(const std::vector& pixels) +{ + if (m_buffer.width() <= 0 || m_buffer.height() <= 0) return; + if (pixels.size() != m_buffer.data().size()) return; + std::memcpy(m_buffer.data().data(), pixels.data(), pixels.size()); + m_buffer.markDirty(0, 0, m_buffer.width(), m_buffer.height()); + flushDirtyToOgre(); +} + +void TexturePaintController::closeSession() +{ + if (m_strokeActive) endStroke(); + + // Restore every TUS we rebound. Must do this BEFORE removing the + // paint texture from TextureManager, otherwise the next render + // tries to sample a freed handle. Look the material up by name + // each time so a destroyed/reloaded material doesn't dangle. + std::set toReload; + for (const auto& s : m_boundSlots) { + try { + if (s.materialName.empty()) continue; + auto matPtr = Ogre::MaterialManager::getSingleton().getByName( + s.materialName); + if (!matPtr) continue; + const auto& techs = matPtr->getTechniques(); + if (s.techIdx >= techs.size()) continue; + auto* tech = techs[s.techIdx]; + if (s.passIdx >= tech->getNumPasses()) continue; + auto* p = tech->getPass(s.passIdx); + if (s.tusIdx >= p->getNumTextureUnitStates()) continue; + auto* tusN = p->getTextureUnitState(s.tusIdx); + tusN->setTextureName(s.originalTexture); + toReload.insert(s.materialName); + } catch (...) {} + } + m_boundSlots.clear(); + for (const auto& mname : toReload) { + try { + auto matPtr = Ogre::MaterialManager::getSingleton().getByName(mname); + if (matPtr) { matPtr->compile(); matPtr->reload(); } + } catch (...) {} + } + + if (m_ogreTexture) { + try { + Ogre::TextureManager::getSingleton().remove(m_ogreTexture); + } catch (...) {} + m_ogreTexture.reset(); + } + if (m_ringObj && m_paintMeshEntity) { + try { + auto* mgr = m_paintMeshEntity->_getManager(); + if (mgr) { + if (m_ringNode) { + m_ringNode->detachAllObjects(); + mgr->getRootSceneNode()->removeChild(m_ringNode); + mgr->destroySceneNode(m_ringNode); + m_ringNode = nullptr; + } + mgr->destroyManualObject(m_ringObj); + m_ringObj = nullptr; + } + } catch (...) {} + } + m_paintMesh.reset(); + m_paintMeshEntity = nullptr; + m_buffer = TexturePaintBuffer(); + m_textureName.clear(); + m_originalTexture.reset(); + m_originalTextureName.clear(); + m_useOriginalTexture = false; + m_loggedInPlaceBlit = false; + m_rebindScheduled = false; + m_gpuFlushScheduled = false; + m_sessionEntity = nullptr; + m_strokePreSnapshot.clear(); + if (!m_uvOverlayUri.isEmpty()) { + m_uvOverlayUri.clear(); + emit uvOverlayChanged(); + } + m_previewUri.clear(); + emit previewChanged(); + emit sessionChanged(); +} + +// --------------------------------------------------------------------------- +// UV-based stroke API (driven by the texture preview panel) +// --------------------------------------------------------------------------- + +bool TexturePaintController::beginStrokeUV(double u, double v) +{ + if (!m_paintEnabled || m_strokeActive) return false; + if (!hasActiveSession()) + if (!ensurePaintableTexture(1024)) return false; + m_strokeActive = true; + m_strokeJustBegan = true; + m_smudgeHavePrev = false; + m_strokePreSnapshot = snapshotPixels(); + emit hoveredUVChanged(u, v); + updateStrokeUV(u, v); + return true; +} + +void TexturePaintController::updateStrokeUV(double u, double v) +{ + if (!m_strokeActive || !m_paintEnabled) return; + const Ogre::Vector2 uv(static_cast(u), static_cast(v)); + emit hoveredUVChanged(u, v); + // Update brush-ring overlay on the mesh so the user sees their + // painting location even when driving the brush from the 2D panel. + Ogre::Vector3 localPos, localNormal; + if (findMeshPointForUV(uv, localPos, localNormal)) + drawHoverRingAt(localPos, localNormal); + if (applyBrushAtUV(uv)) + flushDirtyToOgre(); +} + +void TexturePaintController::endStrokeUV() +{ + endStroke(); +} + +void TexturePaintController::setHoveredUV(double u, double v) +{ + emit hoveredUVChanged(u, v); + // Reverse-lookup: find the 3D point on the mesh that maps to this + // UV, then draw the brush ring there. Lets the user "scrub" the + // texture panel and see the corresponding location on the model. + if (!m_paintEnabled || !m_paintMesh || !m_paintMeshEntity) return; + Ogre::Vector3 localPos, localNormal; + if (findMeshPointForUV(Ogre::Vector2(u, v), localPos, localNormal)) + drawHoverRingAt(localPos, localNormal); +} + +void TexturePaintController::clearHoveredUV() +{ + emit hoveredUVChanged(-1.0, -1.0); +} + +// --------------------------------------------------------------------------- +// Texture slot enumeration +// --------------------------------------------------------------------------- + +void TexturePaintController::refreshSlots() +{ + // Refresh is pure metadata β€” it must not create a paint session + // as a side effect. Otherwise toggling the brush button (which + // calls refreshSlots) silently rebinds the model's diffuse TUS + // and the user sees the model "go textureless". Sessions are + // created lazily inside beginStroke() instead. + // + // But we DO eagerly build the EditableMesh for the selected + // entity so hover/brush-ring queries work without an active + // session. The EditableMesh is just a CPU mirror of mesh data; + // it doesn't alter the model's render. + if (m_paintEnabled) { + if (auto* e = activeEntity()) + ensureEditableMesh(e); + } + + QVariantList newSlots; + auto* entity = activeEntity(); + if (entity) { + for (unsigned int si = 0; si < entity->getNumSubEntities(); ++si) { + auto* subEnt = entity->getSubEntity(si); + if (!subEnt) continue; + Ogre::MaterialPtr mat = subEnt->getMaterial(); + if (!mat || mat->getNumTechniques() == 0) continue; + auto* tech = mat->getTechnique(0); + if (!tech || tech->getNumPasses() == 0) continue; + auto* pass = tech->getPass(0); + if (!pass) continue; + for (unsigned short ti = 0; ti < pass->getNumTextureUnitStates(); ++ti) { + auto* tus = pass->getTextureUnitState(ti); + const std::string& n = tus->getName(); + const std::string tex = tus->getTextureName(); + // Skip entirely-empty TUSes (no name AND no texture) β€” + // they're usually placeholders. + if (n.empty() && tex.empty()) continue; + QVariantMap m; + const QString labelN = QString::fromStdString(n.empty() ? "TUS " + std::to_string(ti) : n); + m["label"] = QStringLiteral("sub %1 β€” %2").arg(si).arg(labelN); + m["submesh"] = static_cast(si); + m["slot"] = QString::fromStdString(n); + m["textureName"] = QString::fromStdString(tex); + newSlots.append(m); + } + } + } + if (newSlots == m_slots) return; + m_slots = newSlots; + if (m_activeSlot >= m_slots.size()) + m_activeSlot = m_slots.isEmpty() ? -1 : 0; + emit slotsChanged(); +} + +// --------------------------------------------------------------------------- +// Preview URI +// --------------------------------------------------------------------------- + +void TexturePaintController::setUvOverlayVisible(bool on) +{ + if (m_uvOverlayVisible == on) return; + m_uvOverlayVisible = on; + if (on && m_uvOverlayUri.isEmpty()) refreshUvOverlay(); + emit uvOverlayChanged(); +} + +void TexturePaintController::refreshUvOverlay() +{ + if (!m_paintMesh || m_buffer.width() <= 0 || m_buffer.height() <= 0) { + if (!m_uvOverlayUri.isEmpty()) { + m_uvOverlayUri.clear(); + emit uvOverlayChanged(); + } + return; + } + const int W = m_buffer.width(); + const int H = m_buffer.height(); + QImage img(W, H, QImage::Format_RGBA8888); + img.fill(Qt::transparent); + QPainter p(&img); + p.setRenderHint(QPainter::Antialiasing, false); + QPen pen(QColor(255, 255, 255, 200)); + // Keep the wireframe a single pixel wide at any resolution. + pen.setWidth(1); + pen.setCosmetic(true); + p.setPen(pen); + + auto toPx = [&](const Ogre::Vector2& uv) { + return QPointF(uv.x * W, uv.y * H); + }; + for (const auto& sub : m_paintMesh->subMeshes()) { + for (const auto& tri : sub.triangles) { + const auto& v0 = sub.vertices[tri.indices[0]]; + const auto& v1 = sub.vertices[tri.indices[1]]; + const auto& v2 = sub.vertices[tri.indices[2]]; + if (!v0.hasUV || !v1.hasUV || !v2.hasUV) continue; + const QPointF p0 = toPx(v0.uv); + const QPointF p1 = toPx(v1.uv); + const QPointF p2 = toPx(v2.uv); + p.drawLine(p0, p1); + p.drawLine(p1, p2); + p.drawLine(p2, p0); + } + } + p.end(); + + QByteArray bytes; + QBuffer qbuf(&bytes); + qbuf.open(QIODevice::WriteOnly); + img.save(&qbuf, "PNG"); + const QString next = QStringLiteral("data:image/png;base64,") + + QString::fromLatin1(bytes.toBase64()); + if (next != m_uvOverlayUri) { + m_uvOverlayUri = next; + emit uvOverlayChanged(); + } +} + +void TexturePaintController::refreshPreviewUri() +{ + if (m_buffer.width() <= 0 || m_buffer.height() <= 0) { + if (!m_previewUri.isEmpty()) { + m_previewUri.clear(); + emit previewChanged(); + } + return; + } + // Scale down to a thumbnail before PNG-encoding. The preview + // panel is 256Γ—256; encoding a 1024Β² or 2048Β² PNG every refresh + // burns most of a frame on the main thread. Qt::FastTransformation + // is the cheapest scaler. + QImage src(const_cast(m_buffer.data().data()), + m_buffer.width(), m_buffer.height(), + m_buffer.width() * 4, QImage::Format_RGBA8888); + constexpr int kPreviewMax = 256; + QImage thumb = (m_buffer.width() > kPreviewMax || m_buffer.height() > kPreviewMax) + ? src.scaled(kPreviewMax, kPreviewMax, + Qt::KeepAspectRatio, Qt::FastTransformation) + : src.copy(); // own the pixels β€” src points at m_buffer + QByteArray bytes; + QBuffer qbuf(&bytes); + qbuf.open(QIODevice::WriteOnly); + thumb.save(&qbuf, "PNG"); + const QString next = QStringLiteral("data:image/png;base64,") + + QString::fromLatin1(bytes.toBase64()); + if (next != m_previewUri) { + m_previewUri = next; + emit previewChanged(); + } +} + +// --------------------------------------------------------------------------- +// 3D-mesh hover ring overlay +// --------------------------------------------------------------------------- + +void TexturePaintController::updateMeshHover(OgreWidget* widget, const QPoint& screenPos) +{ + if (!m_paintEnabled) { clearMeshHover(); return; } + // Build the EditableMesh on first hover if it isn't already + // ready. This handles the "user selected the mesh AFTER enabling + // paint" case so the brush ring appears as soon as the cursor + // touches the mesh. + if (!m_paintMesh || !m_paintMeshEntity) { + if (auto* e = activeEntity()) + ensureEditableMesh(e); + } + Ogre::Vector2 uv; + if (!hitTestUV(screenPos, widget, uv)) { + clearMeshHover(); + return; + } + emit hoveredUVChanged(uv.x, uv.y); + + // Compute the 3D hit point in local space and a surface normal. + // We piggyback on the same hit-test math: re-fire a ray and find + // the closest triangle (same logic as hitTestUV; we keep it inline + // here to also recover the world-space hit position and normal). + auto* mesh = m_paintMesh.get(); + auto* entity = m_paintMeshEntity; + if (!mesh || !entity) return; + auto* spaceCam = widget->getSpaceCamera(); + auto* camera = spaceCam ? spaceCam->getCamera() : nullptr; + if (!camera) return; + int vw = 0, vh = 0; + widget->pixelSizeForCameraPicking(vw, vh); + if (vw <= 0 || vh <= 0) return; + const Ogre::Real nx = static_cast(screenPos.x()) / vw; + const Ogre::Real ny = static_cast(screenPos.y()) / vh; + const Ogre::Ray ray = camera->getCameraToViewportRay(nx, ny); + Ogre::SceneNode* node = entity->getParentSceneNode(); + Ogre::Affine3 worldToLocal = node ? node->_getFullTransform().inverse() : Ogre::Affine3::IDENTITY; + Ogre::Vector3 localOrigin = worldToLocal * ray.getOrigin(); + Ogre::Vector3 localDir = worldToLocal.linear() * ray.getDirection(); + localDir.normalise(); + + Ogre::Vector3 hitLocal(0,0,0); + Ogre::Vector3 hitNormal(0,1,0); + Ogre::Real bestT = std::numeric_limits::infinity(); + for (const auto& sub : mesh->subMeshes()) { + for (const auto& tri : sub.triangles) { + const auto& v0 = sub.vertices[tri.indices[0]]; + const auto& v1 = sub.vertices[tri.indices[1]]; + const auto& v2 = sub.vertices[tri.indices[2]]; + const Ogre::Vector3 e1 = v1.position - v0.position; + const Ogre::Vector3 e2 = v2.position - v0.position; + const Ogre::Vector3 pvec = localDir.crossProduct(e2); + const Ogre::Real det = e1.dotProduct(pvec); + if (std::abs(det) < 1e-8f) continue; + const Ogre::Real invDet = 1.0f / det; + const Ogre::Vector3 tvec = localOrigin - v0.position; + const Ogre::Real u = tvec.dotProduct(pvec) * invDet; + if (u < 0.0f || u > 1.0f) continue; + const Ogre::Vector3 qvec = tvec.crossProduct(e1); + const Ogre::Real v = localDir.dotProduct(qvec) * invDet; + if (v < 0.0f || u + v > 1.0f) continue; + const Ogre::Real tHit = e2.dotProduct(qvec) * invDet; + if (tHit <= 0.0f || tHit >= bestT) continue; + bestT = tHit; + hitLocal = localOrigin + localDir * tHit; + hitNormal = e1.crossProduct(e2); + if (!hitNormal.isZeroLength()) hitNormal.normalise(); + } + } + if (bestT == std::numeric_limits::infinity()) { + clearMeshHover(); + return; + } + drawHoverRingAt(hitLocal, hitNormal); +} + +void TexturePaintController::clearMeshHover() +{ + if (m_ringObj) m_ringObj->clear(); + emit hoveredUVChanged(-1.0, -1.0); +} + +bool TexturePaintController::hitTestLocalPoint(OgreWidget* widget, const QPoint& screenPos, + Ogre::Vector3& outLocal, Ogre::Vector3& outNormal) const +{ + if (!m_paintMesh || !m_paintMeshEntity || !widget) return false; + auto* spaceCam = widget->getSpaceCamera(); + auto* camera = spaceCam ? spaceCam->getCamera() : nullptr; + if (!camera) return false; + int vw = 0, vh = 0; + widget->pixelSizeForCameraPicking(vw, vh); + if (vw <= 0 || vh <= 0) return false; + const Ogre::Real nx = static_cast(screenPos.x()) / vw; + const Ogre::Real ny = static_cast(screenPos.y()) / vh; + const Ogre::Ray ray = camera->getCameraToViewportRay(nx, ny); + Ogre::SceneNode* node = m_paintMeshEntity->getParentSceneNode(); + Ogre::Affine3 worldToLocal = node ? node->_getFullTransform().inverse() : Ogre::Affine3::IDENTITY; + Ogre::Vector3 localOrigin = worldToLocal * ray.getOrigin(); + Ogre::Vector3 localDir = worldToLocal.linear() * ray.getDirection(); + localDir.normalise(); + Ogre::Real bestT = std::numeric_limits::infinity(); + bool found = false; + for (const auto& sub : m_paintMesh->subMeshes()) { + for (const auto& tri : sub.triangles) { + const auto& v0 = sub.vertices[tri.indices[0]]; + const auto& v1 = sub.vertices[tri.indices[1]]; + const auto& v2 = sub.vertices[tri.indices[2]]; + const Ogre::Vector3 e1 = v1.position - v0.position; + const Ogre::Vector3 e2 = v2.position - v0.position; + const Ogre::Vector3 pvec = localDir.crossProduct(e2); + const Ogre::Real det = e1.dotProduct(pvec); + if (std::abs(det) < 1e-8f) continue; + const Ogre::Real invDet = 1.0f / det; + const Ogre::Vector3 tvec = localOrigin - v0.position; + const Ogre::Real u = tvec.dotProduct(pvec) * invDet; + if (u < 0.0f || u > 1.0f) continue; + const Ogre::Vector3 qvec = tvec.crossProduct(e1); + const Ogre::Real v = localDir.dotProduct(qvec) * invDet; + if (v < 0.0f || u + v > 1.0f) continue; + const Ogre::Real tHit = e2.dotProduct(qvec) * invDet; + if (tHit <= 0.0f || tHit >= bestT) continue; + bestT = tHit; + outLocal = localOrigin + localDir * tHit; + Ogre::Vector3 n = e1.crossProduct(e2); + if (!n.isZeroLength()) n.normalise(); + else n = Ogre::Vector3::UNIT_Y; + outNormal = n; + found = true; + } + } + return found; +} + +bool TexturePaintController::findMeshPointForUV(const Ogre::Vector2& uv, + Ogre::Vector3& outLocal, + Ogre::Vector3& outNormal) const +{ + if (!m_paintMesh) return false; + // Walk every triangle, find the one whose UV-space contains `uv` + // (barycentric test on UV triangle), then interpolate the 3D + // position with those same barycentrics. + for (const auto& sub : m_paintMesh->subMeshes()) { + for (const auto& tri : sub.triangles) { + const auto& v0 = sub.vertices[tri.indices[0]]; + const auto& v1 = sub.vertices[tri.indices[1]]; + const auto& v2 = sub.vertices[tri.indices[2]]; + if (!v0.hasUV || !v1.hasUV || !v2.hasUV) continue; + // Barycentric coords for `uv` in the UV triangle (v0.uv, v1.uv, v2.uv). + const Ogre::Vector2 e1 = v1.uv - v0.uv; + const Ogre::Vector2 e2 = v2.uv - v0.uv; + const Ogre::Vector2 dp = uv - v0.uv; + const float denom = e1.x * e2.y - e2.x * e1.y; + if (std::abs(denom) < 1e-10f) continue; + const float u = (dp.x * e2.y - e2.x * dp.y) / denom; + const float v = (e1.x * dp.y - dp.x * e1.y) / denom; + const float w = 1.0f - u - v; + const float eps = 1e-4f; + if (u < -eps || v < -eps || w < -eps) continue; + outLocal = v0.position * w + v1.position * u + v2.position * v; + Ogre::Vector3 n = (v1.position - v0.position) + .crossProduct(v2.position - v0.position); + if (!n.isZeroLength()) n.normalise(); + else n = Ogre::Vector3::UNIT_Y; + outNormal = n; + return true; + } + } + return false; +} + +void TexturePaintController::drawHoverRingAt(const Ogre::Vector3& localPos, + const Ogre::Vector3& localNormal) +{ + auto* entity = m_paintMeshEntity; + if (!entity) return; + auto* sceneMgr = entity->_getManager(); + if (!sceneMgr) return; + + // Ensure the hover-line material exists. It's normally created + // when entering Edit Mode (EditModeController::createOverlayMaterials); + // we may need to draw the ring before the user has ever entered + // Edit Mode, so set it up here too. + static const char* kMatName = "TexturePaint/HoverRing"; + auto& matMgr = Ogre::MaterialManager::getSingleton(); + if (!matMgr.getByName(kMatName)) { + auto mat = matMgr.create(kMatName, + Ogre::ResourceGroupManager::INTERNAL_RESOURCE_GROUP_NAME); + auto* pass = mat->getTechnique(0)->getPass(0); + pass->setLightingEnabled(false); + pass->setVertexColourTracking(Ogre::TVC_DIFFUSE); + pass->setDepthCheckEnabled(false); + pass->setDepthWriteEnabled(false); + pass->setLineWidth(2.0f); + } + if (!m_ringNode) m_ringNode = sceneMgr->getRootSceneNode()->createChildSceneNode(); + if (!m_ringObj) { + m_ringObj = sceneMgr->createManualObject("TexturePaint_HoverRing"); + m_ringObj->setDynamic(true); + m_ringObj->setRenderQueueGroup(Ogre::RENDER_QUEUE_OVERLAY); + m_ringNode->attachObject(m_ringObj); + } + auto* node = entity->getParentSceneNode(); + if (node) { + m_ringNode->setPosition(node->_getDerivedPosition()); + m_ringNode->setOrientation(node->_getDerivedOrientation()); + m_ringNode->setScale(node->_getDerivedScale()); + } + m_ringObj->clear(); + + Ogre::Vector3 normal = localNormal; + if (normal.squaredLength() < 1e-6f) normal = Ogre::Vector3::UNIT_Y; + Ogre::Vector3 tangent = normal.perpendicular(); + tangent.normalise(); + Ogre::Vector3 bitangent = normal.crossProduct(tangent); + bitangent.normalise(); + + const QColor c = texturePaintColor(); + const Ogre::ColourValue ringCol(c.redF(), c.greenF(), c.blueF(), 0.95f); + constexpr int kSegments = 64; + // The brush radius is in local mesh units (shared with vertex paint). + // 0.8 narrows the ring slightly so it visually matches the painted + // footprint (a softly-falloff brush doesn't quite touch the ring edge). + const float radius = static_cast(texturePaintRadius()) * 0.8f; + const Ogre::Vector3 center = localPos + normal * 0.001f; + m_ringObj->begin(kMatName, Ogre::RenderOperation::OT_LINE_STRIP); + for (int i = 0; i <= kSegments; ++i) { + const float t = static_cast(i) / kSegments; + const float a = Ogre::Math::TWO_PI * t; + const Ogre::Vector3 p = center + + (tangent * Ogre::Math::Cos(a) + bitangent * Ogre::Math::Sin(a)) * radius; + m_ringObj->position(p); + m_ringObj->colour(ringCol); + } + m_ringObj->end(); +} diff --git a/src/TexturePaintController.h b/src/TexturePaintController.h new file mode 100644 index 000000000..71b4a6f21 --- /dev/null +++ b/src/TexturePaintController.h @@ -0,0 +1,440 @@ +#ifndef TEXTUREPAINTCONTROLLER_H +#define TEXTUREPAINTCONTROLLER_H + +#include "TexturePaintBuffer.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +#include +#include + +class EditableMesh; +class OgreWidget; + +namespace Ogre { +class Entity; +class Pass; +class TextureUnitState; +} // namespace Ogre + +/** + * @brief QML_SINGLETON managing texture painting on the active edit-mode entity. + * + * Mirrors the EditModeController vertex-paint flow: enable a mode, hook + * into the existing TransformOperator mouse pipeline, hit-test a ray + * against the EditableMesh, interpolate UV from the hit triangle, and + * paint into a CPU-side TexturePaintBuffer. Each stroke uploads only + * the dirty rect to a live Ogre::Texture so the viewport sees changes + * with no full re-upload. + * + * Save / load round-trips the buffer to PNG (or any QImage-supported + * format). Bake builds the same buffer from EditableMesh vertex colors + * and is exposed as a Q_INVOKABLE so the QML "Bake" button hits the + * same code path as the CLI. + * + * The controller does **not** own the Ogre::Texture once it has been + * created β€” Ogre's TextureManager retains ownership, and the buffer + * is a CPU-side mirror. Closing edit mode flushes the buffer to the + * texture one last time and resets the session. + */ +class TexturePaintController : public QObject +{ + Q_OBJECT + QML_ELEMENT + QML_SINGLETON + + Q_PROPERTY(bool texturePaintEnabled READ texturePaintEnabled WRITE setTexturePaintEnabled NOTIFY texturePaintChanged) + // Brush params are mirrored from EditModeController so the toolbar brush + // popup is the single source of truth β€” read-only here, used by QML for + // live preview text in the Texture Paint section. + Q_PROPERTY(QColor texturePaintColor READ texturePaintColor NOTIFY texturePaintChanged) + Q_PROPERTY(double texturePaintRadius READ texturePaintRadius NOTIFY texturePaintChanged) + Q_PROPERTY(double texturePaintStrength READ texturePaintStrength NOTIFY texturePaintChanged) + Q_PROPERTY(double texturePaintFalloff READ texturePaintFalloff NOTIFY texturePaintChanged) + Q_PROPERTY(int textureResolution READ textureResolution NOTIFY sessionChanged) + Q_PROPERTY(QString currentTextureName READ currentTextureName NOTIFY sessionChanged) + Q_PROPERTY(bool hasActiveSession READ hasActiveSession NOTIFY sessionChanged) + + // Brush tool β€” paint / erase / fill / picker. + Q_PROPERTY(int brushTool READ brushTool WRITE setBrushTool NOTIFY brushToolChanged) + + // Paint target β€” texture or vertex. + Q_PROPERTY(int paintTarget READ paintTarget WRITE setPaintTarget NOTIFY paintTargetChanged) + + // Live preview of the current paint buffer as a data URI, so the QML + // preview panel can render it via Image { source: ... }. Emitted on + // every dirty-rect flush so the preview stays in sync with strokes. + Q_PROPERTY(QString previewDataUri READ previewDataUri NOTIFY previewChanged) + + // Texture slots on the currently-selected entity. Each entry is a + // map: { label, submesh, slot, textureName }. QML reads this to + // populate the slot picker. + Q_PROPERTY(QVariantList textureSlots READ textureSlots NOTIFY slotsChanged) + Q_PROPERTY(int activeSlotIndex READ activeSlotIndex WRITE setActiveSlotIndex NOTIFY slotsChanged) + +public: + enum BrushTool { + ToolPaint = 0, ///< Lerp pixels toward brush color. + ToolErase = 1, ///< Paint transparent (alpha 0). Reveals layer below if any. + ToolFill = 2, ///< Flood-fill connected pixels under cursor. + ToolColorPicker = 3, ///< Sample color at hit UV into the brush color. + ToolSmudge = 4, ///< Drag pixels in the brush direction. + }; + Q_ENUM(BrushTool) + + /// What the brush paints into. + enum PaintTarget { + TargetTexture = 0, ///< Paint into the BaseColor texture (default). + TargetVertex = 1, ///< Paint vertex colors (formerly Edit Mode's vertex paint). + }; + Q_ENUM(PaintTarget) + + static TexturePaintController* instance(); + static TexturePaintController* qmlInstance(QQmlEngine* engine, QJSEngine* scriptEngine); + static void kill(); + + /// @name Paint-mode toggle (mirrors vertex-paint toggle on EditMode) + /// @{ + bool texturePaintEnabled() const { return m_paintEnabled; } + void setTexturePaintEnabled(bool enabled); + /// @} + + /// @name Brush parameters (read-only mirror of EditModeController) + /// @{ + QColor texturePaintColor() const; + double texturePaintRadius() const; + double texturePaintStrength() const; + double texturePaintFalloff() const; + /// @} + + /// @name Session state + /// @{ + int textureResolution() const { return m_buffer.width(); } + QString currentTextureName() const { return m_textureName; } + bool hasActiveSession() const { return m_buffer.width() > 0 && !m_textureName.isEmpty(); } + /// @} + + /// @name Brush tool + /// @{ + int brushTool() const { return static_cast(m_tool); } + void setBrushTool(int tool); + /// @} + + /// @name Paint target (texture or vertex colors) + /// @{ + int paintTarget() const { return static_cast(m_target); } + void setPaintTarget(int target); + /// @} + + /// @name Texture slot enumeration (selection-driven) + /// @{ + QVariantList textureSlots() const { return m_slots; } + int activeSlotIndex() const { return m_activeSlot; } + void setActiveSlotIndex(int index); + /// Recompute the texture slot list from the current selection. + Q_INVOKABLE void refreshSlots(); + /// @} + + /// Preview data URI (PNG, base64) regenerated on every dirty flush. + QString previewDataUri() const { return m_previewUri; } + + /// PNG data URI of the UV wireframe (white triangles on transparent + /// background) at the current texture resolution. Lets the QML + /// preview panel overlay UV islands on the texture so the user can + /// see where their strokes are going relative to the unwrap. + Q_PROPERTY(QString uvOverlayDataUri READ uvOverlayDataUri NOTIFY uvOverlayChanged) + QString uvOverlayDataUri() const { return m_uvOverlayUri; } + + Q_PROPERTY(bool uvOverlayVisible READ uvOverlayVisible WRITE setUvOverlayVisible NOTIFY uvOverlayChanged) + bool uvOverlayVisible() const { return m_uvOverlayVisible; } + void setUvOverlayVisible(bool on); + + /// Paint via a UV coordinate directly (driven by the texture preview + /// panel's mouse area). The preview ↔ 3D mesh hover indicator is + /// driven by emitting hoveredUVChanged at the same time. + Q_INVOKABLE bool beginStrokeUV(double u, double v); + Q_INVOKABLE void updateStrokeUV(double u, double v); + Q_INVOKABLE void endStrokeUV(); + /// Update the "hovered UV" without painting. Drives the brush ring + /// overlay on the 3D mesh from the 2D preview panel. + Q_INVOKABLE void setHoveredUV(double u, double v); + Q_INVOKABLE void clearHoveredUV(); + + /// Hover update from the 3D viewport β€” does a hit-test, emits the + /// hovered UV signal, and draws the brush-ring overlay at the hit + /// point in mesh-local space. + void updateMeshHover(OgreWidget* widget, const QPoint& screenPos); + void clearMeshHover(); + + /// @name Stroke API (called from TransformOperator mouse pipeline) + /// @{ + bool beginStroke(OgreWidget* widget, const QPoint& screenPos); + void updateStroke(OgreWidget* widget, const QPoint& screenPos); + void endStroke(); + /// @} + + /** + * @brief Create or attach a paintable texture for the active edit entity. + * + * If the entity's first submesh material has an existing diffuse + * texture, that texture is loaded into the paint buffer. Otherwise + * a blank `resolution`x`resolution` opaque-white buffer is created + * and bound under a generated texture name (`QMEPaint_`). + * + * @return true if a paint session is now active. + */ + Q_INVOKABLE bool ensurePaintableTexture(int resolution = 1024); + + /** + * @brief Save the current paint buffer to disk. + * + * @param path Absolute path. Extension determines format (PNG by default). + * @return true on success. + */ + Q_INVOKABLE bool savePaintBuffer(const QString& path) const; + + /// Show a native save dialog and write the paint buffer to the chosen path. + /// Returns the path on success or empty on cancel/failure. + Q_INVOKABLE QString savePaintBufferInteractive(); + + /** + * @brief Load an image into the paint buffer (replaces contents) and + * binds it as the active texture on the entity's first submesh. + */ + Q_INVOKABLE bool loadPaintBuffer(const QString& path); + + /// Show a native open dialog and load the chosen image. Returns the + /// path on success or empty on cancel/failure. + Q_INVOKABLE QString loadPaintBufferInteractive(); + + /// Write the current paint buffer **into the original texture's + /// on-disk file** so exports pick up the painted pixels. Returns + /// the disk path that was written, or empty if no source file was + /// found (e.g. embedded texture). + Q_INVOKABLE QString bakeToOriginalFile(); + + /// Show a native color picker for the (shared) brush color. Writes + /// back to EditModeController on accept. + Q_INVOKABLE void pickBrushColorInteractive(); + + /// Setters that mirror through to EditModeController so the toolbar + /// brush popup and the Material-mode Paint Brush panel both stay + /// in sync. + Q_INVOKABLE void setBrushRadius(double r); + Q_INVOKABLE void setBrushStrength(double s); + Q_INVOKABLE void setBrushFalloff(double f); + Q_INVOKABLE void setBrushColor(const QColor& c); + + /** + * @brief Bake the active EditableMesh's vertex colors into the texture buffer. + * + * @param resolution Square output resolution (defaults to current + * buffer size if non-zero, else 1024). + * @param dilation Edge dilation in pixels after rasterization. + * @param savePath Optional path β€” if non-empty, the result is also + * written to disk. + * @return Pixel count rasterized (before dilation), or -1 on failure. + */ + Q_INVOKABLE int bakeVertexColorsToTexture(int resolution = 0, + int dilation = 4, + const QString& savePath = QString()); + + /// @brief End the session β€” release the paint buffer. + Q_INVOKABLE void closeSession(); + + /// Walk every UV-mapped triangle and return the local-space + /// position + normal at `uv` (the first triangle that covers it + /// in UV space). Used by the 2D-panel β†’ 3D-mesh hover lookup; + /// also exposed publicly so unit tests can verify reverse-UV + /// math against an in-memory mesh. + bool findMeshPointForUV(const Ogre::Vector2& uv, + Ogre::Vector3& outLocal, + Ogre::Vector3& outNormal) const; + + /// Read-only access for tests. + const TexturePaintBuffer& buffer() const { return m_buffer; } + TexturePaintBuffer& mutableBuffer() { return m_buffer; } + + /// Internal: replace the buffer pixel data and re-upload to the + /// live Ogre texture. Used by the undo command. Width/height must + /// match the current buffer. + void applyPixelSnapshot(const std::vector& pixels); + +signals: + void texturePaintChanged(); + void sessionChanged(); + void brushToolChanged(); + void paintTargetChanged(); + void slotsChanged(); + void previewChanged(); + void uvOverlayChanged(); + /// Emitted when the mouse hovers over a UV-mapped triangle (from + /// the 3D mesh or from the 2D texture preview panel). u,v in [0..1]; + /// (-1, -1) means "no hover". + void hoveredUVChanged(double u, double v); + +private: + explicit TexturePaintController(QObject* parent = nullptr); + ~TexturePaintController() override; + + /// Currently-selected entity (or Edit Mode's active entity if Edit + /// Mode happens to be on). Painting no longer requires Edit Mode β€” + /// we keep our own private EditableMesh built from this entity. + Ogre::Entity* activeEntity() const; + + /// Ensure the private EditableMesh is built for the active entity. + /// Called on session creation and whenever the selection changes. + bool ensureEditableMesh(Ogre::Entity* entity); + + /// Find the texture unit corresponding to the currently-active slot, + /// creating one if no diffuse-like TUS exists on the selected submesh. + Ogre::TextureUnitState* findOrCreateActiveTextureUnit(Ogre::Entity* entity); + + /// Hit-test screen position against the private editable mesh and + /// recover the barycentric-interpolated UV at the hit point. Returns + /// false on miss. + bool hitTestUV(const QPoint& screenPos, OgreWidget* widget, Ogre::Vector2& outUV) const; + + /// Same hit-test but returns the local-space position and normal + /// at the hit (for vertex paint, which works in 3D space). + bool hitTestLocalPoint(OgreWidget* widget, const QPoint& screenPos, + Ogre::Vector3& outLocal, Ogre::Vector3& outNormal) const; + + /// Allocate a new manual Ogre::Texture with current buffer + /// dimensions. When `rebindToModel` is true, also walk the + /// entity's materials and rebind diffuse TUSes pointing at the + /// original texture to the paint texture. When false (the default + /// for session create), the GPU texture is allocated but the + /// model's render is left alone until the first stroke + /// committedly modifies pixels. + bool createOgreTextureFromBuffer(Ogre::Entity* entity, + const QString& nameHint, + bool rebindToModel = false); + + /// Rebind diffuse TUSes pointing at the original-slot texture to + /// the paint texture. Records the original bindings in + /// `m_boundSlots` so closeSession() can restore them. Called + /// either eagerly (rebindToModel=true on session create) or + /// lazily (on the first stroke flush, so toggling the brush is + /// non-destructive). + void rebindEntityDiffuseToPaintTexture(Ogre::Entity* entity); + + /// Upload buffer.dirtyRect() into the live Ogre texture and clear it. + /// This is the public entry point and **debounces** to ~16 ms β€” the + /// actual GPU work happens in doFlushDirtyToOgre on a timer. + void flushDirtyToOgre(); + /// The synchronous GPU upload. Called from the debounce timer. + void doFlushDirtyToOgre(); + + /// Regenerate `m_previewUri` from the buffer (PNG, base64). Emits + /// previewChanged when the URI actually changed. + void refreshPreviewUri(); + + /// Regenerate `m_uvOverlayUri` by drawing every UV-mapped triangle + /// outline at the current texture resolution into a transparent PNG. + void refreshUvOverlay(); + + /// One-time deep-copy snapshot of pixel buffer for undo. + std::vector snapshotPixels() const; + + /// Apply the brush stamp at a UV coord using the current tool. + /// Returns true if any pixel changed. + bool applyBrushAtUV(const Ogre::Vector2& uv); + + /// Draw the hover ring on the mesh at a given local position + + /// normal. Shared between viewport-driven and panel-driven hover. + void drawHoverRingAt(const Ogre::Vector3& localPos, + const Ogre::Vector3& localNormal); + + /// Flood-fill connected pixels at UV with the brush color. + bool floodFillAtUV(const Ogre::Vector2& uv); + + /// Sample the buffer color at a UV and set it as the brush color. + void pickColorAtUV(const Ogre::Vector2& uv); + + bool m_paintEnabled = false; + TexturePaintBuffer m_buffer; + QString m_textureName; + Ogre::TexturePtr m_ogreTexture; + /// The original texture we're painting into. We blit our dirty + /// rects directly to its GPU buffer when possible, so the + /// existing material binding is untouched. Falls back to + /// `m_ogreTexture` (our manual paint texture) when the original + /// isn't writable. + Ogre::TexturePtr m_originalTexture; + QString m_originalTextureName; + bool m_useOriginalTexture = false; + bool m_loggedInPlaceBlit = false; + bool m_rebindScheduled = false; + /// Debounce flag for the GPU upload. We accumulate dirty pixels + /// in m_buffer and schedule a single blit per ~16ms instead of + /// blitting on every mouse-move (which hits 100+ Hz and uploads + /// 4 MB each time on macOS Metal). + bool m_gpuFlushScheduled = false; + Ogre::Entity* m_sessionEntity = nullptr; + + bool m_strokeActive = false; + bool m_strokeJustBegan = false; ///< Fill/picker tools fire only once per stroke. + std::vector m_strokePreSnapshot; // for undo + BrushTool m_tool = ToolPaint; + PaintTarget m_target = TargetVertex; + + /// Track every TUS we rebound to the paint texture so closeSession() + /// can restore the originals. We keep the *material name* (not a + /// raw pointer) so a destroyed/reloaded material doesn't dangle. + struct BoundSlot { + std::string materialName; + unsigned short techIdx = 0; + unsigned short passIdx = 0; + unsigned short tusIdx = 0; + std::string originalTexture; + }; + std::vector m_boundSlots; + + // Private EditableMesh β€” built from the active entity so painting + // doesn't depend on the user-facing Edit Mode workspace. + std::unique_ptr m_paintMesh; + Ogre::Entity* m_paintMeshEntity = nullptr; + + // Texture slot model. Populated from the selected entity's + // materials. The active slot drives which TUS the painter writes + // back to and which texture name the QML preview shows. + QVariantList m_slots; + int m_activeSlot = 0; + + // Smudge state: hold the previous stamp's pre-paint sample so the + // next stamp can copy it forward. + Ogre::Vector2 m_smudgePrev = Ogre::Vector2::ZERO; + bool m_smudgeHavePrev = false; + + // Brush-ring overlay on the 3D mesh surface β€” drawn at the + // current hover hit point so the user sees brush size in world + // units while painting. + Ogre::SceneNode* m_ringNode = nullptr; + Ogre::ManualObject* m_ringObj = nullptr; + + // Preview PNG cache. + QString m_previewUri; + /// Debounce flag: prevents stroke moves from regenerating the + /// base64 PNG on every dirty flush (expensive at 1024Γ—1024). + bool m_previewRefreshScheduled = false; + + // UV wireframe overlay (data: URI of a transparent PNG with the + // triangle outlines drawn at the texture resolution). + QString m_uvOverlayUri; + bool m_uvOverlayVisible = false; + + static TexturePaintController* s_instance; +}; + +#endif // TEXTUREPAINTCONTROLLER_H diff --git a/src/TexturePaintController_test.cpp b/src/TexturePaintController_test.cpp new file mode 100644 index 000000000..c91bb730a --- /dev/null +++ b/src/TexturePaintController_test.cpp @@ -0,0 +1,82 @@ +#include + +#include + +#include "Manager.h" +#include "SelectionSet.h" +#include "TestHelpers.h" +#include "TexturePaintBuffer.h" +#include "TexturePaintController.h" + +#include +#include +#include +#include + +// Reverse UVβ†’3D lookup: ask for UV (0,0) and verify we get the +// position of vertex 0 (which carries UV (0,0)) on the test triangle. +TEST(TexturePaintControllerTest, FindMeshPointForUVHitsCorrectTriangle) +{ + ASSERT_TRUE(tryInitOgre()); + auto* mgr = Manager::getSingleton(); + ASSERT_NE(mgr, nullptr); + auto* scene = mgr->getSceneMgr(); + ASSERT_NE(scene, nullptr); + auto mesh = createInMemoryTriangleMesh("TPC_FindMeshPointForUV"); + auto* entity = scene->createEntity("TPC_TestEntity", mesh->getName()); + auto* node = scene->getRootSceneNode()->createChildSceneNode(); + node->attachObject(entity); + + SelectionSet::getSingleton()->clear(); + SelectionSet::getSingleton()->append(entity); + + auto* ctrl = TexturePaintController::instance(); + ctrl->refreshSlots(); + // Need a paint buffer so `m_paintMesh` is built. + ASSERT_TRUE(ctrl->ensurePaintableTexture(64)); + + Ogre::Vector3 pos, normal; + EXPECT_TRUE(ctrl->findMeshPointForUV(Ogre::Vector2(0.0f, 0.0f), pos, normal)); + EXPECT_NEAR(pos.x, 0.0f, 1e-4); + EXPECT_NEAR(pos.y, 0.0f, 1e-4); + + EXPECT_TRUE(ctrl->findMeshPointForUV(Ogre::Vector2(1.0f, 0.0f), pos, normal)); + EXPECT_NEAR(pos.x, 1.0f, 1e-4); + EXPECT_NEAR(pos.y, 0.0f, 1e-4); + + EXPECT_TRUE(ctrl->findMeshPointForUV(Ogre::Vector2(0.0f, 1.0f), pos, normal)); + EXPECT_NEAR(pos.x, 0.0f, 1e-4); + EXPECT_NEAR(pos.y, 1.0f, 1e-4); + + // Outside the triangle in UV space β†’ no hit. + EXPECT_FALSE(ctrl->findMeshPointForUV(Ogre::Vector2(0.9f, 0.9f), pos, normal)); + + ctrl->closeSession(); + SelectionSet::getSingleton()->clear(); + scene->getRootSceneNode()->removeAndDestroyChild(node); + scene->destroyEntity(entity); + Ogre::MeshManager::getSingleton().remove(mesh); +} + +TEST(TexturePaintControllerTest, BrushToolDefaultIsPaint) +{ + auto* ctrl = TexturePaintController::instance(); + // Reset to a known value via the public path so test order doesn't + // matter. + ctrl->setBrushTool(TexturePaintController::ToolPaint); + EXPECT_EQ(ctrl->brushTool(), static_cast(TexturePaintController::ToolPaint)); +} + +TEST(TexturePaintControllerTest, SetBrushToolEmitsOnceAndSticks) +{ + auto* ctrl = TexturePaintController::instance(); + ctrl->setBrushTool(TexturePaintController::ToolPaint); + QSignalSpy spy(ctrl, &TexturePaintController::brushToolChanged); + ctrl->setBrushTool(TexturePaintController::ToolErase); + EXPECT_EQ(ctrl->brushTool(), static_cast(TexturePaintController::ToolErase)); + EXPECT_EQ(spy.count(), 1); + // Same value should not re-emit. + ctrl->setBrushTool(TexturePaintController::ToolErase); + EXPECT_EQ(spy.count(), 1); + ctrl->setBrushTool(TexturePaintController::ToolPaint); +} diff --git a/src/TransformOperator.cpp b/src/TransformOperator.cpp index aca60dc08..bc68bbe04 100755 --- a/src/TransformOperator.cpp +++ b/src/TransformOperator.cpp @@ -25,6 +25,7 @@ #include "commands/BoneTransformCommand.h" #include "BoneDragRelease.h" #include "EditModeController.h" +#include "TexturePaintController.h" #include "AnimationControlController.h" #include "PropertiesPanelController.h" #include "SkeletonDebug.h" @@ -104,6 +105,10 @@ TransformOperator::TransformOperator() : QObject(nullptr) this, &TransformOperator::onSelectionChanged); connect(EditModeController::instance(), &EditModeController::vertexPaintChanged, this, &TransformOperator::onSelectionChanged); + // Texture paint toggle also affects mouse-tracking (we need + // hover events without a button press) and gizmo visibility. + connect(TexturePaintController::instance(), &TexturePaintController::texturePaintChanged, + this, &TransformOperator::onSelectionChanged); QtInputManager::getInstance().AddMouseListener(this); @@ -579,8 +584,14 @@ void TransformOperator::updateGizmo() m_pRotationGizmo->setVisible(false); m_pTranslationGizmo->setVisible(false); m_pScaleGizmo->setVisible(false); - mTrackingEnable = EditModeController::instance()->isEditModeActive() - && EditModeController::instance()->vertexPaintEnabled(); + // Enable mouse tracking when ANY paint mode is on so + // the hover preview / brush ring updates without a + // pressed button. Vertex paint requires Edit Mode; + // texture paint works in any mode. + mTrackingEnable = + (EditModeController::instance()->isEditModeActive() + && EditModeController::instance()->vertexPaintEnabled()) + || TexturePaintController::instance()->texturePaintEnabled(); break; case TransformOperator::TS_TRANSLATE: m_pTransformNode->setOrientation(gizmoOrientation); @@ -616,8 +627,23 @@ void TransformOperator::updateGizmo() m_pTranslationGizmo->setVisible(false); m_pScaleGizmo->setVisible(false); } - if(m_pActiveWidget) + if(m_pActiveWidget) { m_pActiveWidget->setMouseTracking(mTrackingEnable); + // Crosshair cursor while any paint mode is on so the user + // gets clear feedback that clicks will paint, not select. + // Only override the cursor in TS_SELECT β€” in Translate/Rotate/ + // Scale the gizmo is the active interaction and the user + // should see the default cursor over its handles. + if (mTransformState == TS_SELECT) { + const bool paintOn = + (EditModeController::instance()->isEditModeActive() + && EditModeController::instance()->vertexPaintEnabled()) + || TexturePaintController::instance()->texturePaintEnabled(); + m_pActiveWidget->setCursor(paintOn ? Qt::CrossCursor : Qt::ArrowCursor); + } else { + m_pActiveWidget->setCursor(Qt::ArrowCursor); + } + } } void TransformOperator::tickTransformGizmoScale(const Ogre::Camera* camera) @@ -1001,6 +1027,21 @@ void TransformOperator::mousePressEvent(QMouseEvent *e) return; } + // Texture paint takes priority over selection/box pick: it works + // in Material Mode without entering Edit Mode, so we handle it + // before the Edit-Mode-gated branch below. + { + auto* texPaint = TexturePaintController::instance(); + if (texPaint->texturePaintEnabled() && mTransformState == TS_SELECT) { + if (texPaint->beginStroke(m_pActiveWidget, e->pos())) { + mTexturePaintDragActive = true; + SentryReporter::addBreadcrumb("ui.action", "Texture paint: stroke begin"); + return; + } + // Cursor missed the mesh β€” fall through to normal selection. + } + } + // In edit mode, delegate selection to EditModeController if (EditModeController::instance()->isEditModeActive() && mTransformState == TS_SELECT) { @@ -1266,6 +1307,18 @@ void TransformOperator::mouseMoveEvent(QMouseEvent *e) editCtrl->updateVertexPaintPreview(m_pActiveWidget, e->pos()); } + // Texture paint: while LMB held, update stroke; otherwise update + // the hover preview so the user sees the brush ring even before + // clicking. Decoupled from Edit Mode β€” texture paint owns its own + // EditableMesh now. + auto* texPaint = TexturePaintController::instance(); + if (mTexturePaintDragActive && (e->buttons() & Qt::LeftButton) && m_pActiveWidget) + { + texPaint->updateStroke(m_pActiveWidget, e->pos()); + } else if (texPaint->texturePaintEnabled() && m_pActiveWidget) { + texPaint->updateMeshHover(m_pActiveWidget, e->pos()); + } + // Knife hover preview: cheap to update on every move while the session // is active, and draws the ghost segment from the last confirmed // point to the cursor. Does not consume the event β€” other handlers @@ -1791,6 +1844,13 @@ void TransformOperator::mouseReleaseEvent(QMouseEvent *e) return; } + if (mTexturePaintDragActive && e->button() == Qt::LeftButton) { + TexturePaintController::instance()->endStroke(); + mTexturePaintDragActive = false; + SentryReporter::addBreadcrumb("ui.action", "Texture paint: stroke end"); + return; + } + // End bevel drag but keep the session open (user can re-grab or click // elsewhere to commit). if (mBevelDragActive && e->button() == Qt::LeftButton) { diff --git a/src/TransformOperator.h b/src/TransformOperator.h index e2b932776..94a4b8a31 100755 --- a/src/TransformOperator.h +++ b/src/TransformOperator.h @@ -221,6 +221,7 @@ public slots: // Vertex paint drag state (edit mode only). bool mVertexPaintDragActive = false; + bool mTexturePaintDragActive = false; // Bone-gizmo drag state. Active when a bone is the current selection // (per AnimationControlController::selectedBonePtr) and the user diff --git a/src/VertexColorBaker.cpp b/src/VertexColorBaker.cpp new file mode 100644 index 000000000..75804dc9f --- /dev/null +++ b/src/VertexColorBaker.cpp @@ -0,0 +1,181 @@ +#include "VertexColorBaker.h" + +#include +#include + +namespace { + +inline float edgeFn(const Ogre::Vector2& a, const Ogre::Vector2& b, const Ogre::Vector2& c) +{ + return (c.x - a.x) * (b.y - a.y) - (c.y - a.y) * (b.x - a.x); +} + +} // namespace + +int VertexColorBaker::rasterizeTriangle(TexturePaintBuffer& buffer, + const Ogre::Vector2& uv0, + const Ogre::Vector2& uv1, + const Ogre::Vector2& uv2, + const Ogre::ColourValue& c0, + const Ogre::ColourValue& c1, + const Ogre::ColourValue& c2, + std::vector* outCoverage) +{ + const int W = buffer.width(); + const int H = buffer.height(); + if (W <= 0 || H <= 0) return 0; + if (outCoverage && static_cast(outCoverage->size()) != W * H) + outCoverage = nullptr; + + // UV origin = top-left (matches TexturePaintBuffer::uvToPixel). + auto toPix = [&](const Ogre::Vector2& uv) { + return Ogre::Vector2(uv.x * W, uv.y * H); + }; + + const Ogre::Vector2 p0 = toPix(uv0); + const Ogre::Vector2 p1 = toPix(uv1); + const Ogre::Vector2 p2 = toPix(uv2); + + const float minX = std::min({p0.x, p1.x, p2.x}); + const float maxX = std::max({p0.x, p1.x, p2.x}); + const float minY = std::min({p0.y, p1.y, p2.y}); + const float maxY = std::max({p0.y, p1.y, p2.y}); + + int x0 = std::max(0, static_cast(std::floor(minX))); + int x1 = std::min(W, static_cast(std::ceil(maxX))); + int y0 = std::max(0, static_cast(std::floor(minY))); + int y1 = std::min(H, static_cast(std::ceil(maxY))); + if (x0 >= x1 || y0 >= y1) return 0; + + const float area = edgeFn(p0, p1, p2); + if (std::abs(area) < 1e-7f) return 0; + const float invArea = 1.0f / area; + const bool flip = area < 0.0f; + + int painted = 0; + for (int y = y0; y < y1; ++y) { + for (int x = x0; x < x1; ++x) { + const Ogre::Vector2 sample(static_cast(x) + 0.5f, static_cast(y) + 0.5f); + float w0 = edgeFn(p1, p2, sample); + float w1 = edgeFn(p2, p0, sample); + float w2 = edgeFn(p0, p1, sample); + if (flip) { w0 = -w0; w1 = -w1; w2 = -w2; } + if (w0 < 0.0f || w1 < 0.0f || w2 < 0.0f) continue; + const float b0 = w0 * (flip ? -invArea : invArea); + const float b1 = w1 * (flip ? -invArea : invArea); + const float b2 = 1.0f - b0 - b1; + const Ogre::ColourValue color( + c0.r * b0 + c1.r * b1 + c2.r * b2, + c0.g * b0 + c1.g * b1 + c2.g * b2, + c0.b * b0 + c1.b * b1 + c2.b * b2, + c0.a * b0 + c1.a * b1 + c2.a * b2); + buffer.setPixel(x, y, color); + if (outCoverage) + (*outCoverage)[static_cast(y) * W + x] = 1; + ++painted; + } + } + return painted; +} + +int VertexColorBaker::dilate(TexturePaintBuffer& buffer, + std::vector& coverage, + int iterations) +{ + const int W = buffer.width(); + const int H = buffer.height(); + if (W <= 0 || H <= 0 || iterations <= 0) return 0; + if (static_cast(coverage.size()) != W * H) return 0; + + int totalFlipped = 0; + std::vector nextCov(coverage); + auto& pixels = buffer.data(); + std::vector nextPixels(pixels); + + for (int it = 0; it < iterations; ++it) { + int flippedThisPass = 0; + for (int y = 0; y < H; ++y) { + for (int x = 0; x < W; ++x) { + const int idx = y * W + x; + if (coverage[idx]) continue; + // Find first filled neighbor. + for (int dy = -1; dy <= 1; ++dy) { + for (int dx = -1; dx <= 1; ++dx) { + if (dx == 0 && dy == 0) continue; + const int nx = x + dx; + const int ny = y + dy; + if (nx < 0 || ny < 0 || nx >= W || ny >= H) continue; + const int nidx = ny * W + nx; + if (!coverage[nidx]) continue; + const size_t srcOff = static_cast(nidx) * 4u; + const size_t dstOff = static_cast(idx) * 4u; + nextPixels[dstOff + 0] = pixels[srcOff + 0]; + nextPixels[dstOff + 1] = pixels[srcOff + 1]; + nextPixels[dstOff + 2] = pixels[srcOff + 2]; + nextPixels[dstOff + 3] = pixels[srcOff + 3]; + nextCov[idx] = 1; + ++flippedThisPass; + dx = 2; // break inner loops + dy = 2; + } + } + } + } + if (flippedThisPass == 0) break; + pixels = nextPixels; + coverage = nextCov; + totalFlipped += flippedThisPass; + } + + if (totalFlipped > 0) + buffer.markDirty(0, 0, W, H); + return totalFlipped; +} + +int VertexColorBaker::bake(const EditableMesh& mesh, TexturePaintBuffer& buffer) +{ + return bake(mesh, buffer, Options{}); +} + +int VertexColorBaker::bake(const EditableMesh& mesh, + TexturePaintBuffer& buffer, + const Options& options) +{ + const int res = std::max(1, options.resolution); + buffer.resize(res, res); + buffer.clear(options.background); + + const auto& subs = mesh.subMeshes(); + if (subs.empty()) return 0; + + std::vector coverage(static_cast(res) * static_cast(res), 0); + + int totalPainted = 0; + for (const auto& sub : subs) { + for (const auto& tri : sub.triangles) { + if (tri.indices[0] >= sub.vertices.size() || + tri.indices[1] >= sub.vertices.size() || + tri.indices[2] >= sub.vertices.size()) + continue; + const auto& v0 = sub.vertices[tri.indices[0]]; + const auto& v1 = sub.vertices[tri.indices[1]]; + const auto& v2 = sub.vertices[tri.indices[2]]; + if (!v0.hasUV || !v1.hasUV || !v2.hasUV) + continue; + Ogre::ColourValue c0 = v0.hasColor ? v0.color : Ogre::ColourValue::White; + Ogre::ColourValue c1 = v1.hasColor ? v1.color : Ogre::ColourValue::White; + Ogre::ColourValue c2 = v2.hasColor ? v2.color : Ogre::ColourValue::White; + totalPainted += rasterizeTriangle(buffer, v0.uv, v1.uv, v2.uv, + c0, c1, c2, &coverage); + } + } + + // Coverage is built directly by the rasterizer as it writes each + // pixel β€” the old "differs from background" inference silently + // dropped triangles whose interpolated color equalled the + // background (e.g. vertex-colors==white on a white background). + if (totalPainted > 0) + dilate(buffer, coverage, options.dilationPixels); + + return totalPainted; +} diff --git a/src/VertexColorBaker.h b/src/VertexColorBaker.h new file mode 100644 index 000000000..28ae875b5 --- /dev/null +++ b/src/VertexColorBaker.h @@ -0,0 +1,90 @@ +#ifndef VERTEXCOLORBAKER_H +#define VERTEXCOLORBAKER_H + +#include "EditableMesh.h" +#include "TexturePaintBuffer.h" + +#include +#include +#include + +/** + * @brief Bake EditableMesh vertex colors into a TexturePaintBuffer. + * + * For each triangle, the bake walks UV-space pixels covered by the + * triangle, computes per-pixel barycentric coordinates from the UV + * verts, and writes the barycentric-interpolated vertex color into the + * buffer. The seam-dilation pass then "smears" the rasterized colors + * outward by N pixels to mask UV-island bleed at MIP-map time. + * + * Pure data β€” no Ogre runtime state beyond the math types on + * EditableMesh / TexturePaintBuffer. + */ +class VertexColorBaker +{ +public: + struct Options { + /// Output texture size (square). Buffer will be resized to this. + int resolution = 1024; + /// Pixels of edge-dilation applied after rasterization (0 = none). + /// Each iteration extends rasterized pixels outward by 1 px using + /// 8-neighbor majority sampling. + int dilationPixels = 4; + /// Background color for unrasterized pixels (alpha 0 means + /// transparent β€” but PNG savers will store this exactly, so + /// downstream consumers see a clear seam-mask). + Ogre::ColourValue background = Ogre::ColourValue(1.0f, 1.0f, 1.0f, 0.0f); + }; + + /** + * @brief Bake `mesh` into `outBuffer`. + * + * If `mesh` has no vertex colors and no submeshes are visited, the + * buffer is still resized and cleared to the background color. + * + * @return number of pixels written by rasterization (before dilation). + */ + static int bake(const EditableMesh& mesh, + TexturePaintBuffer& outBuffer, + const Options& options); + + /// Convenience: bake with default options. + static int bake(const EditableMesh& mesh, TexturePaintBuffer& outBuffer); + + /// Standalone rasterizer for a single triangle in UV space. + /// `uv0..uv2` are in [0..1]^2; `c0..c2` are colors at the verts. + /// Updates `outBuffer` and returns the number of pixels written. + /// + /// If `outCoverage` is non-null it must have size `W*H` (row-major, + /// same as `outBuffer.data()` indexing). Every pixel actually written + /// by the rasterizer is set to 1 in `outCoverage`. This is the + /// authoritative coverage mask β€” preferred over inferring coverage + /// from "differs from background" since a triangle that happens to + /// rasterize the background color (e.g. white vertex colors over a + /// white background) would otherwise be invisible to dilation. + static int rasterizeTriangle(TexturePaintBuffer& outBuffer, + const Ogre::Vector2& uv0, + const Ogre::Vector2& uv1, + const Ogre::Vector2& uv2, + const Ogre::ColourValue& c0, + const Ogre::ColourValue& c1, + const Ogre::ColourValue& c2, + std::vector* outCoverage = nullptr); + + /** + * @brief Dilate rasterized pixels outward by `iterations` pixels. + * + * Reads `coverage` (true = pixel was filled by rasterization, false + * = background), then for every false pixel adjacent to a true + * pixel, copies the first non-background neighbor's color and flips + * coverage. Repeats `iterations` times. + * + * @return number of pixels flipped from background to filled across + * all iterations. + */ + static int dilate(TexturePaintBuffer& buffer, + std::vector& coverage, + int iterations); +}; + +#endif // VERTEXCOLORBAKER_H diff --git a/src/VertexColorBaker_test.cpp b/src/VertexColorBaker_test.cpp new file mode 100644 index 000000000..2ca257103 --- /dev/null +++ b/src/VertexColorBaker_test.cpp @@ -0,0 +1,203 @@ +#include + +#include "EditableMesh.h" +#include "TexturePaintBuffer.h" +#include "VertexColorBaker.h" + +namespace { + +EditableMesh makeUnitTriangleMesh(const Ogre::ColourValue& c0, + const Ogre::ColourValue& c1, + const Ogre::ColourValue& c2) +{ + EditableMesh mesh; + EditableSubMesh sub; + EditableVertex v0; v0.position = {0, 0, 0}; v0.uv = {0.0f, 0.0f}; v0.color = c0; + v0.hasUV = true; v0.hasColor = true; + EditableVertex v1; v1.position = {1, 0, 0}; v1.uv = {1.0f, 0.0f}; v1.color = c1; + v1.hasUV = true; v1.hasColor = true; + EditableVertex v2; v2.position = {0, 1, 0}; v2.uv = {0.0f, 1.0f}; v2.color = c2; + v2.hasUV = true; v2.hasColor = true; + sub.vertices = { v0, v1, v2 }; + EditableTriangle tri; tri.indices[0] = 0; tri.indices[1] = 1; tri.indices[2] = 2; + sub.triangles = { tri }; + mesh.subMeshes() = { sub }; + return mesh; +} + +} // namespace + +TEST(VertexColorBakerTest, RasterizeFlatTriangleSolidColor) +{ + TexturePaintBuffer buf(64, 64); + buf.clear(Ogre::ColourValue(0, 0, 0, 0)); // transparent black bg + const int painted = VertexColorBaker::rasterizeTriangle( + buf, + Ogre::Vector2(0.0f, 0.0f), + Ogre::Vector2(1.0f, 0.0f), + Ogre::Vector2(0.0f, 1.0f), + Ogre::ColourValue::Red, Ogre::ColourValue::Red, Ogre::ColourValue::Red); + EXPECT_GT(painted, 1000); // ~half of 64*64 ~= 2048 + // Pixel near vertex 0 (uv 0,0 β†’ top-left pixel (0,0)) should be red. + const auto p0 = buf.pixel(2, 2); + EXPECT_NEAR(p0.r, 1.0f, 0.05f); + EXPECT_NEAR(p0.g, 0.0f, 0.05f); + // Pixel inside the triangle, near center. + const auto pc = buf.pixel(10, 10); + EXPECT_NEAR(pc.r, 1.0f, 0.05f); +} + +TEST(VertexColorBakerTest, RasterizeBarycentricInterpolatesColors) +{ + TexturePaintBuffer buf(128, 128); + buf.clear(Ogre::ColourValue(0, 0, 0, 0)); + VertexColorBaker::rasterizeTriangle( + buf, + Ogre::Vector2(0.0f, 0.0f), + Ogre::Vector2(1.0f, 0.0f), + Ogre::Vector2(0.0f, 1.0f), + Ogre::ColourValue(1.0f, 0.0f, 0.0f, 1.0f), // red at (0,0) + Ogre::ColourValue(0.0f, 1.0f, 0.0f, 1.0f), // green at (1,0) + Ogre::ColourValue(0.0f, 0.0f, 1.0f, 1.0f)); // blue at (0,1) + + // Pixel near v0 (uv 0,0 β†’ pixel (0,0) since UV origin is top-left). + // Use small offsets to stay inside the triangle. + int x0=0, y0=0; buf.uvToPixel(Ogre::Vector2(0.02f, 0.02f), x0, y0); + const auto cNearV0 = buf.pixel(x0, y0); + EXPECT_GT(cNearV0.r, 0.85f); + EXPECT_LT(cNearV0.g, 0.15f); + EXPECT_LT(cNearV0.b, 0.15f); + + // Pixel near v1 (uv 1,0). + int x1=0, y1=0; buf.uvToPixel(Ogre::Vector2(0.95f, 0.02f), x1, y1); + const auto cNearV1 = buf.pixel(x1, y1); + EXPECT_LT(cNearV1.r, 0.15f); + EXPECT_GT(cNearV1.g, 0.80f); + EXPECT_LT(cNearV1.b, 0.15f); + + // Pixel near v2 (uv 0,1). + int x2=0, y2=0; buf.uvToPixel(Ogre::Vector2(0.02f, 0.95f), x2, y2); + const auto cNearV2 = buf.pixel(x2, y2); + EXPECT_LT(cNearV2.r, 0.15f); + EXPECT_LT(cNearV2.g, 0.15f); + EXPECT_GT(cNearV2.b, 0.80f); +} + +TEST(VertexColorBakerTest, RasterizeDegenerateTriangleIsNoop) +{ + TexturePaintBuffer buf(32, 32); + buf.clear(Ogre::ColourValue(0, 0, 0, 0)); + // Collinear points (uv-space line, not a triangle). + const int painted = VertexColorBaker::rasterizeTriangle( + buf, + Ogre::Vector2(0.0f, 0.0f), + Ogre::Vector2(0.5f, 0.5f), + Ogre::Vector2(1.0f, 1.0f), + Ogre::ColourValue::Red, + Ogre::ColourValue::Green, + Ogre::ColourValue::Blue); + EXPECT_EQ(painted, 0); +} + +TEST(VertexColorBakerTest, RasterizeFlippedWindingStillCoversTriangle) +{ + TexturePaintBuffer buf(64, 64); + buf.clear(Ogre::ColourValue(0, 0, 0, 0)); + // CCW vs CW: the rasterizer should handle both consistently. + const int paintedCcw = VertexColorBaker::rasterizeTriangle( + buf, + Ogre::Vector2(0.0f, 0.0f), + Ogre::Vector2(1.0f, 0.0f), + Ogre::Vector2(0.0f, 1.0f), + Ogre::ColourValue::Red, Ogre::ColourValue::Red, Ogre::ColourValue::Red); + EXPECT_GT(paintedCcw, 100); + + TexturePaintBuffer buf2(64, 64); + buf2.clear(Ogre::ColourValue(0, 0, 0, 0)); + // Same triangle, swapped winding. + const int paintedCw = VertexColorBaker::rasterizeTriangle( + buf2, + Ogre::Vector2(0.0f, 0.0f), + Ogre::Vector2(0.0f, 1.0f), + Ogre::Vector2(1.0f, 0.0f), + Ogre::ColourValue::Red, Ogre::ColourValue::Red, Ogre::ColourValue::Red); + // Should cover the same number of pixels (within rounding). + EXPECT_NEAR(paintedCw, paintedCcw, paintedCcw / 10); +} + +TEST(VertexColorBakerTest, BakeProducesNonEmptyOutput) +{ + EditableMesh mesh = makeUnitTriangleMesh( + Ogre::ColourValue::Red, + Ogre::ColourValue::Green, + Ogre::ColourValue::Blue); + TexturePaintBuffer buf; + VertexColorBaker::Options opts; + opts.resolution = 128; + opts.dilationPixels = 0; + const int painted = VertexColorBaker::bake(mesh, buf, opts); + EXPECT_GT(painted, 1000); + EXPECT_EQ(buf.width(), 128); + EXPECT_EQ(buf.height(), 128); +} + +TEST(VertexColorBakerTest, BakeDefaultsToWhiteBackground) +{ + EditableMesh mesh; // empty mesh + TexturePaintBuffer buf; + VertexColorBaker::Options opts; + opts.resolution = 64; + opts.background = Ogre::ColourValue(0.25f, 0.25f, 0.25f, 1.0f); + const int painted = VertexColorBaker::bake(mesh, buf, opts); + EXPECT_EQ(painted, 0); + EXPECT_EQ(buf.width(), 64); + // All pixels should be the background color. + const auto p = buf.pixel(0, 0); + EXPECT_NEAR(p.r, 0.25f, 0.02f); + EXPECT_NEAR(p.g, 0.25f, 0.02f); +} + +TEST(VertexColorBakerTest, DilationExpandsRasterizedRegion) +{ + EditableMesh mesh = makeUnitTriangleMesh( + Ogre::ColourValue::Red, + Ogre::ColourValue::Red, + Ogre::ColourValue::Red); + + TexturePaintBuffer bufNoDilate; + VertexColorBaker::Options optsNo; + optsNo.resolution = 128; + optsNo.dilationPixels = 0; + optsNo.background = Ogre::ColourValue(0, 0, 0, 0); + const int paintedNo = VertexColorBaker::bake(mesh, bufNoDilate, optsNo); + + TexturePaintBuffer bufDilate; + VertexColorBaker::Options optsYes = optsNo; + optsYes.dilationPixels = 6; + const int paintedYes = VertexColorBaker::bake(mesh, bufDilate, optsYes); + + // Count red pixels in each. + auto countRed = [](const TexturePaintBuffer& b) { + int n = 0; + for (int y = 0; y < b.height(); ++y) + for (int x = 0; x < b.width(); ++x) + if (b.pixel(x, y).r > 0.5f) ++n; + return n; + }; + const int nNo = countRed(bufNoDilate); + const int nYes = countRed(bufDilate); + EXPECT_GT(nYes, nNo) << "Dilation must expand the rasterized region"; + EXPECT_EQ(paintedNo, paintedYes) << "Rasterized count (before dilation) should match"; +} + +TEST(VertexColorBakerTest, BakeConvenienceOverloadUsesDefaults) +{ + EditableMesh mesh = makeUnitTriangleMesh( + Ogre::ColourValue::White, + Ogre::ColourValue::White, + Ogre::ColourValue::White); + TexturePaintBuffer buf; + const int painted = VertexColorBaker::bake(mesh, buf); + EXPECT_GT(painted, 100000); // 1024x1024 default has ~half-million rasterized pixels for this tri + EXPECT_EQ(buf.width(), 1024); +} diff --git a/src/main.cpp b/src/main.cpp index efe205ae3..82ddb0aa1 100755 --- a/src/main.cpp +++ b/src/main.cpp @@ -91,7 +91,7 @@ int main(int argc, char *argv[]) || arg == "normal-from-height" || arg == "memory" || arg == "analyze" || arg == "vertex-cache" || arg == "decimate" || arg == "atlas" || arg == "atlas-apply" - || arg == "optimize") + || arg == "optimize" || arg == "bake-vertex-colors") cliMode = true; break; // first non-flag arg determines mode } diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp index 46ab056ad..f71b29654 100755 --- a/src/mainwindow.cpp +++ b/src/mainwindow.cpp @@ -71,6 +71,7 @@ #include "WelcomeScreenController.h" #include "AssetBrowserController.h" #include "EditModeController.h" +#include "TexturePaintController.h" #include "EditorModeController.h" #include #include @@ -328,6 +329,10 @@ MainWindow::~MainWindow() EditorModeController::kill(); Manager* manager = Manager::getSingletonPtr(); if (manager) { + // Paint controller holds an EditableMesh + ring overlay objects + // owned by the SceneManager. Kill it before Manager teardown + // so its destructor runs against a live Ogre. + TexturePaintController::kill(); EditModeController::kill(); SubEntityHighlight::kill(); AnimationBlender::kill(); @@ -531,6 +536,10 @@ void MainWindow::initToolBar() [](QQmlEngine* engine, QJSEngine*) -> QObject* { return EditModeController::qmlInstance(engine, nullptr); }); + qmlRegisterSingletonType("PropertiesPanel", 1, 0, "TexturePaintController", + [](QQmlEngine* engine, QJSEngine*) -> QObject* { + return TexturePaintController::qmlInstance(engine, nullptr); + }); m_propertiesPanel->setSource(QUrl("qrc:/PropertiesPanel/PropertiesPanel.qml")); if (auto* root = m_propertiesPanel->rootObject()) { @@ -1229,7 +1238,8 @@ void MainWindow::initToolBar() vertexPaintButton->setCheckable(true); vertexPaintButton->setIcon(makeVertexPaintBrushIcon()); vertexPaintButton->setIconSize(QSize(18, 18)); - vertexPaintButton->setToolTip(tr("Vertex paint β€” paint on mesh (Select tool). Arrow: brush settings.")); + vertexPaintButton->setToolTip(tr("Paint brush β€” toggles vertex + texture paint together " + "(Material Mode). Arrow: brush settings.")); vertexPaintButton->setFont(topoFont); vertexPaintButton->setStyleSheet(topoBtnStyle); vertexPaintButton->setPopupMode(QToolButton::MenuButtonPopup); @@ -1356,18 +1366,54 @@ void MainWindow::initToolBar() connect(vertexPaintButton, &QToolButton::toggled, this, [this](bool on) { SentryReporter::addBreadcrumb("ui.action", - QStringLiteral("Toolbar: Vertex paint %1").arg(on ? QStringLiteral("on") : QStringLiteral("off"))); - EditModeController::instance()->setVertexPaintEnabled(on); + QStringLiteral("Toolbar: Paint brush %1").arg(on ? "on" : "off")); + // All painting goes through TexturePaintController. The + // controller's "paint target" enum picks between texture + // paint and vertex paint per stroke. + TexturePaintController::instance()->setTexturePaintEnabled(on); if (on) setTransformState(TransformOperator::TS_SELECT); }); - connect(EditModeController::instance(), &EditModeController::vertexPaintChanged, this, [vertexPaintButton]() { + auto syncPaintBtnChecked = [vertexPaintButton]() { + const bool on = TexturePaintController::instance()->texturePaintEnabled(); QSignalBlocker b(vertexPaintButton); - vertexPaintButton->setChecked(EditModeController::instance()->vertexPaintEnabled()); - }); + vertexPaintButton->setChecked(on); + }; + connect(TexturePaintController::instance(), &TexturePaintController::texturePaintChanged, + this, syncPaintBtnChecked); QAction* vertexPaintAction = ui->objectsToolbar->addWidget(vertexPaintButton); - vertexPaintAction->setObjectName("modeEditVertexPaintAction"); + vertexPaintAction->setObjectName("modeMaterialPaintBrushAction"); + + // The paint brush is contextual: + // - Material Mode β†’ texture paint (paint into the BaseColor) + // - Edit Mode β†’ vertex paint (paint vertex colors) + // - Other modes β†’ hidden + // Switching modes turns the previous mode's brush off so we don't + // leave a stale checked state. + auto refreshPaintBrushVisibility = [vertexPaintButton, vertexPaintAction]() { + const auto mode = EditorModeController::instance()->currentMode(); + const bool material = mode == EditorModeController::MaterialMode; + // Paint brush lives in Material Mode only. The user chooses + // between Vertex and Texture painting via the panel's target + // picker; both run through TexturePaintController. + vertexPaintAction->setVisible(material); + vertexPaintButton->setEnabled(material); + // Disable both brushes AND reset the button's checked state on + // mode change. Without resetting the checked flag, the user's + // next click toggles "onβ†’off" (since we silently set the + // controllers off but left the button visually checked). + EditModeController::instance()->setVertexPaintEnabled(false); + TexturePaintController::instance()->setTexturePaintEnabled(false); + SentryReporter::addBreadcrumb( + "ui.action", + QStringLiteral("Mode switch: paint state reset")); + QSignalBlocker b(vertexPaintButton); + vertexPaintButton->setChecked(false); + }; + refreshPaintBrushVisibility(); + connect(EditorModeController::instance(), &EditorModeController::modeChanged, + this, refreshPaintBrushVisibility); // Context-aware visibility + enabled: // - Hidden entirely when NOT in edit mode. @@ -1376,10 +1422,8 @@ void MainWindow::initToolBar() // non-empty selection. auto refreshTopoButtons = [extrudeButton, bevelButton, knifeButton, mergeButton, deleteButton, subdivideButton, fillButton, loopCutButton, convertToQuadsButton, - vertexPaintButton, extrudeAction, bevelAction, knifeAction, mergeAction, deleteAction, - subdivideAction, fillAction, loopCutAction, convertToQuadsAction, - vertexPaintAction]() { + subdivideAction, fillAction, loopCutAction, convertToQuadsAction]() { auto* c = EditModeController::instance(); const bool active = c->isEditModeActive(); extrudeAction->setVisible(active); @@ -1391,7 +1435,6 @@ void MainWindow::initToolBar() fillAction->setVisible(active); loopCutAction->setVisible(active); convertToQuadsAction->setVisible(active); - vertexPaintAction->setVisible(active); if (!active) return; const int mode = c->selectionMode(); // 0 vertex, 1 edge, 2 face const bool hasFaces = c->selectedFaceCount() > 0; @@ -1429,7 +1472,6 @@ void MainWindow::initToolBar() // some quad) still qualify β€” the tri-only submeshes can still // be merged. (CodeRabbit follow-up on PR #347.) convertToQuadsButton->setEnabled(c->canConvertToQuads()); - vertexPaintButton->setEnabled(true); }; refreshTopoButtons(); connect(editCtrlForTopo, &EditModeController::editModeChanged, diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 78b3729b8..336b6b554 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -88,6 +88,9 @@ if(BUILD_TESTS) ${CMAKE_CURRENT_SOURCE_DIR}/../src/MaterialPresetLibrary.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../src/TextureChannelPacker.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../src/TextureAtlasPacker.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/../src/TexturePaintBuffer.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/../src/TexturePaintController.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/../src/VertexColorBaker.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../src/ApplyAtlas.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../src/EmbeddedTextureCache.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../src/NormalMapGenerator.cpp diff --git a/website/src/data/content.js b/website/src/data/content.js index d98cac696..b472e40a4 100644 --- a/website/src/data/content.js +++ b/website/src/data/content.js @@ -187,6 +187,10 @@ export const highlightFeatures = [ { title: 'MCP / AI agent integration', body: 'Expose pipeline tools through MCP for scripted and agent-driven workflows.' + }, + { + title: 'Paint tools', + body: 'Vertex-color polypaint and BaseColor texture painting on the mesh surface, plus a bake step that writes vertex colors into a UV-space PNG with seam dilation.' } ];