Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
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
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,7 @@ qtmesh generate3d image.png --no-pbr -o out.glb # skip the PBR stage (#404 norm
qtmesh generate3d photo.png -o out.glb --backend trellis2 --preset balanced --seed 42 # TRELLIS.2 backend (Microsoft, MIT code+weights): the highest-quality tier and the DEFAULT whenever its sidecar runtime is installed (ai/trellis2/install.py; Linux + NVIDIA GPU ≥24GB VRAM). Python does INFERENCE ONLY; QtMeshEditor natively does alpha matte (own U²-Net — upstream's RMBG-2.0 is CC-BY-NC and never loads), game-ready weld/debris-cull/simplify, xatlas UV + multi-channel PBR bake (basecolor RGBA/roughness/metallic/normal via Trellis2Bake — deliberately WITHOUT NVIDIA nvdiffrast/nvdiffrec, which are research-only-licensed and CI-gated out; audit: docs/trellis2-dependencies.md). Full-res source preserved as <out>_source.qtm3d
qtmesh generate3d photo.png -o out.glb --backend trellis2 --preset high --target-tris 25000 --texture-size 4096 # TRELLIS.2 game-ready presets: --target-tris 10000/25000/50000 (0 = original density); presets fast=512 / balanced=1024_cascade / high=1536_cascade; QTMESH_TRELLIS2_MOCK=1 exercises the whole pipeline without a GPU
qtmesh generate3d photo.png --target-tris 25000 -o out.glb # game-ready pass, ALL backends: weld + debris-cull + meshopt-simplify toward the target, then re-bake the diffuse on the simplified mesh (TripoSR field bake is density-independent) + bake the dense source's relief into a tangent-space detail normal map sharing the same atlas (Trellis2Bake::bakeDetailNormal). This is the fix for 'decimated Tripo output turns into a blob / skins badly' — simplify hard, keep detail in textures. 0 = original density
qtmesh generate3d photo.png -o out.glb --backend pixal3d --resolution 1024 # Pixal3D backend (TencentARC, SIGGRAPH 2026, MIT code+weights): a TRELLIS.2 FORK that replaces the global DINOv3 cross-attention with view-aligned PROJECTION conditioning (out = cross_attn(x, global) + proj_linear(proj)) - markedly better on HUMANOIDS/characters, and it also predicts material better (measured on the same image+seed: metallic ~0.00 on yellow plastic vs TRELLIS.2's false 0.52). Runs on the SAME trellis-cli runtime and REUSES TRELLIS.2's decoders byte-for-byte - one models dir serves both, flow weights taking a `pixal3d_` prefix (src/trellis_cli.cpp: FP = pix ? "/pixal3d_" : "/"). Needs its own ~11 GB flow set (AI Model Settings -> "Pixal3D (best humanoids)"). Pixal3D ships NO res-512 texture flow, so --resolution 512 writes geometry only (the runtime says so; measured PBR=0 at 512 vs 4,987,138 at 1024) - use --resolution 1024 for textures. --pixal-fov DEG sets the projection camera (omit = trellis-cli's own 49.13, Pixal3D's training value; guessing mis-places the camera the whole backend depends on); --no-naf drops the NAF guided upsampler + its pixal3d_naf.gguf dependency
qtmesh generate3d image.png --backend triposg --flow-steps 25 --guidance 7 -o out.glb # TripoSG backend (1.5B rectified-flow DiT, MIT): higher-fidelity GEOMETRY, slower; models download on first use; --guidance 0 disables CFG. TripoSG is GEOMETRY-ONLY (no colour decoder) — a colour bake queries TripoSR's image-conditioned colour field on the same image + projects the input photo onto the visible front (the back is inferred, so it's approximate; a "Generate texture (AI)" option in the GUI does a front-photo + SD-generated-back multi-view bake for a better back). int8 tier is DROPPED for TripoSG (fp32 only — quantized geometry degrades to blobs, no ARM speed win)
qtmesh segment model.fbx # AI part segmentation (#410/#818): per-part vertex/face counts; category auto-detected (body/vegetation/vehicle/building)
qtmesh segment model.fbx --json # full vertex/face → label arrays + per-part summary + resolved category (stable schema)
Expand Down
9 changes: 7 additions & 2 deletions cmake/TrellisCpp.cmake
Original file line number Diff line number Diff line change
Expand Up @@ -109,8 +109,13 @@ if(CMAKE_OSX_DEPLOYMENT_TARGET)
endif()

