Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ qtmesh turntable model.fbx -o frame_%02d.png --frames 24 --axis y --camera-heigh
qtmesh isometric model.fbx -o iso.png # 8-direction static sprite grid (rows=directions)
qtmesh isometric model.fbx --resolution 256 -o iso.png # square 256px cells
qtmesh isometric model.fbx --animation "Walk" --frames 8 -o iso.png # 8×8 animated atlas
qtmesh isometric model.fbx -o iso.png --elevation 35 # camera angle in degrees (default 30)
qtmesh isometric model.fbx -o iso.png --padding 1.5 # zoom out (auto-fit × 1.5)
qtmesh isometric model.fbx -o iso.png --camera-distance 5 # fixed orbit distance
qtmesh validate model.fbx # validate mesh (exit 1 if errors found)
Expand Down Expand Up @@ -249,7 +250,7 @@ Three singletons manage core state. All run on the main thread. Access via `Clas
- **QuadRetopo** (`src/QuadRetopo.h/cpp`, issue #401): triangle-pairing quad-dominant retopology. The issue proposed wrapping Instant Meshes (Wenzel Jakob), but Instant Meshes ships as a research GUI app with no clean C++ library API and has been dormant since 2016. QuadriFlow (the production-grade alternative used by Blender 3.0+) requires Boost + Eigen + LEMON — heavy deps the project doesn't currently use. This first slice ships a native triangle-pairing backend with **zero new dependencies**: walks every interior edge whose two adjacent faces are triangles and scores the merge by (1) coplanarity (dot product of triangle normals; default `maxAngleDeg=25°`), (2) quad shape (deviation of interior angles from 90°; default `shapeToleranceDeg=65°`), (3) aspect ratio (longest/shortest edge; default `maxAspectRatio=6.0`). Pairs are taken greedily best-first; each triangle claimed at most once. Quads are emitted with opposing-corner winding `(opposing0, sharedA, opposing1, sharedB)`. Output goes through `EditableSubMesh::faces` → `triangulateFaces` (fan retri for GPU) → `writeNgonFacesToMesh` (n-gon binding for exporters / Edit Mode). **No new vertices** are introduced, so UVs and skin weights survive unchanged. Backends are pluggable via the `Algorithm` enum (only `TrianglePair` implemented; future `QuadriFlow` / `InstantMeshes` slot in here). Surfaced via `qtmesh retopo --target-faces N --max-angle DEG -o out`, MCP `retopologize`, and the **Material Mode → Mode Tools → "Quad Retopology…" button** (`qml/QuadRetopoDialog.qml`, driven by `QuadRetopoController` singleton). Sentry breadcrumb category `ai.assist.retopo`. Verified on Rumba Dancing.fbx: 10,220 tris → 6,032 faces (4188 quads + 1844 tris), 82% quad dominance. Hard lower bound on face count is ~50% of input (every triangle paired); strict gates typically land 60-70%.
- **UvUnwrap** (`src/UvUnwrap.h/cpp`, issue #400): xatlas-backed automatic UV unwrap. xatlas is the MIT library Blender and Godot use under the hood — single-translation-unit `xatlas.cpp` vendored via FetchContent and wrapped in an inline `add_library(xatlas STATIC …)` target (no upstream CMake config). Pipeline: extract (positions, indices) per submesh → `xatlas::AddMesh` → `xatlas::Generate` → for each output mesh, rebuild a single-binding VertexData copying every source attribute from `xref` (input vertex id) and overwriting the target UV channel with `xatlas::Vertex::uv / atlas.{width,height}`. Skinned-mesh bone assignments survive the seam splits because we rebuild `SubMesh::BoneAssignmentList` against the new vertex IDs via xref; for shared-vertex meshes the source assignments come from `Mesh::getBoneAssignments()`, not `SubMesh::getBoneAssignments()`. Surfaced via `qtmesh uv --unwrap`/`--info`, MCP `auto_uv_unwrap`, and the **Material Mode → Mode Tools → "Auto UV Unwrap…" button** (`qml/UvUnwrapDialog.qml`, driven by `UvUnwrapController` singleton). Sentry breadcrumb category `ai.assist.uv_unwrap`. The unwrap also erases `qtme.faces.<i>` n-gon bindings (they reference source vertex IDs and become stale). **GUI-safe entry point** (`unwrapEntityToFile`): live skinned meshes cannot survive in-place vertex-data mutation because the active `Ogre::SkeletonInstance` caches the hardware blend buffer and picks up stale state on the first frame after the swap. The GUI path snapshots `vertexData` / `indexData` / `mBoneAssignments` / `blendIndexToBoneIndexMap` for every submesh + the mesh's shared maps, calls `unwrapEntityKeepingOriginals` (which deliberately leaks its own allocations rather than freeing the originals), exports the unwrapped result, then restores the snapshot pointer-for-pointer (deleting only the unwrap's leaked allocations) and pastes the index maps back directly — `_compileBoneAssignments` is NOT called on restore because it would re-pack BLEND_INDICES/WEIGHTS bytes against the live buffer and shatter the on-screen mesh. CLI path uses the destructive `unwrapEntity` since the process exits before rendering.
- **ExportOptimizer** (`src/ExportOptimizer.h/cpp`, issue #399): Pipeline that runs `meshopt_optimizeVertexCache` → `meshopt_optimizeOverdraw` (threshold 1.05) → `meshopt_optimizeVertexFetchRemap` on every submesh of an entity. Surfaced through the **Inspector validation flow** — the "Optimize Geometry (cache + overdraw + fetch)" button in `PropertiesPanel.qml` runs it via `MeshValidator::optimizeVertexCache`. NOT hooked into `MeshImporterExporter::exporter` by default (an earlier draft did this and crashed on macOS during a normal export — silent buffer mutation during export is dangerous; explicit user invocation via the validation button is safer). Vertex-fetch is skipped when the submesh uses `useSharedVertices` since remapping shared verts would scramble other submeshes' indices. `qtmesh info --json` includes `submeshAcmr[]` per submesh so downstream tooling can decide whether to recommend re-optimization. Sentry breadcrumb category `ai.assist.optimize_export`.
- **Isometric sprite export** (`src/ModelIsometricRenderer.h/cpp`, epic #724): headless RTT renderer for 8-direction (configurable) isometric sprite atlases. Reuses the turntable's offscreen capture pattern (RTSS materials, stable orbit framing from rest bounds, single camera re-placed per direction). Outer loop = compass directions (row 0 = front/+Z, clockwise from above); inner loop = evenly spaced animation frames via `AnimationState::setTimePosition` + `_updateAnimation` before readback. Grid layout: rows = directions, columns = frames. Options include `--resolution`, `--camera-distance`, and `--padding` (auto-fit multiplier). Surfaced via `qtmesh isometric`, MCP `generate_isometric_sprites`. Sentry breadcrumb categories `file.export` / `ai.tool_call`.
- **Isometric sprite export** (`src/ModelIsometricRenderer.h/cpp`, epic #724): headless RTT renderer for 8-direction (configurable) isometric sprite atlases. Reuses the turntable's offscreen capture pattern (RTSS materials, stable orbit framing from rest bounds, single camera re-placed per direction). Outer loop = compass directions (row 0 = front/+Z, clockwise from above); inner loop = evenly spaced animation frames via `AnimationState::setTimePosition` + `_updateAnimation` before readback. Grid layout: rows = directions, columns = frames. Options include `--elevation` / `--camera-height`, `--resolution`, `--camera-distance`, and `--padding` (auto-fit multiplier). Editor grid and non-export scene entities are hidden during capture. Surfaced via `qtmesh isometric`, MCP `generate_isometric_sprites`, and **Animation Mode → Mode Tools → "Export Isometric Sprites…"** (`qml/IsometricSpritesDialog.qml`, `IsometricSpritesController`). Sentry breadcrumb categories `file.export` / `ai.tool_call`.
- **FBX LOD export gotcha**: `FBXExporter` prefers the cached `qtme.faces.<i>` n-gon binding (set up by quad-migration #326) over `SubMesh::indexData`. The CLI `lod` per-LOD export path in `CLIPipeline::cmdLod` temporarily erases those bindings (and restores them after) so the swapped-in LOD indices actually reach the wire. If you add another LOD-export entry point, mirror that erase/restore pair.

## Development Guidelines
Expand Down
346 changes: 346 additions & 0 deletions qml/IsometricSpritesDialog.qml
Original file line number Diff line number Diff line change
@@ -0,0 +1,346 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import QtQuick.Window
import MaterialEditorQML 1.0
import PropertiesPanel 1.0

// Epic #724: in-app isometric / 8-direction sprite atlas export.
Window {
id: dialog
title: "Isometric Sprites"
width: 560
height: 580
minimumWidth: 480
minimumHeight: 520
flags: Qt.Dialog
modality: Qt.ApplicationModal
color: PropertiesPanelController.panelColor

Comment on lines +18 to +19

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Use ThemeManager colors instead of PropertiesPanelController/hardcoded colors.

Line 18 and the dialog styling throughout use PropertiesPanelController.*, and Line 320 uses hardcoded status colors. This violates the repo’s theming contract for QML and can drift across platforms.

As per coding guidelines, "QML components should use theme colors from ThemeManager singleton for consistent styling across all platforms."

Also applies to: 108-111, 145-148, 193-195, 320-320

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@qml/IsometricSpritesDialog.qml` around lines 18 - 19, Replace all color
property assignments in IsometricSpritesDialog that reference
PropertiesPanelController.panelColor (line 18) and any hardcoded color values
(line 320) with the corresponding ThemeManager singleton color properties.
Update the color assignments at the specified locations (lines 18, 108-111,
145-148, 193-195, and 320) to use ThemeManager instead, ensuring all styling
throughout the dialog component consistently uses the theme system rather than
panel controller or hardcoded values.

