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: 3 additions & 0 deletions CLAUDE.md

Large diffs are not rendered by default.

130 changes: 130 additions & 0 deletions qml/PropertiesPanel.qml
Original file line number Diff line number Diff line change
Expand Up @@ -849,6 +849,17 @@ Rectangle {
Component.onCompleted: content = hdrEnvironmentComponent
}

// ---- Split into Parts (AI segmentation, #859/#861, Object mode) ----
CollapsibleSection {
title: "Split into Parts (AI)"
sectionVisible: root.modeToolSectionVisible(
EditorModeController.ObjectMode,
PartOpsController.hasSelection)
expanded: false

Component.onCompleted: content = partOpsSplitComponent
}

// ---- Decimate (single-pass) ----
CollapsibleSection {
title: "Decimate (single-pass)"
Expand Down Expand Up @@ -6356,6 +6367,125 @@ Rectangle {
// Live slider + preview that swaps a temporary LOD into the viewport,
// mirroring the LOD section's previewLod pattern but for one-shot
// base-mesh reduction. Apply commits the swap permanently.
// PartOps split (#859/#861): segment the selected fused mesh and replace
// it with one submesh per detected part (head/torso/…). Undoable (Ctrl+Z
// restores the fused mesh). Runs in Object mode; no Edit Mode required.
Component {
id: partOpsSplitComponent

Column {
id: partOpsSplitContent
width: parent ? parent.width : 200
padding: 8
spacing: 6

// Category id list, index-aligned with partOpsCategoryCombo.
readonly property var partOpsCategories:
["auto", "body", "vegetation", "vehicle", "building"]

Text {
width: parent.width - 16
wrapMode: Text.WordWrap
color: PropertiesPanelController.textColor
font.pixelSize: 11
text: "Split the selected mesh into named part submeshes "
+ "(head, torso, arms, legs). Undoable."
}

Row {
spacing: 6
Text {
text: "Category:"
color: PropertiesPanelController.textColor
font.pixelSize: 11
anchors.verticalCenter: parent.verticalCenter
}
ThemedComboBox {
id: partOpsCategoryCombo
width: 140
height: 22
font.pixelSize: 11
model: partOpsSplitContent.partOpsCategories
currentIndex: 0
}
}

// Checked = AI-assisted (the ONNX segmentation model, downloaded on
// first use). Unchecked = the deterministic geometric / rig-prior
// fallback. Both run locally; the model is the only thing that
// downloads. Default ON. Uses the inspector's own InspectorCheckBox
// so it matches the other panel toggles (not the Material-Editor
// Themed* look).
InspectorCheckBox {
id: partOpsAiCheck
text: "AI assisted"
checked: true
}

// Inspector-styled button (same Rectangle+MouseArea idiom as the
// in-file InspectorButton, inlined because that component is scoped
// to another section's tree, not this top-level Component).
Rectangle {
id: partOpsSplitBtn
property bool clickEnabled: PartOpsController.hasSelection
width: Math.min(parent ? parent.width - 16 : 200,
partOpsSplitBtnLabel.implicitWidth + 20)
height: 26
radius: 3
opacity: clickEnabled ? 1.0 : 0.45
color: partOpsSplitBtnMa.containsMouse && clickEnabled
? PropertiesPanelController.highlightColor
: PropertiesPanelController.headerColor
border.color: PropertiesPanelController.borderColor
border.width: 1
Text {
id: partOpsSplitBtnLabel
anchors.centerIn: parent
text: "Split into Parts"
color: PropertiesPanelController.textColor
font.pixelSize: 11
}
MouseArea {
id: partOpsSplitBtnMa
anchors.fill: parent
hoverEnabled: true
enabled: partOpsSplitBtn.clickEnabled
cursorShape: partOpsSplitBtn.clickEnabled
? Qt.PointingHandCursor : Qt.ArrowCursor
onClicked: {
partOpsSplitFeedback.color = PropertiesPanelController.textColor
partOpsSplitFeedback.text = "Splitting…"
// noModel is the inverse of "AI assisted".
PartOpsController.splitSelectedIntoParts(
"y",
partOpsSplitContent.partOpsCategories[partOpsCategoryCombo.currentIndex],
!partOpsAiCheck.checked)
}
}
}

Text {
id: partOpsSplitFeedback
width: parent.width - 16
wrapMode: Text.WordWrap
color: PropertiesPanelController.textColor
font.pixelSize: 11
text: ""
}

Connections {
target: PartOpsController
function onSplitFinished(status, isError) {
partOpsSplitFeedback.color = isError ? "#e06060" : "#60c060"
partOpsSplitFeedback.text = status
}
function onSelectionChanged() {
partOpsSplitFeedback.text = ""
}
}
}
}

Component {
id: decimateComponent

Expand Down
26 changes: 26 additions & 0 deletions src/Assimp/MeshProcessor.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,8 @@

SubMeshData* MeshProcessor::processMesh(aiMesh* mesh, const aiScene* scene) {
SubMeshData* subMeshData = new SubMeshData();
if (mesh->mName.length > 0)
subMeshData->name = mesh->mName.C_Str();

// Rotation applied to vertex data when the source file uses a Z-up coordinate system.
// Baking it here avoids a scene-node rotation and keeps the entity in its natural pose.
Expand Down Expand Up @@ -179,6 +181,30 @@
// Create a submesh
Ogre::SubMesh* subMesh = ogreMesh->createSubMesh();

// Register the source name (aiMesh::mName) so named submeshes — e.g.
// PartOps parts "head"/"torso" round-tripped through FBX — are
// addressable by name and shown in the Scene tree. Skipped when the
// source mesh was unnamed OR the name is already taken: nameSubMesh
// overwrites the SubMeshNameMap entry, so a duplicate aiMesh::mName
// would make BOTH names resolve to the last submesh (CodeRabbit). On a
// collision we disambiguate with an index suffix instead of dropping
// the name, so every submesh stays addressable.
if (!subMeshData->name.empty()) {
const unsigned short idx =

Check warning on line 193 in src/Assimp/MeshProcessor.cpp

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Replace the redundant type with "auto".

See more on https://sonarcloud.io/project/issues?id=fernandotonon_QtMeshEditor&issues=AZ-NHpy4ztpgaD9rYZlx&open=AZ-NHpy4ztpgaD9rYZlx&pullRequest=923
static_cast<unsigned short>(ogreMesh->getNumSubMeshes() - 1);
const Ogre::Mesh::SubMeshNameMap& nameMap = ogreMesh->getSubMeshNameMap();
std::string name = subMeshData->name;
if (nameMap.find(name) != nameMap.end()) {
unsigned int suffix = 1;
std::string candidate;
do {
candidate = name + "_" + std::to_string(suffix++);
} while (nameMap.find(candidate) != nameMap.end());
name = candidate;
}
ogreMesh->nameSubMesh(name, idx);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// Create the vertex data
Ogre::VertexData* vertexData = new Ogre::VertexData();
subMesh->useSharedVertices = false;
Expand Down
2 changes: 2 additions & 0 deletions src/Assimp/MeshProcessor.h
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ struct SubMeshData {
std::vector<Ogre::VertexBoneAssignment> boneAssignments;
std::vector<MorphTargetData> morphTargets; ///< Empty when source had no blend shapes.
unsigned int materialIndex;
std::string name; ///< From `aiMesh::mName`; drives Mesh::nameSubMesh so
///< named submeshes (e.g. PartOps parts) survive import.
};

class MeshProcessor {
Expand Down
110 changes: 109 additions & 1 deletion src/CLIPipeline.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,8 @@
#include "ImageTo3D/TripoSGPredictor.h"
#include "ImageTo3D/MeshGenBuilder.h"
#include "MeshSegmenter.h"
#include "SubMeshOps.h"
#include "PartOpsMesh.h"
#include "MeshDecimator.h"
#include "EditableMesh.h"
#include "TexturePaintBuffer.h"
Expand Down Expand Up @@ -2166,7 +2168,7 @@
// Auto-rigged (no prior animation) meshes that face −Z would walk
// backward — detect facing from the mesh's foot region.
const bool yaw180 = AnimationMerger::detectBackwardFacing(entity);
auto res = AnimationMerger::applyMotionClip(skel.get(), animName, quats, fps,

Check failure on line 2171 in src/CLIPipeline.cpp

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this code to not nest more than 3 if|for|do|while|switch statements.

See more on https://sonarcloud.io/project/issues?id=fernandotonon_QtMeshEditor&issues=AZ-Q_nm3_95_XqQUPwaH&open=AZ-Q_nm3_95_XqQUPwaH&pullRequest=923
worldFrame, cmuRest,
/*refineWithModel=*/false,
/*refineStride=*/8, yaw180,
Expand Down Expand Up @@ -2918,7 +2920,7 @@
}

return 0;
}

Check failure on line 2923 in src/CLIPipeline.cpp

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this code to not nest more than 3 if|for|do|while|switch statements.

See more on https://sonarcloud.io/project/issues?id=fernandotonon_QtMeshEditor&issues=AZ-Q_nm3_95_XqQUPwaI&open=AZ-Q_nm3_95_XqQUPwaI&pullRequest=923

if (analyzeMode) {
if (jsonOutput) {
Expand Down Expand Up @@ -10301,6 +10303,9 @@
// [--dump-training-data <out.json>]
QString inputPath;
QString dumpPath;
QString writeLabelsPath; // PartOps #864: dump face/vertex labels to JSON
QString outputPath; // PartOps #864: --split-parts output mesh
bool splitParts = false; // PartOps #861/#864
bool jsonOutput = false;
bool noModel = false;
int upAxis = 1; // +Y default
Expand All @@ -10311,6 +10316,23 @@
if (arg == "segment" || arg == "--cli") continue;
if (arg == "--json") { jsonOutput = true; continue; }
if (arg == "--no-model") { noModel = true; continue; }
if (arg == "--split-parts") { splitParts = true; continue; }
if (arg == "--write-labels") {
if (i + 1 >= argc) {
err() << "Error: --write-labels requires an output path." << Qt::endl;
return 2;
}
writeLabelsPath = QString::fromLocal8Bit(argv[++i]);
continue;
}
if (arg == "-o" || arg == "--output") {
if (i + 1 >= argc) {
err() << "Error: -o requires an output path." << Qt::endl;
return 2;
}
outputPath = QString::fromLocal8Bit(argv[++i]);
continue;
}
if (arg == "--category") {
if (i + 1 >= argc) {
err() << "Error: --category requires a value (auto, body, "
Expand Down Expand Up @@ -10354,11 +10376,16 @@
err() << "Error: No input file specified." << Qt::endl;
err() << "Usage: qtmesh segment <file> [--json] [--no-model] [--up-axis x|y|z] "
"[--category auto|body|vegetation|vehicle|building] "
"[--dump-training-data <out.json>]" << Qt::endl;
"[--dump-training-data <out.json>] [--write-labels <out.json>] "
"[--split-parts -o <out.glb>]" << Qt::endl;
return 2;
}
QFileInfo fi(inputPath);
if (!fi.exists()) { err() << "Error: file not found: " << inputPath << Qt::endl; return 1; }
if (splitParts && outputPath.isEmpty()) {
err() << "Error: --split-parts requires -o <output mesh>." << Qt::endl;
return 2;
}
if (!initOgreHeadless()) return 1;

SentryReporter::addBreadcrumb(QStringLiteral("ai.assist.segment"),
Expand Down Expand Up @@ -10517,6 +10544,87 @@
{QStringLiteral("success"), true},
{QStringLiteral("capability"), QStringLiteral("segmentation")}});

// --- PartOps: write labels (#864) --------------------------------------
if (!writeLabelsPath.isEmpty()) {
QJsonObject root;
root["schema"] = QStringLiteral("qtmesh-partops-labels-v1");
root["mesh"] = fi.fileName();
root["category"] = MeshSegmenter::categoryName(r.category);
root["vertexCount"] = vertexCount;
root["faceCount"] = static_cast<int>(r.faceLabels.size());
QJsonArray vl, fl;
for (int l : r.vertexLabels) vl.append(l);
for (int l : r.faceLabels) fl.append(l);
root["vertexLabels"] = vl;
root["faceLabels"] = fl;
QJsonObject names;
for (int p = 0; p < P; ++p)
if (vCount[p] > 0 || fCount[p] > 0)
names[QString::number(p)] = MeshSegmenter::partName(p);
root["partNames"] = names;
QFile lf(writeLabelsPath);
if (!lf.open(QIODevice::WriteOnly)) {
err() << "Error: cannot write labels to " << writeLabelsPath << Qt::endl;
return 1;
}
lf.write(QJsonDocument(root).toJson(QJsonDocument::Compact));
lf.close();
SentryReporter::addBreadcrumb(QStringLiteral("mesh.parts.segment_preview"),
QStringLiteral("write-labels faces=%1")
.arg(r.faceLabels.size()));
if (!splitParts && !jsonOutput)
cliWrite(QString("Wrote labels: %1 (%2 faces)\n")
.arg(QFileInfo(writeLabelsPath).fileName())
.arg(r.faceLabels.size()));
}

// --- PartOps: split into per-part submeshes (#861/#864) ----------------
if (splitParts) {
auto groups = SubMeshOps::groupFacesByLabel(r.faceLabels);
SubMeshOps::SplitOptions sopts; // default "Body" prefix, preserve material
PartOpsMesh::SplitOutcome so = PartOpsMesh::splitEntity(
entity, r.faceLabels, groups, sopts, fi.completeBaseName().toStdString());
if (!so.ok) {
err() << "Error: split failed — "
<< (so.error.isEmpty() ? QStringLiteral("unknown") : so.error) << Qt::endl;
return 1;
}
auto* mgr = Manager::getSingletonPtr();
Ogre::SceneNode* node = mgr ? mgr->addSceneNode("PartOpsSplit") : nullptr;
if (!node || !mgr->createEntity(node, so.mesh)) {
err() << "Error: could not build scene node for split mesh." << Qt::endl;
return 1;
}
const QString fmt = formatForExtension(outputPath);
if (MeshImporterExporter::exporter(
node, QFileInfo(outputPath).absoluteFilePath(), fmt) != 0) {
err() << "Error: export failed for " << outputPath << Qt::endl;
return 1;
}
SentryReporter::addBreadcrumb(
QStringLiteral("mesh.parts.split_segments"),
QStringLiteral("parts=%1 dupVerts=%2")
.arg(so.createdSubMeshes).arg(so.duplicatedBoundaryVertices));
if (jsonOutput) {
QJsonObject root;
root["mesh"] = fi.fileName();
root["output"] = QFileInfo(outputPath).fileName();
root["createdSubMeshes"] = so.createdSubMeshes;
root["duplicatedBoundaryVertices"] = so.duplicatedBoundaryVertices;
QJsonArray pn;
for (const QString& n : so.partNames) pn.append(n);
root["partNames"] = pn;
cliWrite(QString::fromUtf8(QJsonDocument(root).toJson(QJsonDocument::Compact)) + "\n");
} else {
cliWrite(QString("Split %1 into %2 part submeshes → %3\n")
.arg(fi.fileName()).arg(so.createdSubMeshes)
.arg(QFileInfo(outputPath).fileName()));
for (const QString& n : so.partNames)
cliWrite(QString(" %1\n").arg(n));
}
return 0; // split path produces its own output; skip the label dump below
}

if (jsonOutput) {
QJsonObject root;
root["mesh"] = fi.fileName();
Expand Down Expand Up @@ -10585,7 +10693,7 @@

QFileInfo fi(filePath);
if (!fi.exists()) {
err() << "Error: File not found: " << filePath << Qt::endl;

Check warning on line 10696 in src/CLIPipeline.cpp

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Define each identifier in a dedicated statement.

See more on https://sonarcloud.io/project/issues?id=fernandotonon_QtMeshEditor&issues=AZ-K3h3QdJ1LoJ_JXT8b&open=AZ-K3h3QdJ1LoJ_JXT8b&pullRequest=923
return 1;
}

Expand Down
Loading
Loading