ExternalProject_Add(qtmesh_trelliscpp
GIT_REPOSITORY https://github.com/pwilkin/trellis.cpp.git
GIT_TAG 2516c48b677050c570f47eba2e68dc8a5bc918b0
# fernandotonon/trellis.cpp @ qtmesh-pixal3d = upstream pwilkin/main plus the
# three macOS commits QtMeshEditor depends on (Metal backend, direct CONV_3D
# in the SS decoder, and --dump-post, which is how we take over the asset
# pipeline after inference). Upstream shipped Pixal3D support in v0.8.0, so
# the fork carries no generation logic of its own — only the platform work.
GIT_REPOSITORY https://github.com/fernandotonon/trellis.cpp.git
GIT_TAG f46ea9a8c5cb6b7cf0d58ea6594db7d918d4e384
GIT_SHALLOW OFF
PREFIX "${CMAKE_BINARY_DIR}/_deps/qtmesh_trelliscpp"
SOURCE_DIR "${CMAKE_BINARY_DIR}/_deps/qtmesh_trelliscpp-src"
Expand Down
28 changes: 21 additions & 7 deletions qml/PropertiesPanel.qml
Original file line number Diff line number Diff line change
Expand Up @@ -2260,16 +2260,27 @@ Rectangle {
// while the app runs is picked up on the next session /
// section reopen).
readonly property bool t2Ready: MeshGenController.trellis2Available()
readonly property bool t2Selected: currentIndex === 0
readonly property bool sgSelected: currentIndex === 2
// Backend IDs, parallel to `model`. Everything keys off
// these rather than a positional currentIndex — the list
// previously hardcoded `currentIndex === 0/2` in 14 places,
// so inserting an entry silently rewired all of them.
readonly property var backendIds: ["trellis2", "pixal3d", "triposr", "triposg"]
readonly property string backendId: backendIds[currentIndex]
// Pixal3D rides the SAME trellis-cli runtime as TRELLIS.2,
// so every trellis-only control applies to both.
readonly property bool t2Selected: backendId === "trellis2"
|| backendId === "pixal3d"
readonly property bool pxSelected: backendId === "pixal3d"
readonly property bool sgSelected: backendId === "triposg"
model: ["TRELLIS.2 (high quality" + (t2Ready ? ")" : ", needs runtime)"),
"Pixal3D (best humanoids" + (t2Ready ? ")" : ", needs runtime)"),
"TripoSR (fast, textured)",
"TripoSG (best geometry)"]
currentIndex: t2Ready ? 0 : 1
currentIndex: t2Ready ? 0 : 2
// Switching to TripoSG snaps the tier picker to fp32 (its
// only geometry tier); the int8 option is meaningless there.
onCurrentIndexChanged: {
if (currentIndex === 2)
if (sgSelected)
mgQualityCombo.currentIndex = 0
}
}
Expand Down Expand Up @@ -2539,8 +2550,11 @@ Rectangle {
&& MeshGenController.selectedImagePath.length > 0
&& (!mgBackendCombo.t2Selected || mgBackendCombo.t2Ready)
onClicked: {
var t2 = mgBackendCombo.currentIndex === 0 // TRELLIS.2
var sg = mgBackendCombo.currentIndex === 2 // TripoSG
// Value-based, not positional: t2 covers BOTH trellis-cli
// families (TRELLIS.2 and Pixal3D), which share the sidecar
// path and so emit the same progress stages.
var t2 = mgBackendCombo.t2Selected
var sg = mgBackendCombo.sgSelected
var steps = [{ key: "prep", label: "Prepare models" }]
// The worker only posts a "background" stage on the TripoSR
// path; TripoSG/TRELLIS.2 remove the bg inside their
Expand Down Expand Up @@ -2608,7 +2622,7 @@ Rectangle {
"bake_texture": buildBake,
"generate_pbr": buildPbr,
"upscale_texture": mgUpscale.checked && buildBake,
"backend": t2 ? "trellis2" : (sg ? "triposg" : "triposr"),
"backend": mgBackendCombo.backendId,
// Game-ready simplification target (all backends).
"target_tris": mgT2Mesh.triValue
}
Expand Down
28 changes: 28 additions & 0 deletions src/AIModelCatalog.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,14 @@ QList<AIModelCatalog::ModelSpec> AIModelCatalog::specs() const
const QString trellis2Base = resolveBaseUrl(
"ai/trellis2GgufBaseUrl", "QTMESH_TRELLIS2_GGUF_BASE_URL",
"https://huggingface.co/fernandotonon/QtMeshEditor-trellis2-gguf/resolve/main/");
// Pixal3D flow weights. The upstream trellis.cpp docs point at this
// pre-built MIT set; its GGUFs are byte-size-identical to converting
// TencentARC/Pixal3D's own safetensors with tools/convert.py, and it also
// carries naf.gguf, which is NOT in TencentARC's repo (NAF ships as a
// torch .pth and is the one component converted through torch).
const QString pixal3dBase = resolveBaseUrl(
"ai/pixal3dGgufBaseUrl", "QTMESH_PIXAL3D_GGUF_BASE_URL",
"https://huggingface.co/vegax87/Pixal3D/resolve/main/");
const QString segmentBase = resolveBaseUrl(
"ai/segmentModelBaseUrl", "QTMESH_SEGMENT_MODEL_BASE_URL",
"https://huggingface.co/fernandotonon/QtMeshEditor-models/resolve/main/segment/");
Expand Down Expand Up @@ -270,6 +278,26 @@ QList<AIModelCatalog::ModelSpec> AIModelCatalog::specs() const
file(QStringLiteral("trellis2"), QStringLiteral("shape_flow_1024.gguf"), trellis2Base, QStringLiteral("TRELLIS.2 shape flow (1024 cascade)")),
file(QStringLiteral("trellis2"), QStringLiteral("tex_flow_1024.gguf"), trellis2Base, QStringLiteral("TRELLIS.2 texture flow (1024 cascade)")),
}};
// Pixal3D: a TRELLIS.2 FORK, so it reuses that family's decoders and the
// same trellis-cli runtime — only the four flow models (plus NAF) differ,
// and they live in the SAME directory under a `pixal3d_` prefix.
out << ModelSpec{
QStringLiteral("pixal3d-gguf"), tr("Pixal3D (best humanoids)"), tr("Image to 3D"),
tr("TencentARC Pixal3D GGUF weights (MIT) — a TRELLIS.2 fork that replaces the "
"global DINOv3 cross-attention with view-aligned projection conditioning, "
"which is markedly better on humanoids and characters. Runs on the same "
"trellis.cpp runtime and REUSES the TRELLIS.2 decoders, so install those "
"weights too. Note Pixal3D ships no 512 texture flow: generate at 1024 for "
"textures, or 512 for geometry only."),
QStringLiteral("~11.0 GB"), QString(),
true,
{
file(QStringLiteral("trellis2"), QStringLiteral("pixal3d_ss_flow.gguf"), pixal3dBase, QStringLiteral("Pixal3D sparse-structure flow")),
file(QStringLiteral("trellis2"), QStringLiteral("pixal3d_shape_flow_512.gguf"), pixal3dBase, QStringLiteral("Pixal3D shape flow (512)")),
file(QStringLiteral("trellis2"), QStringLiteral("pixal3d_shape_flow_1024.gguf"), pixal3dBase, QStringLiteral("Pixal3D shape flow (1024)")),
file(QStringLiteral("trellis2"), QStringLiteral("pixal3d_tex_flow_1024.gguf"), pixal3dBase, QStringLiteral("Pixal3D texture flow (1024)")),
file(QStringLiteral("trellis2"), QStringLiteral("pixal3d_naf.gguf"), pixal3dBase, QStringLiteral("Pixal3D NAF guided upsampler")),
}};
#ifdef ENABLE_STABLE_DIFFUSION
constexpr bool sdAvailable = true;
#else
Expand Down
38 changes: 33 additions & 5 deletions src/CLIPipeline.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -11638,6 +11638,11 @@ int CLIPipeline::cmdGenerate3d(int argc, char* argv[])
int textureSupersample = 1; // 2 = 2x2 subsamples per texel (speckle fix)
int texVolumeRes = 0; // 0 = sidecar auto | 512 | 1024
bool keepSource = true; // write the .qtm3d full-res source sidecar
// Pixal3D camera knobs. 0 leaves trellis-cli on its own default (49.13,
// the value Pixal3D was trained with) — guessing a FOV would silently
// mis-place the projection camera that the whole backend depends on.
float pixalFovDeg = 0.0f;
bool pixalNoNaf = false;
MeshGenPredictor::Quality quality = MeshGenPredictor::Quality::Fp32;
MeshGenPredictor::Backend backend = MeshGenPredictor::Backend::TripoSR;
bool backendSet = false; // explicit --backend beats the auto default
Expand Down Expand Up @@ -11671,15 +11676,17 @@ int CLIPipeline::cmdGenerate3d(int argc, char* argv[])
if (arg == "--no-pbr") { generatePbr = false; continue; }
if (arg == "--backend") {
if (i + 1 >= argc) {
err() << "Error: --backend requires trellis2, triposr or triposg." << Qt::endl;
err() << "Error: --backend requires trellis2, pixal3d, triposr or triposg." << Qt::endl;
return 2;
}
const QString b = QString::fromLocal8Bit(argv[++i]).toLower();
if (b == "triposr") backend = MeshGenPredictor::Backend::TripoSR;
else if (b == "triposg") backend = MeshGenPredictor::Backend::TripoSG;
else if (b == "trellis2" || b == "trellis.2" || b == "trellis")
backend = MeshGenPredictor::Backend::Trellis2;
else { err() << "Error: --backend must be trellis2, triposr or triposg." << Qt::endl; return 2; }
else if (b == "pixal3d" || b == "pixal")
backend = MeshGenPredictor::Backend::Pixal3D;
else { err() << "Error: --backend must be trellis2, pixal3d, triposr or triposg." << Qt::endl; return 2; }
backendSet = true;
continue;
}
Expand Down Expand Up @@ -11722,6 +11729,18 @@ int CLIPipeline::cmdGenerate3d(int argc, char* argv[])
continue;
}
if (arg == "--no-source") { keepSource = false; continue; }
if (arg == "--pixal-fov") {
if (i + 1 >= argc) {
err() << "Error: --pixal-fov requires degrees." << Qt::endl; return 2; }
bool ok = false;
pixalFovDeg = QString::fromLocal8Bit(argv[++i]).toFloat(&ok);
if (!ok || pixalFovDeg <= 0.0f || pixalFovDeg >= 180.0f) {
err() << "Error: --pixal-fov must be in (0, 180) degrees." << Qt::endl;
return 2;
}
continue;
}
if (arg == "--no-naf") { pixalNoNaf = true; continue; }
if (arg == "--target-tris") {
if (i + 1 >= argc) { err() << "Error: --target-tris requires a number." << Qt::endl; return 2; }
bool ok = false;
Expand Down Expand Up @@ -11834,9 +11853,10 @@ int CLIPipeline::cmdGenerate3d(int argc, char* argv[])
"[--no-color] [--remove-bg] [--quality fp32|int8] "
"[--no-smooth] [--no-refine] [--no-bake-texture] [--texture-size 1024] "
"[--upscale-texture] [--no-pbr] "
"[--backend trellis2|triposr|triposg] [--flow-steps 25] [--guidance 7.0] "
"[--backend trellis2|pixal3d|triposr|triposg] [--flow-steps 25] [--guidance 7.0] "
"[--seed 42] [--preset fast|balanced|high] [--target-tris N]"
" [--texture-supersample 1|2] [--tex-res 512|1024] [--no-source]"
" [--pixal-fov DEG] [--no-naf]"
<< Qt::endl;
err() << " qtmesh generate3d --prompt \"a goblin warrior\" -o out.glb "
"[--image-model FLUX.2-klein-4B] [...same options]" << Qt::endl;
Expand Down Expand Up @@ -11901,7 +11921,11 @@ int CLIPipeline::cmdGenerate3d(int argc, char* argv[])
err() << "Using backend: trellis2 (runtime detected; pass "
"--backend triposr|triposg to override)." << Qt::endl;
}
const bool useTrellis2 = (backend == MeshGenPredictor::Backend::Trellis2);
// Pixal3D rides the SAME trellis-cli runtime as TRELLIS.2 — same sidecar,
// same game-ready pass, same native PBR bake; only the flow weights differ.
// Everything gated on this flag applies to both.
const bool useTrellis2 =
MeshGenPredictor::isTrellisRuntime(backend);