Source: Coding guidelines

property string outputPath: ""
property string animationName: ""
property int directions: 8
property int frames: 8
property int resolution: 256
property double elevation: 30
property double padding: 1.25
property double cameraDistance: 0
property double startAzimuth: 0

property string lastStatus: ""
property bool lastWasError: false

readonly property bool useAnimation: dialog.animationName.length > 0
readonly property int labelColWidth: 100

function open() {
dialog.lastStatus = ""
dialog.lastWasError = false
dialog.show()
dialog.raise()
dialog.requestActivate()
keyCapture.forceActiveFocus()
}

function runExport() {
if (IsometricSpritesController.isExporting) return
if (!IsometricSpritesController.hasExportableSelection) return
if (dialog.outputPath.length === 0) {
dialog.lastStatus = "Choose an output PNG path first."
dialog.lastWasError = true
return
}
const r = IsometricSpritesController.exportSelected(
dialog.outputPath,
dialog.animationName,
dialog.directions,
dialog.frames,
dialog.resolution,
dialog.elevation,
dialog.padding,
dialog.cameraDistance,
dialog.startAzimuth)
if (r && r.ok) {
dialog.lastStatus = "✓ " + r.outputPath
+ " (" + r.sheetWidth + "×" + r.sheetHeight + " px)"
dialog.lastWasError = false
} else {
dialog.lastStatus = "✗ " + (r && r.error ? r.error : "export failed")
dialog.lastWasError = true
}
}

