
Threejs 3d Generator
- 1.2k installs
- 1.2k repo stars
- Updated July 16, 2026
- majidmanzarpour/threejs-game-skills
Helps with ai & agent building tasks.
About
threejs-3d-generator is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted development.
- threejs-3d-generator
- AI & Agent Building
- AI-coding skill
Threejs 3d Generator by the numbers
- 1,239 all-time installs (skills.sh)
- +143 installs in the week ending Aug 4, 2026 (Skillselion tracking)
- Ranked #912 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/majidmanzarpour/threejs-game-skills --skill threejs-3d-generatorAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 1.2k |
|---|---|
| repo stars | ★ 1.2k |
| Last updated | July 16, 2026 |
| Repository | majidmanzarpour/threejs-game-skills ↗ |
What it does
Helps with ai & agent building tasks.
Files
Three.js 3D Generator
Purpose
Create production-oriented 3D assets, then prepare them for Three.js games. This is the Three.js game system's 3D-generation layer; it uses Tripo as the provider for text-to-3D, image-to-3D, texturing, rigging, retargeting, stylization, conversion, and downloadable GLB/FBX outputs.
API Key
Never store API keys in skill files or client-side game code. The script checks:
1. --api-key 2. TRIPO_API_KEY
Step 0 before declaring the key unavailable:
bash ~/.claude/skills/threejs-game-director/scripts/probe_asset_credentials.shFor Codex installs:
bash ~/.codex/skills/threejs-game-director/scripts/probe_asset_credentials.shPaste the literal TRIPO_API_KEY=SET|MISSING output in the report. Do not conclude the key is unavailable from a plain non-interactive shell until this probe has sourced the user's shell profiles.
When the probe says SET but threejs_3d_asset.py reports a missing key, the key is exported in an interactive-only profile (e.g. ~/.zshrc). Wrap script invocations the same way the probe does:
zsh -c 'source "$HOME/.zprofile" 2>/dev/null; source "$HOME/.zshrc" 2>/dev/null; python3 .../threejs_3d_asset.py ...'Use the API only from local/server-side tooling. Generated model download URLs expire quickly, so download outputs immediately after successful tasks.
Tool Script
Reference gate:
- Load
references/api-notes.mdbefore provider API work, endpoint/task decisions, model-version choices, polling, postprocess, conversion, rigging, animation, or download handling. - Load
references/threejs-integration.mdbefore importing Tripo outputs into a browser game or advising GLB/FBX integration. - Load
references/image-generator-workflows.mdbefore pairingthreejs-image-generatorwith this skill for 2D concepts, texture references, UI art, logos, decals, or image-to-3D inputs.
Track required references in a reference ledger with yes/no, path, and failure reason. Do not mark an asset pipeline complete while a required reference is skipped.
Run from the user's current project directory:
python3 ~/.claude/skills/threejs-3d-generator/scripts/threejs_3d_asset.py --helpIf installed in Codex instead of Claude, use:
python3 ~/.codex/skills/threejs-3d-generator/scripts/threejs_3d_asset.py --helpCommon Commands
Recommended premium game hero model:
python3 ~/.claude/skills/threejs-3d-generator/scripts/threejs_3d_asset.py text \
--prompt "game-ready [hero asset], strong readable silhouette, layered hard-surface detail, PBR materials, clean topology for browser game, centered pivot, 3/4 view, no text" \
--model-version v3.1-20260211 \
--texture-quality detailed \
--geometry-quality detailed \
--wait --download --out-dir assets/models/heroText to 3D:
python3 ~/.claude/skills/threejs-3d-generator/scripts/threejs_3d_asset.py text \
--prompt "game-ready sci-fi hover bike, sleek armored panels, readable silhouette, PBR, front facing" \
--model-version v3.1-20260211 \
--texture-quality detailed \
--geometry-quality detailed \
--wait --download --out-dir assets/models/hover-bikeImage to 3D from a local threejs-image-generator concept:
python3 ~/.claude/skills/threejs-3d-generator/scripts/threejs_3d_asset.py image \
--image assets/concepts/hover-bike-front.png \
--model-version v3.1-20260211 \
--enable-image-autofix \
--texture-alignment original_image \
--texture-quality detailed \
--wait --download --out-dir assets/models/hover-bikeStatus and download:
python3 ~/.claude/skills/threejs-3d-generator/scripts/threejs_3d_asset.py status TASK_ID
python3 ~/.claude/skills/threejs-3d-generator/scripts/threejs_3d_asset.py download TASK_ID --out-dir assets/modelsTexture, rig, animate, or convert:
python3 ~/.claude/skills/threejs-3d-generator/scripts/threejs_3d_asset.py postprocess \
--type texture_model --original-task-id TASK_ID \
--texture-prompt "brushed gunmetal, orange hazard decals, worn edges" \
--wait --download --out-dir assets/models/retextured
python3 ~/.claude/skills/threejs-3d-generator/scripts/threejs_3d_asset.py postprocess \
--type animate_prerigcheck --original-task-id TASK_ID --wait
python3 ~/.claude/skills/threejs-3d-generator/scripts/threejs_3d_asset.py postprocess \
--type animate_rig --original-task-id TASK_ID --rig-type biped --spec tripo --wait
# animate_retarget takes the RIG task ID, not the generation task ID.
# Batch up to 5 presets per task via --animations.
# NEVER pass --animate-in-place: it corrupts the bake (mirrored limbs / exploded
# skinning). Strip root motion in the engine instead.
python3 ~/.claude/skills/threejs-3d-generator/scripts/threejs_3d_asset.py postprocess \
--type animate_retarget --original-task-id RIG_TASK_ID --model-version v2.5-20260210 \
--animations preset:idle,preset:walk,preset:run \
--wait --download --out-dir assets/models/animated
python3 ~/.claude/skills/threejs-3d-generator/scripts/threejs_3d_asset.py postprocess \
--type conversion --original-task-id TASK_ID --format GLTF \
--face-limit 20000 --wait --download --out-dir assets/models/gltf
python3 ~/.claude/skills/threejs-3d-generator/scripts/threejs_3d_asset.py postprocess \
--type stylize_model --original-task-id TASK_ID --style voxel \
--wait --download --out-dir assets/models/voxelAnimated character pipeline (generation -> prerigcheck -> validated rig with retries -> retargets -> downloads). The pipeline routes itself by body plan: biped characters automatically use the v1.0-20240301 anatomical rig with one FBX per animation (plain preset names are mapped onto the preset:biped:* library); creatures use the v2.5-20260210 rig with GLB clips:
python3 ~/.claude/skills/threejs-3d-generator/scripts/threejs_3d_asset.py character-pipeline \
--prompt "stylized cyber runner character, T-pose, full body, game-ready outfit, readable silhouette" \
--animations preset:idle,preset:walk,preset:run,preset:jump \
--out-dir assets/models/cyber-runner
# Creature example: stance language matters — generate in the pose the preset expects.
python3 ~/.claude/skills/threejs-3d-generator/scripts/threejs_3d_asset.py character-pipeline \
--prompt "stylized wolf, quadrupedal stance, all four legs planted and separated, full body" \
--rig-type quadruped --animations preset:quadruped:walk \
--out-dir assets/models/wolfThree.js Image Generator Pairing
Use threejs-image-generator before 3D generation when the asset benefits from a strong 2D reference:
- Character concept, full-body T-pose/A-pose, front/side/back variants.
- Building, prop, vehicle, weapon, pickup, enemy, obstacle, or terrain tile reference.
- Style sheet for a whole asset family.
- Texture references: terrain, rock, metal, fabric, decals, skyboxes, backgrounds, UI materials.
- Logos, faction marks, pickup icons, hazard signs, cockpit decals, HUD symbols, and GUI panels.
Load references/image-generator-workflows.md for prompt patterns before generating or editing 2D inputs.
Three.js Integration
Load references/threejs-integration.md before importing Tripo outputs into a browser game. In short:
- Prefer GLB/PBR outputs for Three.js.
- Use
GLTFLoaderfor loading. - Use
AnimationMixerfor rigged/animated GLBs. - Keep generated model files out of client-side API flows; generation is a tooling step.
- Inspect triangle count, texture count, material count, file size, scale, pivot, bounds, and animation clips.
- Use generated 3D assets as hero/high-fidelity content, then build surrounding prop kits procedurally or with additional
threejs-3d-generator/threejs-image-generatorpasses.
Rigging and Animation Reliability
Load references/api-notes.md for the full parameter tables. The rules that prevent most failures:
- Generate characters as one fused mesh: keep
--quadand--generate-partsoff (generate_partsdisables texturing;quadforces FBX output). - Require full-body T-pose or A-pose, arms away from body, symmetric, no props fused to the silhouette. Verify the rendered preview is actually in T/A-pose before rigging; regenerate if not.
- Run
animate_prerigcheckfirst (it takes no model version) and use the detectedrig_typeforanimate_rigand preset selection.riggable=falsemeans regenerate with a clearer pose, not force-rig. riggable=truedoes not guarantee a usable rig. After rigging, validate the skeleton before retargeting:threejs_3d_asset.py validate-rig rig-model.glb --rig-type biped(thecharacter-pipelinedoes this automatically). Check both presence AND chain depth: a rig with a 1-bone leg or 2-bone arm warps every clip.- Auto-rigging is nondeterministic. On validation failure, retry the rig task (~25 credits) before regenerating the model —
character-pipeline --rig-retries N(default 2) automates this, and--model-task-id TASK_IDresumes from an existing generation. Armored/hard-surface characters need the most retries; organic meshes usually rig first try. - Creatures get exactly one preset (walk/march). For multi-mode creatures (crawl + fly dragons), rig the same model twice — ground rig type for the locomotion preset,
avianfor wing chains — and drive wings procedurally in Three.js or via external clips on amixamo-spec rig. - Retargeted clips are named
NlaTrack,NlaTrack.001, … in request order — map by index and rename after import. - Rig version is the main quality lever, and it differs by body plan (measured June 2026). The
character-pipelineroutes this automatically; only override--rig-model-versiondeliberately: - HUMANOIDS:
v1.0-20240301(anatomical Mixamo-like skeleton with twist bones + the largepreset:biped:*clip library: idle, walk, run, slash, jump, dances, ...). The v2.x limb-chain rigger went 0/16 on humanoid meshes — armored or not, T-pose or A-pose — always producing asymmetric chains. - CREATURES:
v2.5-20260210(v2.x handles quadruped/avian well: symmetric 5-6 bone chains). - For v1.0 rigs, retarget with
--model-version default(omit the version): the retarget enum rejects explicitv1.0-20240301(HTTP 400 code 2017) but the server default handles v1.0 rigs. - v1.0 retargets must use
--out-format fbx(the script enforces this): Tripo's GLB bake on this path exports twist-bone transforms in the wrong space and limbs collapse into the torso — the FBX of the same task is correct. Load with three.jsFBXLoaderor convert offline. v2.5 creature retargets are fine as GLB. - Use
--spec tripo(default) when Tripo presets will be retargeted;--spec mixamorigs cannot be used with Tripo retarget and are only for external animation pipelines. animate_retargettakes the RIG task ID. Batch up to 5 presets per task with--animations.- Only 16 presets exist for v2.5 rigs (no
preset:attack; usepreset:slash/preset:shoot). Non-biped rig types have a single locomotion preset each; plan extra creature motion procedurally or via external retargeting. - A creature's MESH STANCE drives how presets read: a quadruped walk on an upright-standing dragon looks like a human walking. Generate creatures in the stance the animation expects (horizontal body, all fours planted) — the pipeline only auto-appends T-pose language for biped rigs.
- After download, run
threejs_3d_asset.py validate-animation clip.glb(keyframe QA: flags scale tracks, limb-stretching translation tracks, extreme rotations, and reports per-clip duration/channel coverage), then verify motion visually in the engine. - Never use
--animate-in-place(verified to corrupt clips: mirrored/crossed limbs on v1.0 rigs, exploded skinning on v2.5). Keep root motion baked and convert to in-place at import: zero only the HORIZONTAL components of the root bone's position track, keep vertical (jumps and gait bob live there). Gameplay code then drives locomotion. Exact snippet inthreejs-integration.md. - After download, inspect
gltf.animationsclip names and counts before wiring theAnimationMixer.
Quality Rules
- Improve the user's prompt with material, silhouette, camera/readability, scale, and game-use constraints.
- For riggable characters, include full-body T-pose or A-pose in the prompt or create a T-pose reference image first.
- For Three.js games, request GLB/PBR, reasonable face limits, and texture quality matched to the performance budget.
- For mobile/browser games, favor
smart_low_poly,face_limit, later conversion, or low-poly postprocess when the asset is too expensive. - Always download output URLs immediately after success.
- Report the credential probe output, reference ledger, task IDs, output paths, model version, texture/geometry settings, animations, conversion settings, Three.js import notes, and any missing/failed steps.
interface:
display_name: "Three.js 3D Generator"
short_description: "Generate, texture, rig, animate, and convert Three.js game assets"
default_prompt: "Generate a game-ready 3D asset for a Three.js game. First run the credential probe, load the required provider API reference, and load Three.js integration or image-generator workflow references when relevant. Use threejs-image-generator first if a 2D concept/reference/texture/logo would improve the result. Download the outputs, report credential probe output, reference ledger, task IDs, files, and Three.js import/performance notes."
Tripo API Notes
These notes summarize the official Tripo OpenAPI docs used by this skill.
Base API
- Base URL:
https://api.tripo3d.ai/v2/openapi - Auth:
Authorization: Bearer <TRIPO_API_KEY> - Task create:
POST /task - Task query:
GET /task/:task_id - Direct image upload:
POST /upload/stswithmultipart/form-data - STS token upload flow:
POST /upload/sts/token
Task responses use code and data. Treat nonzero code as an API error.
Task Status
Ongoing:
queuedrunning
Final:
successfailedbannedexpiredcancelledunknown
Query a task with the same API key that created it. Output download URLs usually expire after a few minutes, so download immediately.
Core Task Types
text_to_model: prompt to model.image_to_model: uploaded image token or image URL to model.multiview_to_model: four directional views to model.import_model: import an uploaded model file.texture_model: retexture or PBR texture an existing model task.stylize_model: stylize a model as LEGO, voxel, Voronoi, or Minecraft style when supported.animate_prerigcheck: check whether a model can be rigged. See "Rigging and Animation".animate_rig: auto-rig a model. See "Rigging and Animation".animate_retarget: retarget preset animations onto a rig task. See "Rigging and Animation".conversion: export/convert model format.mesh_segmentation: segment model parts.mesh_completion: complete selected model parts.highpoly_to_lowpoly: smart low-poly conversion.
Rigging and Animation
Chain: generation task -> animate_prerigcheck -> animate_rig -> animate_retarget. Each step has different task-ID and version semantics; getting them wrong is the main cause of silent rig/animation failures.
Source-model requirements
- Rig/retarget/prerigcheck accept source generation tasks with
model_versionTurbo-v1.0-20250506orv2.0-20240919and newer. 1.x generation tasks are not supported.v3.1-20260211works. - Generate characters as one fused mesh.
generate_partsis incompatible withtexture=true/pbr=trueand withquad=true, so a parts-segmented character cannot be textured and does not fit the rig pipeline. - Avoid
quad=truefor characters bound for GLB: quad forces FBX output and defaultsface_limitto 10000 when unset.
animate_prerigcheck
- Request takes only
typeandoriginal_model_task_id. There is nomodel_versionparameter. - Output:
riggable(bool) plus detectedrig_type(biped,quadruped,hexapod,octopod,avian,serpentine,aquatic). - Use the detected
rig_typeto choose theanimate_rigrig type and compatible presets. - Official docs note
riggable=false"doesn't necessarily mean it cannot be rigged". Treat false as a strong warning: the best response is to regenerate with a clearer full-body T-pose, not to force the rig.
animate_rig
Version choice is THE quality lever, and the right answer differs by body plan (measured June 2026):
- HUMANOIDS: use
model_version=v1.0-20240301. It produces a proper anatomical skeleton (Hip, Pelvis, Spine01/02, Head, L_/R_ Clavicle/Upperarm/Forearm/Hand/Thigh/Calf/Foot/ToeBase plus twist bones) and unlocks the largepreset:biped:*clip library. The v2.x "Limb chain" rigger went 0/16 on humanoid test meshes (plate armor, fitted armor, T-pose, A-pose — always asymmetric chains like a 9-bone arm vs 4, or a 1-bone leg), while v1.0 produced a clean symmetric skeleton first try with 39-40 channel clips. - CREATURES (quadruped/hexapod/octopod/avian/serpentine/aquatic): use
model_version=v2.5-20260210. The v2.x limb-chain rigger handles creatures well (symmetric 5-6 bone chains observed on quadruped and avian dragons). original_model_task_id: the generation task ID.rig_type: defaults tobiped. Pass the prerigcheck-detected type for creatures.spec:tripo(default) ormixamo. Onlyspec=triporigs can be used with Tripoanimate_retarget. Choosemixamoonly when retargeting external animation libraries (Mixamo/custom clips) outside Tripo.out_format:glb(default) orfbx.
Rig validation gate (mandatory before retargeting)
riggable=true from prerigcheck does NOT guarantee a usable rig. Auto-rigging can silently produce a degenerate skeleton (observed in practice: a biped rig with only 9 bones — Root, Spine_0-2, and one arm; no legs, no right arm, no head). Every retarget on such a rig inherits the damage: clips contain channels only for the mapped bones and the character stays frozen in bind pose at runtime.
After downloading the rig GLB and before spending retarget credits, validate the skeleton:
python3 .../threejs_3d_asset.py validate-rig path/to/rig-model.glb --rig-type bipedA structurally valid rig has both _Left_Limb_ and _Right_Limb_ chains with matching chain depths per limb row (±1 bone is normal variance; healthy rigs are 5/5, 6/6, or 6/5 — broken ones are 9/4, 2/4, or 4/1, and a 1-bone leg cannot bend a knee, warping every clip), at least 3 bones per limb chain for biped/quadruped, two limb rows (0_ arms, 1_ legs), and a plausible bone count. v1.0 rigs use anatomical names instead (validated as L_/R_ pairs: Clavicle, Upperarm, Forearm, Hand, Thigh, Calf, Foot). The character-pipeline command runs this check automatically; validate-rig runs it standalone, and validate-animation QAs the retargeted clips (scale tracks, limb-stretch translations, durations, channel coverage).
Auto-rigging is NONDETERMINISTIC: the same model can produce a degenerate skeleton on one attempt and a healthy one on the next. When validation fails, retry the rig task first (~25 credits) before regenerating the model (~30+ credits); character-pipeline --rig-retries N does this automatically (default 2). Hard-surface armored characters (plate knights, mechs) fail rig validation far more often than organic meshes — budget several retries or prefer visible-anatomy designs. If retries keep failing, regenerate the base model with clearer limb separation — strict T-pose, arms horizontal, legs apart and fully visible, no long skirt/tabard/cape/props fusing limbs into the body silhouette. Use character-pipeline --model-task-id TASK_ID to resume from an existing generation without paying for regeneration.
animate_retarget
original_model_task_id: the RIG task ID, not the original generation task ID.- For v2.5 rigs, pass
model_version: v2.5-20260210. For v1.0 rigs, OMIT model_version entirely (the CLI accepts--model-version default): the retarget enum rejects an explicitv1.0-20240301with HTTP 400code: 2017"The version value is invalid", but the server default retargets v1.0 rigs correctly. - Exactly one of
animation(single preset string) oranimations(array, max length 5) is required. Batch clips into one task instead of one task per clip; it is faster and cheaper. out_format:glb(default) orfbx.bake_animation: default true, glb only.export_with_geometry: default true.- v1.0 RIGS: ONE ANIMATION PER RETARGET TASK. Batching via
animationsmakes the FBX contain one armature object per clip (Armature.001,.002, …) with name-colliding bones; engines bind takes to the wrong skeleton and the body pitches sideways/prone. Submit one task per preset (10 credits each — batching saves nothing). - v1.0 RIGS MUST RETARGET WITH
out_format=fbx. Root cause (verified June 2026): Tripo's animation pipeline is FBX-native (their own tutorials are FBX end-to-end and never use GLB for animation). The GLB bake of v1.0 retargets exports twist-bone transforms in the wrong space — and the limb mesh is skinned almost entirely to twist bones, so arms/legs collapse into the torso in every engine (reproduced identically in three.js and Babylon; the FBX of the same task is correct, with real clip names likewalk_normal_m_remapinstead ofNlaTrack). Load FBX with three.jsFBXLoader, or convert FBX→GLB offline (Blender / FBX2glTF). v2.5 creature-rig retargets export GLB correctly. - NEVER set
animate_in_place=true. Verified June 2026: it corrupts the baked clips — on v1.0 rigs limbs mirror/cross sides (arms fold into the chest, legs cross the midline, "twisted opposite directions"), on v2.5 rigs skinned regions explode into shards. Keep root motion baked and strip the root translation track in the engine instead (seethreejs-integration.md). Forward-kinematics fingerprint of the corruption: in a walk clip, both hands sit on the same side of the hip instead of mirrored sides. - Set quality expectations honestly: Tripo's own blog describes auto-rigging as "80-90% of the way there" with shoulders, hips, and fingers needing the most manual refinement. For hero characters, plan a manual pass (or a Mixamo pipeline via
spec=mixamo+ their FBX clips). - A batched task returns one GLB containing all requested clips, named
NlaTrack,NlaTrack.001, … in request order. Map clips by index, not name. - Observed credits (June 2026): retarget costs ~10 credits per animation whether batched or separate (batch of 4 = 40); prerigcheck is free (0 credits); rig ~25; text_to_model with detailed texture ~30. Batching saves task count, not credits. Failed rig tasks consume 0 credits, but rig retries on success do — budget ~25 credits per retry when planning character work.
- Out-of-credit error: HTTP 403 with
code: 2010("You don't have enough credit"). Surface this to the user as a purchase/top-up blocker; do not retry.
Preset catalog (v2.5-20260210 / v2.0-20250506 rigs)
This is the complete documented list. There is no preset:attack; the attack-like presets are preset:slash and preset:shoot.
- Biped (11):
preset:idle,preset:walk,preset:run,preset:dive,preset:climb,preset:jump,preset:slash,preset:shoot,preset:hurt,preset:fall,preset:turn. - Quadruped:
preset:quadruped:walk. Hexapod:preset:hexapod:walk. Octopod:preset:octopod:walk. Serpentine:preset:serpentine:march. Aquatic:preset:aquatic:march. - Avian: no documented preset for v2.5 rigs.
- The large legacy
preset:biped:*library (~100 clips: dance variants, etc.) only works withv1.0-20240301rigs (biped only). Tradeoff: current-quality v2.5 rig with 16 presets vs legacy v1.0 rig with the big biped clip library.
Non-biped coverage is one locomotion clip per rig type — there is NO fly, crawl-variant, attack, idle, or hurt preset for creatures. Plan multi-mode creatures (e.g. a dragon that crawls AND flies) as:
- Ground locomotion: rig the creature's ground rig type (e.g. quadruped) and retarget its walk preset.
- Flight/secondary modes: rig the SAME model task again with
rig_type=avian(a second ~25-credit rig task on one model is allowed) to get wing limb chains, then drive wing bones procedurally in the engine (sinusoidal flap on the wing chain) or retarget external clips viaspec=mixamo. - Per-mode rigs are separate GLBs of the same mesh; swap or crossfade them at the engine level by game state.
Riggable-character generation recipe
- Prompt or reference image: full-body T-pose or A-pose, arms away from body, legs separated, front-facing, symmetric, no props fused into the silhouette, whole body in frame.
- The body must be anatomically complete, including a head. Over-constraining a prompt ("no cape, no shield, slim silhouette") can yield an empty/headless armor shell; the rig task then fails outright (
status: failed,error_code: 1001, no credits consumed). Check the rendered preview for a complete head-to-feet body before rigging. - Keep
texture/pbron,quadoff,generate_partsoff, and a saneface_limit. - Before rigging, check
rendered_imageactually shows a T/A-pose. If not, regenerate with a stronger pose instruction or athreejs-image-generatorT-pose reference image instead of rigging a bad pose. - For non-biped creatures, expect only the single locomotion preset for that rig type; plan extra animations via external retargeting (
spec=mixamo) or procedural motion in Three.js.
Common Output Fields
model: model download URL.base_model: untextured/base model URL.pbr_model: PBR model URL.rendered_image: preview image URL.generated_image: generated image URL.generate_multiview_image: front/left/back/right view URLs.
Game Defaults
- Prefer H3 text/image-to-model:
v3.1-20260211when quality matters. - Prefer GLB/PBR for Three.js runtime imports.
- Use
texture_quality=detailedfor high-value hero assets; usestandardfor background assets. - Use
geometry_quality=detailedonly when the asset needs it and budget allows. - Use
face_limit,smart_low_poly, conversion, or low-poly postprocess for browser/mobile budgets. - Use T-pose/A-pose references for rigged characters; keep
quadandgenerate_partsoff for anything that will be rigged. - Always pin
model_versionon rig/retarget tasks; the unpinned defaults are legacy versions.
Three.js Image Generator Pairing
Use threejs-image-generator when a strong 2D input improves threejs-3d-generator output or when the final asset is 2D rather than 3D. The current image provider is Google's Gemini image API.
2D To 3D Reference Images
Generate clean reference images before image-to-3D for:
- Characters: full-body T-pose or A-pose, neutral expression, visible hands/feet, no cropped limbs.
- Creatures: side/front silhouettes, clear limb count, readable anatomy.
- Vehicles: front/side/three-quarter concepts, clear wheels/thrusters/wings, material zones.
- Buildings: front elevation, roof silhouette, doors/windows, scale cues.
- Weapons/tools: side view, readable handle/blade/barrel proportions, material callouts.
- Props/pickups: centered object, plain background, strong silhouette, no text baked in unless wanted.
- Terrain/world modules: tileable rocks, cliffs, rails, gates, arena pieces, modular set dressing.
Prompt pattern:
Create a clean 3D-generation reference image of [asset]. Centered single object, plain light background, full object visible, readable silhouette, [style], [materials], no motion blur, no cropped parts.For riggable characters:
Create a full-body T-pose character reference for 3D rigging: [character details]. Arms out, legs visible, symmetrical stance, plain background, front-facing, readable costume layers.Texture And Material References
Use threejs-image-generator for:
- Terrain albedo references: rock, sand, mud, snow, moss, cracked asphalt.
- Sci-fi trim sheets, panel lines, decals, hazard stripes, window bands.
- Metal, leather, fabric, glass, ceramic, wood, painted plastic, worn armor.
- Sky, clouds, nebula, city haze, horizon plates, menu backgrounds.
- Faction marks, logos, numbers, signs, pickup icons, hazard labels.
Prompt pattern:
Create a seamless game texture reference for [surface], top-down/orthographic, no perspective, no strong shadows, PBR-friendly color, [style/material details].UI And Logo Use Cases
Use threejs-image-generator directly, not 3D generation, for:
- Logos and faction marks.
- HUD icons, item icons, ability icons, pickup symbols.
- Menu backgrounds and loading illustrations.
- Button/icon textures, decals, title art, achievement badges.
- 2D sky/backdrop cards when a 3D model is unnecessary.
3D Generator Handoff
After generating a 2D reference:
1. Save the image in the working project, usually assets/concepts/. 2. Use the threejs-3d-generator image command with --image <path>. 3. Use --enable-image-autofix for rough images. 4. Use --texture-alignment original_image when visual match matters. 5. Use --texture-alignment geometry when structural accuracy matters. 6. Download generated 3D outputs immediately after success.
Avoid
- Crowded scene images for single-object 3D generation.
- Cropped limbs, hidden backs, extreme perspective, motion blur, or heavy depth of field.
- Tiny UI/logo text in 3D model textures unless text fidelity is noncritical.
- Using 3D generation for pure 2D UI assets.
Three.js Integration
Use this after threejs-3d-generator generates or post-processes a model.
Preferred Outputs
- Three.js runtime: GLB/PBR model first.
- Animation/game-engine interchange: FBX when needed, then convert/import carefully.
- Static web asset exchange: GLTF/GLB.
- 3D print only: STL or 3MF, not for textured game runtime.
- Apple AR: USDZ.
Import Pattern
Use GLTFLoader:
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
const loader = new GLTFLoader();
const gltf = await loader.loadAsync('/assets/models/asset/model.glb');
scene.add(gltf.scene);For animation:
const mixer = new THREE.AnimationMixer(gltf.scene);
const action = mixer.clipAction(gltf.animations[0]);
action.play();
// in loop
mixer.update(deltaSeconds);Animation intake notes:
- A batched retarget returns ONE GLB whose clips are named
NlaTrack,NlaTrack.001, … in request order. The names carry no meaning: map clips to presets by index against the order you requested, rename them after load (clip.name = 'walk'), then select by your own names. - Log
gltf.animations.map(c =>${c.name} ${c.tracks.length} tracks)after load. A healthy humanoid clip drives many bones; clips with only a handful of tracks mean the upstream auto-rig was degenerate — fix the rig, not the runtime. - Do NOT strip or neutralize twist-bone tracks (
UpperarmTwist,ForearmTwist, …) on v1.0 rigs: Tripo skins most of the limb mesh to the twist bones, and their baked values differ greatly from the GLB rest pose — removing the tracks collapses the limbs into the torso. Tripo's slightly open, palm-forward hands are the preset house style, not corruption. - Never retarget with
animate_in_place=true(it corrupts the bake — mirrored limbs, exploded skinning). Convert to in-place at import instead, and do it precisely: - Touch ONLY the top root bone's position track (
Root.position). Tripo FBX clips bake position tracks on EVERY bone with values that differ from FBXLoader's rest transforms; filtering Hip/Pelvis or pattern-matching broadly collapses the skeleton into a hunch. - Zero the HORIZONTAL components only — keep Y. Vertical root motion IS the animation for jumps (and the bob in gaits); deleting the whole track turns a jump into grounded hand-waving.
for (const clip of clips) {
for (const tr of clip.tracks) {
if (tr.name !== 'Root.position') continue;
const v = tr.values, x0 = v[0], z0 = v[2];
for (let i = 0; i < v.length; i += 3) { v[i] = x0; v[i + 2] = z0; }
}
}FBX intake specifics (v1.0 humanoid retargets):
FBXLoaderlives atthree/addons/loaders/FBXLoader.jsand importsfflate; bundler projects get it from npm automatically, but import-map/CDN pages must map it (e.g."fflate": "https://cdn.jsdelivr.net/npm/fflate@0.8.2/esm/browser.js").- Use ONE FBX per animation (the pipeline does this automatically) and play each file's
animations[0]as exported. The variant order of duplicate takes differs per file —animations[0]is always the correct one.
FBXLoaderemits each take twice under different node-path prefixes (Armature.001|walk_…andArmature|Armature.001|walk_…). Keep the variant with the SHALLOWER path (fewer|segments); the deep variant's tracks bind incorrectly.- Clips carry real names (
walk_normal_m_remap,idle_251105_remap) — select by keyword (slash arrives as an attack/swing name), not by index. - Play the clips otherwise untouched. Every loader-side "fix" beyond the single root-position strip has been tested and makes things worse.
- FBXLoader produces Phong materials (darker than GLB PBR). For final art parity, convert FBX→GLB offline (Blender import/export or FBX2glTF) — the corrected animation survives conversion.
- Tripo preset retargeting only works on
spec=triporigs. Aspec=mixamorig is for external animation pipelines (Mixamo clips, custom libraries) — retarget those in a DCC tool or at runtime withSkeletonUtils.retargetClip, and expect to supply bone-name mappings and check bind-pose orientation when skeleton conventions differ.
Asset Intake Checklist
Inspect before shipping:
- File size.
- Triangle count.
- Mesh count.
- Material count.
- Texture count and texture dimensions.
- PBR material behavior under the game lighting rig.
- Scale in meters and
auto_sizeassumptions. - Pivot/origin and bounds.
- Collision proxy separate from detailed mesh.
- Animation clip names, durations, root motion, and in-place behavior.
- Mobile memory/performance impact.
Game Asset Strategy
- Use
threejs-3d-generatorfor hero assets, characters, creatures, bosses, buildings, weapons, signature props, and complex pickups. - Use procedural Three.js kits for high-volume repeated detail such as bolts, windows, track plates, rails, debris, markers, and background silhouettes.
- Use
threejs-image-generatorfor concept art, texture references, decals, logos, UI icons, and backdrop images. - Combine: image-generator concept -> 3D-generator model -> Three.js import -> procedural set dressing -> visual scorecard.
Performance Discipline
- Use
face_limit,smart_low_poly, conversion, or low-poly postprocess for browser/mobile budgets. - Prefer one high-fidelity hero asset plus instanced/procedural supporting detail over many unique heavy models.
- Keep textures compressed or reasonably sized.
- Clone carefully. Share geometry/materials when possible.
- Dispose loaded assets when leaving scenes.
- Run renderer diagnostics after importing: calls, triangles, geometries, textures, materials, file sizes.
Common Fixes
- Model too large/small: normalize bounds in an asset wrapper group.
- Wrong orientation: set a wrapper rotation, or use Tripo image
orientation=align_imagefor image inputs. - Animations move out of place: keep exported root motion intact, then strip only horizontal root-position components in code as shown above. Do not retarget with
animate_in_place=true. - Too expensive: lower
face_limit, usesmart_low_poly, reduce texture quality/size, or convert with a face limit. - Materials too dark/bright: check color space, tone mapping, environment, and light exposure.
- Collision too complex: build primitive proxies in Three.js and keep Tripo mesh visual-only.
Final Evidence
Report:
- 3D generator task IDs.
- Downloaded asset paths.
- Model version and generation options.
- Post-process tasks: texture, rig, animation, conversion.
- Three.js import files changed.
- Renderer diagnostics before/after import.
- Screenshot evidence in active gameplay.
#!/usr/bin/env python3
"""Small Tripo OpenAPI client for skill-driven 3D asset generation."""
from __future__ import annotations
import argparse
import json
import mimetypes
import os
from pathlib import Path
import sys
import time
from typing import Any
from urllib import error, parse, request
BASE_URL = "https://api.tripo3d.ai/v2/openapi"
FINAL_STATUSES = {"success", "failed", "banned", "expired", "cancelled", "unknown"}
DOWNLOAD_KEYS = (
"pbr_model",
"model",
"base_model",
"rendered_image",
"generated_image",
)
RIG_MODEL_VERSION = "v2.5-20260210"
BIPED_PRESETS = (
"preset:idle",
"preset:walk",
"preset:run",
"preset:dive",
"preset:climb",
"preset:jump",
"preset:slash",
"preset:shoot",
"preset:hurt",
"preset:fall",
"preset:turn",
)
RIG_TYPE_PRESETS = {
"biped": set(BIPED_PRESETS),
"quadruped": {"preset:quadruped:walk"},
"hexapod": {"preset:hexapod:walk"},
"octopod": {"preset:octopod:walk"},
"serpentine": {"preset:serpentine:march"},
"aquatic": {"preset:aquatic:march"},
"avian": set(),
}
KNOWN_PRESETS = set().union(*RIG_TYPE_PRESETS.values())
RETARGET_BATCH_LIMIT = 5
def validate_animations(animations: list[str], rig_type: str | None = None, rig_model_version: str | None = None) -> None:
"""Fail fast on presets the API will reject, before any credits are spent."""
# None = server default (used when retargeting v1.0 rigs); allow both namespaces then.
legacy = None if rig_model_version is None else rig_model_version.startswith("v1.0")
for animation in animations:
if not animation.startswith("preset:"):
continue
if animation.startswith("preset:biped:"):
# The large legacy clip library only works on v1.0-20240301 rigs.
if legacy is False:
raise TripoError(
f"{animation} belongs to the v1.0-20240301 rig's preset library; retarget "
"with --model-version default on a v1.0 rig to use it."
)
continue
if legacy:
raise TripoError(
f"{animation} is a v2.x preset; v1.0-20240301 rigs use the preset:biped:* "
"library instead (e.g. preset:biped:idle, preset:biped:walk, preset:biped:run)."
)
if animation not in KNOWN_PRESETS:
hint = ""
if "attack" in animation:
hint = " There is no preset:attack; use preset:slash or preset:shoot."
raise TripoError(
f"Unknown animation preset {animation!r}.{hint} "
f"Valid presets: {', '.join(sorted(KNOWN_PRESETS))}"
)
if rig_type and rig_type in RIG_TYPE_PRESETS and animation not in RIG_TYPE_PRESETS[rig_type]:
valid = ", ".join(sorted(RIG_TYPE_PRESETS[rig_type])) or "none documented for this rig type"
raise TripoError(
f"{animation} is not compatible with rig_type {rig_type!r}. Valid: {valid}"
)
class TripoError(RuntimeError):
pass
def eprint(*parts: object) -> None:
print(*parts, file=sys.stderr)
def api_key_from(args: argparse.Namespace) -> str:
key = args.api_key or os.environ.get("TRIPO_API_KEY")
if not key:
raise TripoError("Missing API key. Set TRIPO_API_KEY or pass --api-key.")
return key
def json_request(api_key: str, method: str, path: str, payload: dict[str, Any] | None = None) -> dict[str, Any]:
url = f"{BASE_URL}{path}"
body = json.dumps(payload).encode("utf-8") if payload is not None else None
req = request.Request(url, data=body, method=method)
req.add_header("Authorization", f"Bearer {api_key}")
if payload is not None:
req.add_header("Content-Type", "application/json")
try:
with request.urlopen(req, timeout=60) as resp:
raw = resp.read().decode("utf-8")
except error.HTTPError as exc:
raw = exc.read().decode("utf-8", errors="replace")
raise TripoError(f"HTTP {exc.code} {exc.reason}: {raw}") from exc
except error.URLError as exc:
raise TripoError(f"Request failed: {exc.reason}") from exc
data = json.loads(raw)
if data.get("code") != 0:
raise TripoError(json.dumps(data, indent=2))
return data
def multipart_upload(api_key: str, file_path: Path) -> str:
if not file_path.exists():
raise TripoError(f"Image not found: {file_path}")
if file_path.stat().st_size > 20 * 1024 * 1024:
raise TripoError("Tripo upload limit is 20MB.")
mime = mimetypes.guess_type(str(file_path))[0] or "application/octet-stream"
ext = file_path.suffix.lower().lstrip(".")
if ext == "jpg":
ext = "jpeg"
if ext not in {"png", "jpeg", "webp"}:
raise TripoError("Direct image upload accepts png, jpeg/jpg, or webp.")
boundary = f"tripo-boundary-{int(time.time() * 1000)}"
content = file_path.read_bytes()
parts = [
f"--{boundary}\r\n".encode(),
(
f'Content-Disposition: form-data; name="file"; filename="{file_path.name}"\r\n'
f"Content-Type: {mime}\r\n\r\n"
).encode(),
content,
f"\r\n--{boundary}--\r\n".encode(),
]
body = b"".join(parts)
req = request.Request(f"{BASE_URL}/upload/sts", data=body, method="POST")
req.add_header("Authorization", f"Bearer {api_key}")
req.add_header("Content-Type", f"multipart/form-data; boundary={boundary}")
req.add_header("Content-Length", str(len(body)))
try:
with request.urlopen(req, timeout=120) as resp:
raw = resp.read().decode("utf-8")
except error.HTTPError as exc:
raw = exc.read().decode("utf-8", errors="replace")
raise TripoError(f"Upload failed: HTTP {exc.code} {exc.reason}: {raw}") from exc
data = json.loads(raw)
if data.get("code") != 0:
raise TripoError(json.dumps(data, indent=2))
token = data.get("data", {}).get("image_token")
if not token:
raise TripoError(f"Upload response did not include image_token: {raw}")
return token
def submit_task(api_key: str, payload: dict[str, Any]) -> str:
data = json_request(api_key, "POST", "/task", payload)
task_id = data.get("data", {}).get("task_id")
if not task_id:
raise TripoError(f"Task response did not include task_id: {data}")
print(task_id)
return task_id
def get_task(api_key: str, task_id: str) -> dict[str, Any]:
return json_request(api_key, "GET", f"/task/{task_id}")["data"]
def wait_for_task(api_key: str, task_id: str, interval: int, timeout: int) -> dict[str, Any]:
start = time.monotonic()
while True:
data = get_task(api_key, task_id)
status = data.get("status", "unknown")
progress = data.get("progress", 0)
eprint(f"{task_id}: {status} {progress}%")
if status in FINAL_STATUSES:
return data
if time.monotonic() - start > timeout:
raise TripoError(f"Timed out waiting for task {task_id}")
time.sleep(interval)
def safe_name(value: str) -> str:
keep = []
for char in value.lower():
if char.isalnum():
keep.append(char)
elif char in {"-", "_", " "}:
keep.append("-")
name = "".join(keep).strip("-")
while "--" in name:
name = name.replace("--", "-")
return name or "asset"
def extension_for(key: str, url: str, content_type: str | None = None) -> str:
path = parse.urlparse(url).path
ext = Path(path).suffix
if ext:
return ext
if content_type:
guessed = mimetypes.guess_extension(content_type.split(";")[0].strip())
if guessed:
return guessed
if "image" in key:
return ".png"
return ".glb"
def download_url(url: str, out_dir: Path, filename_base: str, key: str) -> Path:
req = request.Request(url, method="GET")
with request.urlopen(req, timeout=300) as resp:
content = resp.read()
content_type = resp.headers.get("Content-Type")
ext = extension_for(key, url, content_type)
out_dir.mkdir(parents=True, exist_ok=True)
path = out_dir / f"{filename_base}-{key}{ext}"
path.write_bytes(content)
return path
def download_outputs(task: dict[str, Any], out_dir: Path) -> list[Path]:
task_id = task["task_id"]
output = task.get("output") or {}
paths: list[Path] = []
out_dir.mkdir(parents=True, exist_ok=True)
(out_dir / f"{task_id}.json").write_text(json.dumps(task, indent=2), encoding="utf-8")
for key in DOWNLOAD_KEYS:
url = output.get(key)
if isinstance(url, str) and url.startswith("http"):
path = download_url(url, out_dir, task_id, key)
paths.append(path)
print(path)
multiview = output.get("generate_multiview_image")
if isinstance(multiview, dict):
for key, url in multiview.items():
if isinstance(url, str) and url.startswith("http"):
path = download_url(url, out_dir, task_id, key)
paths.append(path)
print(path)
if not paths:
eprint("No downloadable output URLs found.")
return paths
def glb_node_names(path: Path) -> list[str]:
import struct
with path.open("rb") as handle:
header = handle.read(12)
if len(header) < 12 or header[:4] != b"glTF":
raise TripoError(f"Not a GLB file: {path}")
chunk_len, _chunk_type = struct.unpack("<II", handle.read(8))
gltf = json.loads(handle.read(chunk_len))
return [node.get("name", "") for node in gltf.get("nodes", []) if node.get("name")]
def glb_bone_names(path: Path) -> list[str]:
return [name for name in glb_node_names(path) if name.startswith("tripo::")]
LEGACY_BIPED_PAIRED_BONES = ("Clavicle", "Upperarm", "Forearm", "Hand", "Thigh", "Calf", "Foot")
def validate_rig_glb(path: Path, rig_type: str) -> tuple[str, list[str]]:
"""Validate either rig naming scheme. v2.x rigs use tripo::<row>_<side>_Limb_<n>
chains; v1.0-20240301 rigs use an anatomical Mixamo-like skeleton (Hip, Spine01,
L_Upperarm, R_Calf, twist bones, ...)."""
names = glb_node_names(path)
bones = [n for n in names if n.startswith("tripo::")]
if bones:
return f"{len(bones)} tripo:: bones: {', '.join(sorted(bones))}", validate_rig_bones(bones, rig_type)
left = {n[2:] for n in names if n.startswith("L_")}
right = {n[2:] for n in names if n.startswith("R_")}
if not left and not right:
return "no recognizable rig bones", ["no tripo:: or legacy L_/R_ bones found in rig GLB"]
problems = []
for part in LEGACY_BIPED_PAIRED_BONES:
if part not in left or part not in right:
problems.append(f"legacy rig missing L_/R_ {part}")
if left != right:
problems.append(f"legacy rig asymmetric bones: {sorted(left.symmetric_difference(right))}")
if rig_type != "biped":
problems.append(f"legacy anatomical skeleton is biped-only; requested rig_type {rig_type!r}")
return f"legacy anatomical skeleton, {len(left)} paired L/R bones: {', '.join(sorted(left))}", problems
def validate_rig_bones(bones: list[str], rig_type: str) -> list[str]:
"""Return problems with a downloaded rig's skeleton. A passing prerigcheck does
not guarantee a usable rig: degenerate rigs (e.g. spine plus one arm) do occur,
and every later retarget inherits the damage."""
problems: list[str] = []
if not bones:
problems.append("no tripo:: bones found in rig GLB")
return problems
rows: dict[str, dict[str, int]] = {}
for bone in bones:
if "_Limb_" not in bone:
continue
row, side = bone.split("::")[-1].split("_")[0:2]
rows.setdefault(row, {}).setdefault(side, 0)
rows[row][side] += 1
for row, sides in sorted(rows.items()):
if set(sides) != {"Left", "Right"}:
problems.append(f"limb row {row} has only {'/'.join(sorted(sides))} (asymmetric rig)")
continue
left, right = sides["Left"], sides["Right"]
# A knee-less leg or elbow-less arm warps every retargeted clip. Healthy
# Tripo rigs are depth-symmetric (e.g. 5/5, 6/6, occasionally 6/5); broken
# ones are 2/4, 9/4, or 4/1. Tolerate a 1-bone difference.
if abs(left - right) > 1:
problems.append(f"limb row {row} chain depth mismatch: Left={left} vs Right={right}")
if rig_type in {"biped", "quadruped"} and min(left, right) < 3:
problems.append(f"limb row {row} chain too shallow ({min(left, right)} bones; need >=3 for joint articulation)")
if rig_type in {"biped", "quadruped"} and len(rows) < 2:
problems.append(f"only {len(rows)} limb row(s); {rig_type} needs arms and legs (2 rows)")
if rig_type == "biped" and not any(b.startswith("tripo::Head") for b in bones) and len(bones) < 12:
problems.append(f"suspiciously small skeleton ({len(bones)} bones)")
return problems
def add_common_model_args(parser: argparse.ArgumentParser) -> None:
parser.add_argument("--model-version", default="v3.1-20260211")
parser.add_argument("--negative-prompt")
parser.add_argument("--model-seed", type=int)
parser.add_argument("--image-seed", type=int)
parser.add_argument("--texture-seed", type=int)
parser.add_argument("--texture-quality", choices=["standard", "detailed", "extreme"])
parser.add_argument("--geometry-quality", choices=["standard", "detailed"])
parser.add_argument("--face-limit", type=int)
parser.add_argument("--no-texture", action="store_true")
parser.add_argument("--no-pbr", action="store_true")
parser.add_argument("--smart-low-poly", action="store_true")
parser.add_argument("--quad", action="store_true")
parser.add_argument("--auto-size", action="store_true")
parser.add_argument("--compress", choices=["geometry", "meshopt"])
parser.add_argument("--generate-parts", action="store_true")
parser.add_argument("--no-export-uv", action="store_true")
def apply_common_model_args(args: argparse.Namespace, payload: dict[str, Any]) -> None:
mapping = {
"negative_prompt": args.negative_prompt,
"model_seed": args.model_seed,
"image_seed": args.image_seed,
"texture_seed": args.texture_seed,
"texture_quality": args.texture_quality,
"geometry_quality": args.geometry_quality,
"face_limit": args.face_limit,
"smart_low_poly": True if args.smart_low_poly else None,
"quad": True if args.quad else None,
"auto_size": True if args.auto_size else None,
"compress": args.compress,
"generate_parts": True if args.generate_parts else None,
}
for key, value in mapping.items():
if value is not None:
payload[key] = value
if args.no_texture:
payload["texture"] = False
if args.no_pbr:
payload["pbr"] = False
if args.no_export_uv:
payload["export_uv"] = False
def maybe_wait_and_download(api_key: str, task_id: str, args: argparse.Namespace) -> dict[str, Any] | None:
if not args.wait:
return None
task = wait_for_task(api_key, task_id, args.interval, args.timeout)
print(json.dumps(task, indent=2))
if task.get("status") != "success":
raise TripoError(f"Task {task_id} ended as {task.get('status')}")
if args.download:
download_outputs(task, Path(args.out_dir))
return task
def cmd_text(args: argparse.Namespace) -> None:
api_key = api_key_from(args)
payload: dict[str, Any] = {
"type": "text_to_model",
"prompt": args.prompt,
"model_version": args.model_version,
}
apply_common_model_args(args, payload)
task_id = submit_task(api_key, payload)
maybe_wait_and_download(api_key, task_id, args)
def cmd_image(args: argparse.Namespace) -> None:
api_key = api_key_from(args)
image = args.image
if image.startswith(("http://", "https://")):
file_obj = {"type": "image", "url": image}
else:
token = multipart_upload(api_key, Path(image))
file_obj = {"type": "image", "file_token": token}
payload: dict[str, Any] = {
"type": "image_to_model",
"file": file_obj,
"model_version": args.model_version,
}
if args.enable_image_autofix:
payload["enable_image_autofix"] = True
if args.texture_alignment:
payload["texture_alignment"] = args.texture_alignment
if args.orientation:
payload["orientation"] = args.orientation
apply_common_model_args(args, payload)
task_id = submit_task(api_key, payload)
maybe_wait_and_download(api_key, task_id, args)
def cmd_status(args: argparse.Namespace) -> None:
api_key = api_key_from(args)
print(json.dumps(get_task(api_key, args.task_id), indent=2))
def cmd_download(args: argparse.Namespace) -> None:
api_key = api_key_from(args)
task = get_task(api_key, args.task_id)
if task.get("status") != "success":
raise TripoError(f"Task is {task.get('status')}; download URLs are available after success.")
download_outputs(task, Path(args.out_dir))
def normalized_post_type(value: str) -> str:
aliases = {
"convert_model": "conversion",
"conversion": "conversion",
"retarget": "animate_retarget",
"rig": "animate_rig",
"prerig": "animate_prerigcheck",
"prerigcheck": "animate_prerigcheck",
"lowpoly": "highpoly_to_lowpoly",
}
return aliases.get(value, value)
def cmd_postprocess(args: argparse.Namespace) -> None:
api_key = api_key_from(args)
task_type = normalized_post_type(args.type)
payload: dict[str, Any] = {
"type": task_type,
"original_model_task_id": args.original_task_id,
}
default_versions = {
"texture_model": "v3.0-20250812",
"animate_rig": RIG_MODEL_VERSION,
"animate_retarget": RIG_MODEL_VERSION,
"highpoly_to_lowpoly": "P-v2.0-20251225",
}
if task_type == "animate_prerigcheck":
# The prerigcheck request schema has no model_version parameter.
if args.model_version:
eprint("warning: animate_prerigcheck takes no model_version; ignoring it")
elif args.model_version and args.model_version.lower() in {"default", "none"}:
# Omit model_version and let the API choose (needed when retargeting v1.0 rigs:
# the retarget enum rejects v1.0-20240301 as an explicit value).
pass
else:
model_version = args.model_version or default_versions.get(task_type)
if model_version:
payload["model_version"] = model_version
if task_type == "texture_model":
if not args.texture_prompt:
raise TripoError("--texture-prompt is required for texture_model")
payload["texture_prompt"] = {"text": args.texture_prompt}
if args.texture_quality:
payload["texture_quality"] = args.texture_quality
elif task_type == "animate_rig":
if args.out_format:
payload["out_format"] = args.out_format
if args.rig_type:
payload["rig_type"] = args.rig_type
if args.spec:
payload["spec"] = args.spec
elif task_type == "animate_retarget":
# original_model_task_id must be the RIG task ID for retarget.
# v1.0 rigs (legacy humanoid path): the GLB animation bake is DEFECTIVE —
# limb mesh is skinned to twist bones whose GLB transforms are exported in
# the wrong space, collapsing arms into the torso (verified June 2026; the
# FBX export of the same task is correct). Force FBX on this path.
legacy_retarget = "model_version" not in payload
if legacy_retarget and (args.out_format or "glb") != "fbx":
if args.out_format == "glb":
raise TripoError(
"v1.0-rig retargets must use --out-format fbx: Tripo's GLB bake corrupts "
"twist-bone transforms (limbs collapse into the torso). Load the FBX with "
"three.js FBXLoader, or convert FBX->GLB offline (Blender/FBX2glTF)."
)
eprint("note: v1.0-rig retarget defaults to out_format=fbx (GLB bake is broken for this path)")
payload["out_format"] = "fbx"
if legacy_retarget and args.animations:
raise TripoError(
"v1.0-rig retargets must request ONE animation per task: batching produces an FBX "
"with one armature per clip (Armature.001, .002, ...) whose name-colliding bones "
"bind to the wrong skeleton and pitch the body. Submit separate tasks with --animation."
)
if args.animations:
animations = [item.strip() for item in args.animations.split(",") if item.strip()]
validate_animations(animations, rig_model_version=payload.get("model_version"))
if len(animations) > RETARGET_BATCH_LIMIT:
raise TripoError(
f"animate_retarget accepts at most {RETARGET_BATCH_LIMIT} animations per task; "
f"got {len(animations)}. Split into multiple tasks."
)
payload["animations"] = animations
elif args.animation:
validate_animations([args.animation], rig_model_version=payload.get("model_version"))
payload["animation"] = args.animation
else:
raise TripoError("--animation or --animations is required for animate_retarget")
if args.out_format:
payload["out_format"] = args.out_format
if args.animate_in_place:
eprint(
"warning: animate_in_place is VERIFIED to corrupt retargeted clips "
"(mirrored/crossed limbs on v1.0 rigs, exploded skinning on v2.5 rigs, June 2026). "
"Prefer baked root motion and strip the root translation track in the engine."
)
payload["animate_in_place"] = True
if args.no_bake_animation:
payload["bake_animation"] = False
if args.no_export_with_geometry:
payload["export_with_geometry"] = False
elif task_type == "conversion":
if not args.format:
raise TripoError("--format is required for conversion")
payload["format"] = args.format
for key in ("face_limit", "texture_size", "flatten_bottom_threshold"):
value = getattr(args, key)
if value is not None:
payload[key] = value
if args.quad:
payload["quad"] = True
if args.force_symmetry:
payload["force_symmetry"] = True
if args.flatten_bottom:
payload["flatten_bottom"] = True
elif task_type == "highpoly_to_lowpoly":
if args.face_limit:
payload["face_limit"] = args.face_limit
elif task_type == "stylize_model":
if not args.style:
raise TripoError("--style is required for stylize_model")
payload["style"] = args.style
if args.block_size:
payload["block_size"] = args.block_size
task_id = submit_task(api_key, payload)
maybe_wait_and_download(api_key, task_id, args)
def resolve_rig_version(rig_type: str, override: str | None) -> str:
"""Measured June 2026: the v2.x limb-chain rigger fails on humanoids (0/16,
asymmetric chains) while v1.0 produces a proper anatomical skeleton; v2.x is
solid for creatures. Route by body plan unless explicitly overridden."""
if override:
return override
return "v1.0-20240301" if rig_type == "biped" else RIG_MODEL_VERSION
def to_legacy_biped_presets(animations: list[str]) -> list[str]:
"""v1.0 rigs use the preset:biped:* library; map plain v2.5-style names onto it
so callers can say preset:idle regardless of which rig path gets chosen."""
mapped = []
for animation in animations:
suffix = animation[len("preset:"):] if animation.startswith("preset:") else None
if suffix and ":" not in suffix:
mapped.append(f"preset:biped:{suffix}")
else:
mapped.append(animation)
return mapped
def cmd_character_pipeline(args: argparse.Namespace) -> None:
api_key = api_key_from(args)
if not args.model_task_id and not args.prompt:
raise TripoError("--prompt is required unless --model-task-id reuses an existing generation task")
animations = [item.strip() for item in args.animations.split(",") if item.strip()]
if animations and args.spec == "mixamo":
raise TripoError(
"spec=mixamo rigs cannot be used with Tripo animate_retarget. "
"Use --animations '' and retarget external clips (e.g. Mixamo) onto the rig yourself, "
"or keep --spec tripo for Tripo preset animations."
)
# Loose catalog check now (both namespaces allowed); strict per-rig-type
# validation happens after prerigcheck, before rig credits are spent.
validate_animations(animations, None, None)
out_dir = Path(args.out_dir)
if args.model_task_id:
model_task_id = args.model_task_id
eprint(f"Skipping generation; reusing model task {model_task_id}")
else:
prompt = args.prompt
# The T-pose suffix is biped-specific; creature prompts need their own stance language.
if (args.rig_type in (None, "biped")
and "t-pose" not in prompt.lower() and "a-pose" not in prompt.lower()):
prompt = (
f"{prompt}, full-body T-pose for rigging, arms straight out to the sides, "
"legs apart and visible, front facing, symmetric, no props attached to the body"
)
text_payload: dict[str, Any] = {
"type": "text_to_model",
"prompt": prompt,
"model_version": args.model_version,
"texture_quality": args.texture_quality,
"geometry_quality": args.geometry_quality,
"pbr": True,
}
if args.face_limit:
text_payload["face_limit"] = args.face_limit
model_task_id = submit_task(api_key, text_payload)
model_task = wait_for_task(api_key, model_task_id, args.interval, args.timeout)
if model_task.get("status") != "success":
raise TripoError(f"Model task failed: {model_task.get('status')}")
download_outputs(model_task, out_dir / "base")
eprint("Check the downloaded rendered_image: the character must be in a clear T/A-pose before rigging.")
check_id = submit_task(api_key, {
"type": "animate_prerigcheck",
"original_model_task_id": model_task_id,
})
check_task = wait_for_task(api_key, check_id, args.interval, args.timeout)
print(json.dumps(check_task, indent=2))
output = check_task.get("output") or {}
if output.get("riggable") is False:
if not args.force_rig:
raise TripoError(
f"Prerigcheck reports the model is not riggable: {output}. "
"Best fix: regenerate with a clearer full-body T-pose (or a T-pose reference image). "
"Tripo docs note a false result is not always final; pass --force-rig to attempt anyway."
)
eprint("warning: proceeding despite riggable=false (--force-rig)")
detected_rig_type = output.get("rig_type")
rig_type = args.rig_type or detected_rig_type or "biped"
if args.rig_type and detected_rig_type and args.rig_type != detected_rig_type:
eprint(
f"warning: prerigcheck detected rig_type={detected_rig_type} "
f"but --rig-type {args.rig_type} was requested; using {args.rig_type}"
)
rig_model_version = resolve_rig_version(rig_type, args.rig_model_version)
legacy = rig_model_version.startswith("v1.0")
if legacy:
animations = to_legacy_biped_presets(animations)
eprint(f"Using rig_type={rig_type} rig_model_version={rig_model_version}"
+ (f" animations={','.join(animations)}" if animations else ""))
validate_animations(animations, rig_type if not legacy else None, rig_model_version)
# Auto-rigging is nondeterministic: the same model can produce a degenerate
# skeleton on one attempt and a healthy one on the next. Retry before giving up.
attempts = 1 + max(0, args.rig_retries)
rig_id = None
last_detail = "no rig attempt succeeded"
for attempt in range(1, attempts + 1):
candidate_id = submit_task(api_key, {
"type": "animate_rig",
"original_model_task_id": model_task_id,
"model_version": rig_model_version,
"rig_type": rig_type,
"spec": args.spec,
"out_format": "glb",
})
rig_task = wait_for_task(api_key, candidate_id, args.interval, args.timeout)
if rig_task.get("status") != "success":
last_detail = f"rig task ended as {rig_task.get('status')} (error_code={rig_task.get('error_code')})"
eprint(f"rig attempt {attempt}/{attempts}: {last_detail}")
continue
suffix = "rig" if attempt == 1 else f"rig-attempt{attempt}"
rig_paths = download_outputs(rig_task, out_dir / suffix)
rig_glbs = [p for p in rig_paths if p.suffix == ".glb"]
problems = []
if rig_glbs:
description, problems = validate_rig_glb(rig_glbs[0], rig_type)
eprint(f"Rig skeleton ({attempt}/{attempts}): {description}")
if not problems:
rig_id = candidate_id
break
last_detail = "; ".join(problems)
eprint(f"rig attempt {attempt}/{attempts} failed validation: {last_detail}")
if rig_id is None:
if not args.force_rig:
raise TripoError(
f"No structurally valid rig after {attempts} attempt(s) ({last_detail}). "
"Retargets on a bad rig warp the character. Regenerate the base model with "
"clearer limb separation (strict T-pose, arms horizontal, legs apart and visible, "
"no long skirt/cape/props fusing limbs to the body), or pass --force-rig to use "
"the last rig anyway."
)
rig_id = candidate_id
eprint(f"warning: using unvalidated rig ({last_detail}); continuing (--force-rig)")
# Retarget references the RIG task ID. v2.5 rigs batch up to 5 presets per
# task; v1.0 rigs must take ONE per task (batched FBX exports one armature
# per clip whose name-colliding bones cross-bind and pitch the body).
batch_size = 1 if legacy else RETARGET_BATCH_LIMIT
for start in range(0, len(animations), batch_size):
batch = animations[start:start + batch_size]
# v1.0 GLB animation bake is defective (twist-bone space bug collapses limbs);
# the FBX export of the same task is correct. See api-notes.md.
retarget_payload: dict[str, Any] = {
"type": "animate_retarget",
"original_model_task_id": rig_id,
"animations": batch,
"out_format": "fbx" if legacy else "glb",
}
if not legacy:
retarget_payload["model_version"] = rig_model_version
if args.animate_in_place:
eprint(
"warning: animate_in_place is VERIFIED to corrupt retargeted clips "
"(mirrored/crossed limbs on v1.0 rigs, exploded skinning on v2.5 rigs, June 2026). "
"Prefer baked root motion and strip the root translation track in the engine."
)
retarget_payload["animate_in_place"] = True
anim_id = submit_task(api_key, retarget_payload)
anim_task = wait_for_task(api_key, anim_id, args.interval, args.timeout)
if anim_task.get("status") != "success":
raise TripoError(f"Animation batch {batch} failed: {anim_task.get('status')}")
batch_name = safe_name("-".join(item.split(":")[-1] for item in batch))
download_outputs(anim_task, out_dir / batch_name)
eprint("Inspect gltf.animations clip names/counts in each download before wiring the AnimationMixer.")
def load_glb(path: Path) -> tuple[dict[str, Any], bytes]:
import struct
data = path.read_bytes()
if data[:4] != b"glTF":
raise TripoError(f"Not a GLB file: {path}")
offset = 12
gltf: dict[str, Any] | None = None
bin_chunk = b""
while offset < len(data):
clen, ctype = struct.unpack_from("<II", data, offset)
chunk = data[offset + 8:offset + 8 + clen]
if ctype == 0x4E4F534A:
gltf = json.loads(chunk)
elif ctype == 0x004E4942:
bin_chunk = chunk
offset += 8 + clen
if gltf is None:
raise TripoError(f"No JSON chunk in GLB: {path}")
return gltf, bin_chunk
def _read_accessor(gltf: dict[str, Any], bin_chunk: bytes, idx: int) -> list[tuple[float, ...]]:
import struct
comp = {5126: ("f", 4), 5123: ("H", 2), 5125: ("I", 4)}
ncomp = {"SCALAR": 1, "VEC3": 3, "VEC4": 4}
acc = gltf["accessors"][idx]
bv = gltf["bufferViews"][acc["bufferView"]]
start = bv.get("byteOffset", 0) + acc.get("byteOffset", 0)
n = ncomp[acc["type"]]
fmt, _ = comp[acc["componentType"]]
count = acc["count"]
vals = struct.unpack_from(f"<{count * n}{fmt}", bin_chunk, start)
return [vals[i * n:(i + 1) * n] for i in range(count)]
def validate_animation_glb(path: Path) -> tuple[list[str], list[str]]:
"""Keyframe-level QA for retargeted clips. Returns (report_lines, problems).
Warp signatures: scale tracks, or translation tracks on non-root bones that
deviate far from the bone's rest offset (limb stretching)."""
import math
gltf, bin_chunk = load_glb(path)
nodes = gltf.get("nodes", [])
roots = {"Armature", "Root", "Hip", "Pelvis", "tripo::Root"}
report: list[str] = []
problems: list[str] = []
animations = gltf.get("animations", [])
if not animations:
return ["no animations in file"], ["no animation clips found"]
for anim in animations:
dur = 0.0
rot_bones: set[str] = set()
big_rot: dict[str, int] = {}
for ch in anim["channels"]:
sampler = anim["samplers"][ch["sampler"]]
times = _read_accessor(gltf, bin_chunk, sampler["input"])
out = _read_accessor(gltf, bin_chunk, sampler["output"])
node = nodes[ch["target"]["node"]] if ch["target"].get("node") is not None else {}
name = node.get("name", "?")
dur = max(dur, times[-1][0])
path_kind = ch["target"]["path"]
if path_kind == "rotation":
rot_bones.add(name)
base = out[0]
amp = 0.0
for q in out:
dot = abs(sum(a * b for a, b in zip(base, q)))
amp = max(amp, 2 * math.acos(min(1.0, dot)))
if math.degrees(amp) > 170:
big_rot[name] = round(math.degrees(amp))
elif path_kind == "scale":
problems.append(f"{anim.get('name')}: scale track on {name} (warp risk)")
elif path_kind == "translation" and name not in roots and name.split("::")[-1] not in roots:
rest = node.get("translation", [0, 0, 0])
restlen = math.sqrt(sum(c * c for c in rest)) or 1e-9
dev = max(math.sqrt(sum((v[i] - rest[i]) ** 2 for i in range(3))) for v in out)
if dev / restlen > 0.5:
problems.append(
f"{anim.get('name')}: translation track on non-root bone {name} deviates "
f"{dev / restlen:.1f}x its rest offset (limb stretch warp)"
)
report.append(f"{anim.get('name')}: {dur:.2f}s, {len(anim['channels'])} channels, {len(rot_bones)} bones rotating")
if big_rot:
report.append(f" rotation amplitude >170deg (check visually): {big_rot}")
return report, problems
def cmd_validate_animation(args: argparse.Namespace) -> None:
report, problems = validate_animation_glb(Path(args.glb_path))
for line in report:
print(line)
if problems:
raise TripoError("Animation validation failed: " + "; ".join(problems))
print("Clips look structurally sound (verify motion visually in the engine).")
def cmd_validate_rig(args: argparse.Namespace) -> None:
description, problems = validate_rig_glb(Path(args.glb_path), args.rig_type)
print(description)
if problems:
raise TripoError("Rig validation failed: " + "; ".join(problems))
print("Rig looks structurally valid.")
def add_shared_runtime_args(parser: argparse.ArgumentParser) -> None:
parser.add_argument("--api-key")
parser.add_argument("--wait", action="store_true")
parser.add_argument("--download", action="store_true")
parser.add_argument("--out-dir", default="tripo-output")
parser.add_argument("--interval", type=int, default=8)
parser.add_argument("--timeout", type=int, default=600)
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="Tripo OpenAPI 3D asset helper")
sub = parser.add_subparsers(dest="command", required=True)
text = sub.add_parser("text", help="submit text_to_model")
text.add_argument("--prompt", required=True)
add_common_model_args(text)
add_shared_runtime_args(text)
text.set_defaults(func=cmd_text)
image = sub.add_parser("image", help="submit image_to_model from local path or URL")
image.add_argument("--image", required=True)
image.add_argument("--enable-image-autofix", action="store_true")
image.add_argument("--texture-alignment", choices=["original_image", "geometry"])
image.add_argument("--orientation", choices=["default", "align_image"])
add_common_model_args(image)
add_shared_runtime_args(image)
image.set_defaults(func=cmd_image)
status = sub.add_parser("status", help="get task status")
status.add_argument("task_id")
status.add_argument("--api-key")
status.set_defaults(func=cmd_status)
download = sub.add_parser("download", help="download successful task outputs")
download.add_argument("task_id")
download.add_argument("--api-key")
download.add_argument("--out-dir", default="tripo-output")
download.set_defaults(func=cmd_download)
post = sub.add_parser("postprocess", help="submit Tripo postprocess task")
post.add_argument("--type", required=True)
post.add_argument("--original-task-id", required=True)
post.add_argument("--model-version")
post.add_argument("--texture-prompt")
post.add_argument("--texture-quality", choices=["standard", "detailed", "extreme"])
post.add_argument("--out-format", choices=["glb", "fbx"])
post.add_argument("--rig-type", choices=["biped", "quadruped", "hexapod", "octopod", "avian", "serpentine", "aquatic"])
post.add_argument("--spec", choices=["tripo", "mixamo"])
post.add_argument("--animation")
post.add_argument("--animations")
post.add_argument("--animate-in-place", action="store_true")
post.add_argument("--no-bake-animation", action="store_true")
post.add_argument("--no-export-with-geometry", action="store_true")
post.add_argument("--format", choices=["GLTF", "USDZ", "FBX", "OBJ", "STL", "3MF"])
post.add_argument("--face-limit", type=int)
post.add_argument("--texture-size", type=int)
post.add_argument("--quad", action="store_true")
post.add_argument("--force-symmetry", action="store_true")
post.add_argument("--flatten-bottom", action="store_true")
post.add_argument("--flatten-bottom-threshold", type=float)
post.add_argument("--style", choices=["lego", "voxel", "voronoi", "minecraft"])
post.add_argument("--block-size", type=int)
add_shared_runtime_args(post)
post.set_defaults(func=cmd_postprocess)
validate = sub.add_parser("validate-rig", help="check a downloaded rig GLB for degenerate auto-rig skeletons")
validate.add_argument("glb_path")
validate.add_argument("--rig-type", default="biped", choices=["biped", "quadruped", "hexapod", "octopod", "avian", "serpentine", "aquatic"])
validate.set_defaults(func=cmd_validate_rig)
validate_anim = sub.add_parser("validate-animation", help="keyframe-level QA for retargeted clip GLBs (warp signatures)")
validate_anim.add_argument("glb_path")
validate_anim.set_defaults(func=cmd_validate_animation)
pipeline = sub.add_parser("character-pipeline", help="generate, prereig-check, rig, animate, and download a character")
pipeline.add_argument("--prompt")
pipeline.add_argument("--model-task-id",
help="reuse an existing generation task instead of generating (skips --prompt)")
pipeline.add_argument("--rig-retries", type=int, default=2,
help="extra rig attempts when validation fails; rigging is nondeterministic (default 2)")
pipeline.add_argument("--rig-model-version", default=None,
help="rig model version override. Default: auto by rig type — "
"biped -> v1.0-20240301 (anatomical skeleton, FBX clips; the v2.x "
"biped rigger and GLB bake are broken), creatures -> v2.5-20260210 (GLB)")
pipeline.add_argument("--animations", default="preset:idle,preset:walk,preset:run")
pipeline.add_argument("--model-version", default="v3.1-20260211")
pipeline.add_argument("--texture-quality", default="detailed", choices=["standard", "detailed", "extreme"])
pipeline.add_argument("--geometry-quality", default="standard", choices=["standard", "detailed"])
pipeline.add_argument("--face-limit", type=int)
pipeline.add_argument(
"--rig-type",
choices=["biped", "quadruped", "hexapod", "octopod", "avian", "serpentine", "aquatic"],
help="override the prerigcheck-detected rig type (default: auto-detect, fallback biped)",
)
pipeline.add_argument("--spec", default="tripo", choices=["tripo", "mixamo"],
help="mixamo rigs cannot use Tripo preset retargeting")
pipeline.add_argument("--force-rig", action="store_true",
help="attempt rigging even if prerigcheck reports riggable=false")
pipeline.add_argument("--animate-in-place", action="store_true")
pipeline.add_argument("--api-key")
pipeline.add_argument("--out-dir", default="tripo-character")
pipeline.add_argument("--interval", type=int, default=8)
pipeline.add_argument("--timeout", type=int, default=900)
pipeline.set_defaults(func=cmd_character_pipeline)
return parser
def main() -> int:
parser = build_parser()
args = parser.parse_args()
try:
args.func(args)
except TripoError as exc:
eprint(f"threejs_3d_asset.py: {exc}")
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())