#ifndef ENABLE_ONNX
// The TRELLIS.2 sidecar backend has no ONNX dependency; only the local
Expand All @@ -11925,7 +11949,9 @@ int CLIPipeline::cmdGenerate3d(int argc, char* argv[])
}

const bool useSG = (backend == MeshGenPredictor::Backend::TripoSG);
const QString backendName = useTrellis2 ? QStringLiteral("trellis2")
const QString backendName =
backend == MeshGenPredictor::Backend::Pixal3D ? QStringLiteral("pixal3d")
: useTrellis2 ? QStringLiteral("trellis2")
: (useSG ? QStringLiteral("triposg") : QStringLiteral("triposr"));

Check warning on line 11955 in src/CLIPipeline.cpp

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Extract this nested conditional operator into an independent statement.

See more on https://sonarcloud.io/project/issues?id=fernandotonon_QtMeshEditor&issues=AaDZCA9bVIiJnbhEct2N&open=AaDZCA9bVIiJnbhEct2N&pullRequest=1072
SentryReporter::addBreadcrumb(QStringLiteral("ai.assist.image_to_3d"),
QString("generate3d .%1 res=%2 color=%3 backend=%4")
Expand Down Expand Up @@ -12001,6 +12027,8 @@ int CLIPipeline::cmdGenerate3d(int argc, char* argv[])
opts.bakeNormalMap = generatePbr && bake;
opts.textureSupersample = textureSupersample;
opts.texVolumeRes = texVolumeRes;
opts.pixal3dFovDeg = pixalFovDeg;
opts.pixal3dNoNaf = pixalNoNaf;
if (useTrellis2) {
opts.removeBackground = true; // trellis2 needs an alpha matte; the
// predictor skips it when the input
Expand Down
47 changes: 47 additions & 0 deletions src/ImageTo3D/CLIPipeline_cmdgenerate3d_coverage_test.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -228,3 +228,50 @@ TEST(CLIPipelineCmdGenerate3dCoverage, NoSourceTakesNoValue)
Gen3dArgv args({"generate3d", kMissingImage, "--no-source"});
EXPECT_EQ(CLIPipeline::cmdGenerate3d(args.argc(), args.argv()), 1);
}

// ---- Pixal3D backend --------------------------------------------------------
//
// Pixal3D is a TRELLIS.2 fork with view-aligned projection conditioning,
// markedly better on humanoids. It rides the SAME trellis-cli runtime, so the
// CLI only has to name it and carry its two camera knobs.

TEST(CLIPipelineCmdGenerate3dCoverage, Pixal3DBackendIsAccepted)
{
// exit 1 (not 2) = the argument parsed and the run failed later on the
// missing image, which is how this file distinguishes accept from reject.
for (const char* name : {"pixal3d", "pixal", "PIXAL3D"}) {
Gen3dArgv args({"generate3d", kMissingImage, "--backend", name});
EXPECT_EQ(CLIPipeline::cmdGenerate3d(args.argc(), args.argv()), 1)
<< "should accept --backend " << name;
}
}

TEST(CLIPipelineCmdGenerate3dCoverage, UnknownBackendStillRejected)
{
// Adding a backend must not turn the enum into a catch-all.
Gen3dArgv args({"generate3d", kMissingImage, "--backend", "pixal4d"});
EXPECT_EQ(CLIPipeline::cmdGenerate3d(args.argc(), args.argv()), 2);
}

TEST(CLIPipelineCmdGenerate3dCoverage, PixalFovRejectsOutOfRangeAngles)
{
// 0 is reserved for "leave trellis-cli on its own 49.13 default", so an
// explicit 0 is a caller error rather than a silent no-op — otherwise
// `--pixal-fov 0` would look like it set something and do nothing.
for (const char* bad : {"0", "-10", "180", "999", "abc"}) {
Gen3dArgv args({"generate3d", kMissingImage, "--pixal-fov", bad});
EXPECT_EQ(CLIPipeline::cmdGenerate3d(args.argc(), args.argv()), 2)
<< "should reject --pixal-fov " << bad;
}
Gen3dArgv missing({"generate3d", kMissingImage, "--pixal-fov"});
EXPECT_EQ(CLIPipeline::cmdGenerate3d(missing.argc(), missing.argv()), 2);
Gen3dArgv ok({"generate3d", kMissingImage, "--pixal-fov", "49.13"});
EXPECT_EQ(CLIPipeline::cmdGenerate3d(ok.argc(), ok.argv()), 1);
}

TEST(CLIPipelineCmdGenerate3dCoverage, NoNafTakesNoValue)
{
// Bare switch: must not swallow the next argument.
Gen3dArgv args({"generate3d", kMissingImage, "--no-naf"});
EXPECT_EQ(CLIPipeline::cmdGenerate3d(args.argc(), args.argv()), 1);
}
Loading
Loading