Item {
id: keyCapture
anchors.fill: parent
focus: true
Keys.onPressed: function(event) {
if (event.key === Qt.Key_Escape) {
dialog.close()
event.accepted = true
} else if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) {
dialog.runExport()
event.accepted = true
}
}
}

Connections {
target: IsometricSpritesController
function onOutputPathPicked(path) {
dialog.show()
dialog.raise()
dialog.requestActivate()
if (path.length > 0)
dialog.outputPath = path
}
}

component InspectorButton: Rectangle {
id: btn
property string label: ""
property bool buttonEnabled: true
signal clicked()
implicitWidth: btnLabel.implicitWidth + 16
Layout.preferredWidth: Math.max(90, implicitWidth)
activeFocusOnTab: buttonEnabled
Keys.onSpacePressed: if (buttonEnabled) btn.clicked()
Keys.onReturnPressed: if (buttonEnabled) btn.clicked()
Keys.onEnterPressed: if (buttonEnabled) btn.clicked()
height: 26
radius: 3
color: btnMa.containsMouse && buttonEnabled
? PropertiesPanelController.highlightColor
: PropertiesPanelController.headerColor
border.color: PropertiesPanelController.borderColor
border.width: 1
opacity: buttonEnabled ? 1.0 : 0.45
Text {
id: btnLabel
anchors.centerIn: parent
text: btn.label
color: PropertiesPanelController.textColor
font.pixelSize: 11
}
MouseArea {
id: btnMa
anchors.fill: parent
hoverEnabled: true
enabled: btn.buttonEnabled
cursorShape: btn.buttonEnabled ? Qt.PointingHandCursor : Qt.ForbiddenCursor
onClicked: btn.clicked()
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

component InspectorLabel: Text {
color: PropertiesPanelController.textColor
font.pixelSize: 11
}

component InspectorNumberField: Rectangle {
id: nf
property double value: 0
property double minValue: 0
property double maxValue: 1e9
property bool isInt: false
signal newValue(double v)
implicitWidth: 80
height: 24
color: PropertiesPanelController.inputColor
border.color: ni.activeFocus
? PropertiesPanelController.highlightColor
: PropertiesPanelController.borderColor
border.width: 1
radius: 3
TextInput {
id: ni
anchors.fill: parent
anchors.leftMargin: 6
anchors.rightMargin: 6
text: nf.isInt ? Math.round(nf.value).toString() : nf.value.toFixed(2)
color: PropertiesPanelController.textColor
font.pixelSize: 11
verticalAlignment: TextInput.AlignVCenter
selectByMouse: true
enabled: !IsometricSpritesController.isExporting
onEditingFinished: {
const n = nf.isInt ? parseInt(text, 10) : parseFloat(text)
if (isNaN(n)) {
text = nf.isInt ? Math.round(nf.value).toString() : nf.value.toFixed(2)
return
}
nf.newValue(Math.max(nf.minValue, Math.min(nf.maxValue, n)))
}
}
}

ColumnLayout {
anchors.fill: parent
anchors.margins: 16
spacing: 10

InspectorLabel {
Layout.fillWidth: true
wrapMode: Text.WordWrap
opacity: 0.85
text: "Export the selected mesh as an isometric sprite atlas: rows are compass "
+ "directions, columns are animation frames. Row 0 is the front view (+Z)."
}

RowLayout {
Layout.fillWidth: true
spacing: 8
InspectorLabel { text: "Output:"; Layout.preferredWidth: dialog.labelColWidth }
Rectangle {
Layout.fillWidth: true
height: 24
color: PropertiesPanelController.inputColor
border.color: PropertiesPanelController.borderColor
border.width: 1
radius: 3
Text {
anchors.fill: parent
anchors.leftMargin: 6
anchors.rightMargin: 6
text: dialog.outputPath.length > 0
? dialog.outputPath
: "(click Browse… to choose)"
color: PropertiesPanelController.textColor
opacity: dialog.outputPath.length > 0 ? 1.0 : 0.45
font.pixelSize: 11
elide: Text.ElideMiddle
verticalAlignment: Text.AlignVCenter
}
}
InspectorButton {
label: "Browse…"
Layout.preferredWidth: 90
buttonEnabled: !IsometricSpritesController.isExporting
onClicked: {
dialog.hide()
IsometricSpritesController.requestOutputPathPick(dialog.outputPath)
}
}
}

RowLayout {
Layout.fillWidth: true
spacing: 8
InspectorLabel { text: "Animation:"; Layout.preferredWidth: dialog.labelColWidth }
ThemedComboBox {
Layout.fillWidth: true
height: 24
font.pixelSize: 11
model: ["(static mesh)"].concat(IsometricSpritesController.availableAnimations)
currentIndex: dialog.animationName.length === 0
? 0
: Math.max(0, model.indexOf(dialog.animationName))
enabled: !IsometricSpritesController.isExporting
onCurrentTextChanged: {
dialog.animationName = (currentText === "(static mesh)") ? "" : currentText
}
}
}

RowLayout {
Layout.fillWidth: true
spacing: 8
InspectorLabel { text: "Directions:"; Layout.preferredWidth: dialog.labelColWidth }
InspectorNumberField {
isInt: true
value: dialog.directions
minValue: 1
maxValue: 64
onNewValue: function(v) { dialog.directions = Math.round(v) }
}
InspectorLabel { text: "Frames:"; Layout.preferredWidth: 56 }
InspectorNumberField {
isInt: true
value: dialog.useAnimation ? dialog.frames : 1
minValue: 1
maxValue: 360
opacity: dialog.useAnimation ? 1.0 : 0.45
enabled: dialog.useAnimation
onNewValue: function(v) { dialog.frames = Math.round(v) }
}
Item { Layout.fillWidth: true }
}

RowLayout {
Layout.fillWidth: true
spacing: 8
InspectorLabel { text: "Cell px:"; Layout.preferredWidth: dialog.labelColWidth }
InspectorNumberField {
isInt: true
value: dialog.resolution
minValue: 16
maxValue: 8192
onNewValue: function(v) { dialog.resolution = Math.round(v) }
}
InspectorLabel { text: "Elevation°:"; Layout.preferredWidth: 56 }
InspectorNumberField {
value: dialog.elevation
minValue: -80
maxValue: 80
onNewValue: function(v) { dialog.elevation = v }
}
Item { Layout.fillWidth: true }
}

RowLayout {
Layout.fillWidth: true
spacing: 8
InspectorLabel { text: "Padding:"; Layout.preferredWidth: dialog.labelColWidth }
InspectorNumberField {
value: dialog.padding
minValue: 0.1
maxValue: 10
onNewValue: function(v) { dialog.padding = v }
}
InspectorLabel { text: "Cam dist:"; Layout.preferredWidth: 56 }
InspectorNumberField {
value: dialog.cameraDistance
minValue: 0
maxValue: 1e6
onNewValue: function(v) { dialog.cameraDistance = v }
}
Item { Layout.fillWidth: true }
}

InspectorLabel {
Layout.fillWidth: true
wrapMode: Text.WordWrap
opacity: 0.65
font.pixelSize: 10
text: "Padding scales auto-fit framing. Camera distance 0 = auto-fit × padding."
}

Item { Layout.fillHeight: true }

InspectorLabel {
Layout.fillWidth: true
wrapMode: Text.WordWrap
visible: dialog.lastStatus.length > 0
color: dialog.lastWasError ? "#cc6666" : "#66aa66"
text: dialog.lastStatus
}

RowLayout {
Layout.fillWidth: true
spacing: 8
Item { Layout.fillWidth: true }
InspectorButton {
label: "Close"
Layout.preferredWidth: 90
onClicked: dialog.close()
}
InspectorButton {
label: IsometricSpritesController.isExporting ? "Exporting…" : "Export PNG"
Layout.preferredWidth: 110
buttonEnabled: IsometricSpritesController.hasExportableSelection
&& !IsometricSpritesController.isExporting
onClicked: dialog.runExport()
}
}
}
}
Loading
Loading