male(v15): Tripo mesh + Meshy rig, UAL retarget test beds

- male/: raw + z-facing + quat-named bodies, Meshy rigged GLB/FBX,
  stock walk/run clips, male-ual-clips.glb (16 UAL1 clips baked via
  ariki-game mixamo_retarget.py --map ual)
- anim_male.tscn: Meshy stock clips; anim_male_ual.tscn: UAL clips
  (scales position keys x100 — Meshy skeleton is cm, clips are m)
- view_tripo.tscn: env-var GLB turntable/anim shots + interactive orbit
- rotate_glb.gd: headless face-+Z/height fixup (Meshy pose estimation
  rejects Tripo's +X-facing normalized output)
- Meshy spine chain is reverse-named: Spine02 = lowest vertebra

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Can Narin
2026-07-29 21:39:30 +03:00
parent ce4c4e7d13
commit 8fa53c8e78
16 changed files with 880 additions and 0 deletions
+248
View File
@@ -0,0 +1,248 @@
# Animation control test: Meshy-rigged male Lena (Tripo mesh from V15).
# Merges the walk/run clips from the separate Meshy GLBs onto one player.
# Controls:
# ←/→ or A/D previous / next animation
# Space pause / resume
# ,/. step one frame back / forward (while paused)
# +/- playback speed up / down R reset speed
# U lighting ON / FLAT
# left-drag orbit camera, scroll wheel zooms
extends Node3D
const DIR := "C:/Users/CAN/tinqs-ltd/tattoo-test/male/"
const BASE_GLB := DIR + "male-lena-v15-walking.glb"
# extra clips pulled from sibling GLBs (same Meshy rig): file -> clip name
const EXTRA_CLIPS := {
"male-lena-v15-running.glb": "Run",
"male-lena-v15-rigged.glb": "TPose",
}
const BASE_CLIP_NAME := "Walk"
var model: Node3D
var skel: Skeleton3D
var cam: Camera3D
var player: AnimationPlayer
var hud: Label
var clips: Array[String] = []
var clip_idx := 0
var lights: Array[Light3D] = []
var env: Environment
var lights_on := true
var interactive := false
var dragging := false
var orbit_yaw := 0.0
var orbit_pitch := 0.08
var orbit_dist := 3.6
var orbit_target := Vector3(0, 0.95, 0)
func _ready() -> void:
env = Environment.new()
env.background_mode = Environment.BG_COLOR
env.background_color = Color(0.12, 0.13, 0.16)
env.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR
env.ambient_light_color = Color(0.75, 0.78, 0.85)
env.ambient_light_energy = 0.7
env.tonemap_mode = Environment.TONE_MAPPER_FILMIC
var we := WorldEnvironment.new()
we.environment = env
add_child(we)
var sun := DirectionalLight3D.new()
sun.rotation_degrees = Vector3(-40, -35, 0)
sun.light_energy = 1.5
add_child(sun)
var fill := DirectionalLight3D.new()
fill.rotation_degrees = Vector3(-15, 140, 0)
fill.light_energy = 0.6
add_child(fill)
var under := DirectionalLight3D.new()
under.rotation_degrees = Vector3(35, 0, 0)
under.light_energy = 0.35
add_child(under)
lights = [sun, fill, under]
var ground := MeshInstance3D.new()
var plane := PlaneMesh.new()
plane.size = Vector2(12, 12)
ground.mesh = plane
var gmat := StandardMaterial3D.new()
gmat.albedo_color = Color(0.16, 0.17, 0.21)
gmat.roughness = 1.0
ground.set_surface_override_material(0, gmat)
add_child(ground)
model = _load_glb(BASE_GLB)
if model == null:
return
add_child(model)
var src_player: AnimationPlayer = null
var stack: Array = [model]
while not stack.is_empty():
var n: Node = stack.pop_back()
if n is Skeleton3D and skel == null:
skel = n
if n is AnimationPlayer and src_player == null:
src_player = n
for c in n.get_children():
stack.push_back(c)
if skel == null or src_player == null:
push_error("base GLB missing skeleton or AnimationPlayer")
return
# one flat library on a fresh player: base clip + clips lifted from siblings
player = AnimationPlayer.new()
add_child(player)
var lib := AnimationLibrary.new()
var base_anim := src_player.get_animation(src_player.get_animation_list()[0]).duplicate() as Animation
_adopt_clip(base_anim, lib, BASE_CLIP_NAME)
src_player.queue_free()
for file in EXTRA_CLIPS:
var extra := _load_glb(DIR + file)
if extra == null:
continue
var p := _find_player(extra)
if p != null and p.get_animation_list().size() > 0:
var anim := p.get_animation(p.get_animation_list()[0]).duplicate() as Animation
_adopt_clip(anim, lib, EXTRA_CLIPS[file])
extra.queue_free()
player.add_animation_library("", lib)
clips.assign(Array(lib.get_animation_list()))
clips.sort_custom(func(a, b): return _clip_rank(a) < _clip_rank(b))
print("clips: ", ",".join(clips))
cam = Camera3D.new()
cam.fov = 50
add_child(cam)
cam.current = true
hud = Label.new()
hud.position = Vector2(16, 12)
hud.add_theme_font_size_override("font_size", 22)
hud.add_theme_color_override("font_color", Color(0.95, 0.95, 0.9))
add_child(hud)
_play_clip(0)
_update_cam()
interactive = true
func _clip_rank(n: String) -> int:
return {"TPose": 0, "Walk": 1, "Run": 2}.get(n, 99)
func _load_glb(path: String) -> Node3D:
var doc := GLTFDocument.new()
var state := GLTFState.new()
if doc.append_from_file(path, state) != OK:
push_error("GLB load failed: " + path)
return null
return doc.generate_scene(state)
func _find_player(root: Node) -> AnimationPlayer:
var stack: Array = [root]
while not stack.is_empty():
var n: Node = stack.pop_back()
if n is AnimationPlayer:
return n
for c in n.get_children():
stack.push_back(c)
return null
# retarget a clip's skeleton tracks onto this scene's skeleton and add it.
# All Meshy exports share the same rig, so only the node-path prefix differs.
func _adopt_clip(anim: Animation, lib: AnimationLibrary, clip_name: String) -> void:
var skel_path := String(get_path_to(skel))
anim.loop_mode = Animation.LOOP_LINEAR
for i in anim.get_track_count():
var path := String(anim.track_get_path(i))
if path.contains("Skeleton3D"):
var bone := path.get_slice(":", 1)
anim.track_set_path(i, skel_path + ":" + bone)
lib.add_animation(clip_name, anim)
func _play_clip(idx: int) -> void:
if clips.is_empty():
return
clip_idx = wrapi(idx, 0, clips.size())
player.play(clips[clip_idx])
_update_hud()
func _set_lights(on: bool) -> void:
lights_on = on
for l in lights:
l.visible = on
env.ambient_light_energy = 0.7 if on else 1.6
_update_hud()
func _update_hud() -> void:
if hud == null or clips.is_empty():
return
var clip: String = clips[clip_idx]
var anim := player.get_animation(clip)
hud.text = "[%d/%d] %s %.2fs / %.2fs speed %.2fx light: %s %s\n←/→ clip Space pause ,/. step +/- speed R reset U light drag rotate scroll zoom" \
% [clip_idx + 1, clips.size(), clip, player.current_animation_position, anim.length,
player.speed_scale, "SUN" if lights_on else "FLAT",
"PLAYING" if player.is_playing() else "PAUSED"]
func _process(_dt: float) -> void:
if interactive and player.is_playing():
_update_hud()
func _step_frame(dir: int) -> void:
if player.is_playing():
player.pause()
var anim := player.get_animation(clips[clip_idx])
var t := fposmod(player.current_animation_position + dir / 30.0, anim.length)
player.seek(t, true)
_update_hud()
func _unhandled_input(event: InputEvent) -> void:
if not interactive:
return
if event is InputEventKey and event.pressed and not event.echo:
match event.keycode:
KEY_RIGHT, KEY_D:
_play_clip(clip_idx + 1)
KEY_LEFT, KEY_A:
_play_clip(clip_idx - 1)
KEY_SPACE:
if player.is_playing():
player.pause()
else:
player.play()
_update_hud()
KEY_COMMA:
_step_frame(-1)
KEY_PERIOD:
_step_frame(1)
KEY_EQUAL, KEY_KP_ADD:
player.speed_scale = clampf(player.speed_scale * 1.25, 0.1, 4.0)
_update_hud()
KEY_MINUS, KEY_KP_SUBTRACT:
player.speed_scale = clampf(player.speed_scale / 1.25, 0.1, 4.0)
_update_hud()
KEY_R:
player.speed_scale = 1.0
_update_hud()
KEY_U:
_set_lights(not lights_on)
elif event is InputEventMouseButton:
if event.button_index == MOUSE_BUTTON_LEFT:
dragging = event.pressed
elif event.pressed and event.button_index == MOUSE_BUTTON_WHEEL_UP:
orbit_dist = clampf(orbit_dist * 0.90, 0.35, 10.0)
_update_cam()
elif event.pressed and event.button_index == MOUSE_BUTTON_WHEEL_DOWN:
orbit_dist = clampf(orbit_dist / 0.90, 0.35, 10.0)
_update_cam()
elif event is InputEventMouseMotion and dragging:
orbit_yaw -= event.relative.x * 0.008
orbit_pitch = clampf(orbit_pitch + event.relative.y * 0.008, -1.3, 1.3)
_update_cam()
func _update_cam() -> void:
var dir := Vector3(
sin(orbit_yaw) * cos(orbit_pitch),
sin(orbit_pitch),
cos(orbit_yaw) * cos(orbit_pitch))
cam.look_at_from_position(orbit_target + dir * orbit_dist, orbit_target)
+6
View File
@@ -0,0 +1,6 @@
[gd_scene load_steps=2 format=3 uid="uid://anim_male_scene"]
[ext_resource type="Script" path="res://anim_male.gd" id="1"]
[node name="AnimMale" type="Node3D"]
script = ExtResource("1")
+270
View File
@@ -0,0 +1,270 @@
# Animation control test: V15 male (Tripo mesh, Meshy rig renamed to Quaternius
# bones) driven by REAL UAL1 clips baked for this skeleton via
# ariki-game/tools/mixamo_retarget.py --map ual (world-rotation-delta retarget).
# Controls: same as anim_male.gd (see HUD).
extends Node3D
const DIR := "C:/Users/CAN/tinqs-ltd/tattoo-test/male/"
const BODY_GLB := DIR + "male-lena-v15-quatnamed.glb"
const CLIPS_GLB := DIR + "male-ual-clips.glb"
const PREFERRED_ORDER := ["Idle_Loop", "Walk_Loop", "Jog_Fwd_Loop", "Sprint_Loop", "Dance_Loop", "Punch_Jab"]
var model: Node3D
var skel: Skeleton3D
var cam: Camera3D
var player: AnimationPlayer
var hud: Label
var clips: Array[String] = []
var clip_idx := 0
var lights: Array[Light3D] = []
var env: Environment
var lights_on := true
var interactive := false
var dragging := false
var orbit_yaw := 0.0
var orbit_pitch := 0.08
var orbit_dist := 3.6
var orbit_target := Vector3(0, 0.95, 0)
func _ready() -> void:
env = Environment.new()
env.background_mode = Environment.BG_COLOR
env.background_color = Color(0.12, 0.13, 0.16)
env.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR
env.ambient_light_color = Color(0.75, 0.78, 0.85)
env.ambient_light_energy = 0.7
env.tonemap_mode = Environment.TONE_MAPPER_FILMIC
var we := WorldEnvironment.new()
we.environment = env
add_child(we)
var sun := DirectionalLight3D.new()
sun.rotation_degrees = Vector3(-40, -35, 0)
sun.light_energy = 1.5
add_child(sun)
var fill := DirectionalLight3D.new()
fill.rotation_degrees = Vector3(-15, 140, 0)
fill.light_energy = 0.6
add_child(fill)
var under := DirectionalLight3D.new()
under.rotation_degrees = Vector3(35, 0, 0)
under.light_energy = 0.35
add_child(under)
lights = [sun, fill, under]
var ground := MeshInstance3D.new()
var plane := PlaneMesh.new()
plane.size = Vector2(12, 12)
ground.mesh = plane
var gmat := StandardMaterial3D.new()
gmat.albedo_color = Color(0.16, 0.17, 0.21)
gmat.roughness = 1.0
ground.set_surface_override_material(0, gmat)
add_child(ground)
model = _load_glb(BODY_GLB)
if model == null:
return
add_child(model)
var stack: Array = [model]
while not stack.is_empty():
var n: Node = stack.pop_back()
if n is Skeleton3D and skel == null:
skel = n
for c in n.get_children():
stack.push_back(c)
if skel == null:
push_error("no skeleton in body GLB")
return
print("INFO bones=", skel.get_bone_count())
# lift baked UAL clips onto a local player bound to this skeleton
var clip_scene := _load_glb(CLIPS_GLB)
if clip_scene == null:
return
var src := _find_player(clip_scene)
if src == null:
push_error("no AnimationPlayer in clips GLB")
return
player = AnimationPlayer.new()
add_child(player)
var skel_path := String(get_path_to(skel))
# The Meshy body skeleton is authored in cm (armature node carries a 0.01
# scale); the baked clips are in meters. Rotations are unit-free but
# position keys must be rescaled into the body's bone space.
var pos_scale := 1.0
var cskel: Skeleton3D = null
var cstack: Array = [clip_scene]
while not cstack.is_empty():
var cn: Node = cstack.pop_back()
if cn is Skeleton3D:
cskel = cn
break
for cc in cn.get_children():
cstack.push_back(cc)
if cskel != null:
var body_y := skel.get_bone_global_rest(skel.find_bone("pelvis")).origin.y
var clip_y := cskel.get_bone_global_rest(cskel.find_bone("pelvis")).origin.y
if clip_y > 0.001:
pos_scale = body_y / clip_y
print("INFO pos_scale=", pos_scale)
var lib := AnimationLibrary.new()
for clip_name in src.get_animation_list():
var anim := src.get_animation(clip_name).duplicate() as Animation
anim.loop_mode = Animation.LOOP_LINEAR
for i in anim.get_track_count():
var path := String(anim.track_get_path(i))
var bone := path.get_slice(":", 1)
anim.track_set_path(i, skel_path + ":" + bone)
if anim.track_get_type(i) == Animation.TYPE_POSITION_3D:
for k in anim.track_get_key_count(i):
anim.track_set_key_value(i, k, anim.track_get_key_value(i, k) * pos_scale)
lib.add_animation(clip_name, anim)
player.add_animation_library("", lib)
clip_scene.queue_free()
clips.assign(Array(lib.get_animation_list()))
clips.sort_custom(func(a, b): return _clip_rank(a) < _clip_rank(b))
print("clips: ", ",".join(clips))
if clips.is_empty():
push_error("no clips loaded")
return
cam = Camera3D.new()
cam.fov = 50
add_child(cam)
cam.current = true
hud = Label.new()
hud.position = Vector2(16, 12)
hud.add_theme_font_size_override("font_size", 22)
hud.add_theme_color_override("font_color", Color(0.95, 0.95, 0.9))
add_child(hud)
_play_clip(0)
_update_cam()
# UAL_SHOTS=1: save mid-clip verification frames before going interactive
if OS.get_environment("UAL_SHOTS") == "1":
DirAccess.make_dir_recursive_absolute(DIR + "shots/")
for shot_clip in ["Idle_Loop", "Walk_Loop", "Jog_Fwd_Loop", "Dance_Loop"]:
var i := clips.find(shot_clip)
if i < 0:
continue
_play_clip(i)
await get_tree().create_timer(0.6).timeout
await RenderingServer.frame_post_draw
var img := get_viewport().get_texture().get_image()
img.save_png(DIR + "shots/ual_" + shot_clip + ".png")
print("shot saved: ", DIR + "shots/ual_" + shot_clip + ".png")
print("SHOTS_DONE")
_play_clip(0)
interactive = true
func _clip_rank(n: String) -> int:
var i := PREFERRED_ORDER.find(n)
return i if i >= 0 else 99
func _load_glb(path: String) -> Node3D:
var doc := GLTFDocument.new()
var state := GLTFState.new()
if doc.append_from_file(path, state) != OK:
push_error("GLB load failed: " + path)
return null
return doc.generate_scene(state)
func _find_player(root: Node) -> AnimationPlayer:
var stack: Array = [root]
while not stack.is_empty():
var n: Node = stack.pop_back()
if n is AnimationPlayer:
return n
for c in n.get_children():
stack.push_back(c)
return null
func _play_clip(idx: int) -> void:
if clips.is_empty():
return
clip_idx = wrapi(idx, 0, clips.size())
player.play(clips[clip_idx])
_update_hud()
func _set_lights(on: bool) -> void:
lights_on = on
for l in lights:
l.visible = on
env.ambient_light_energy = 0.7 if on else 1.6
_update_hud()
func _update_hud() -> void:
if hud == null or clips.is_empty():
return
var clip: String = clips[clip_idx]
var anim := player.get_animation(clip)
hud.text = "UAL on Quat-named Meshy rig [%d/%d] %s %.2fs / %.2fs speed %.2fx light: %s %s\n←/→ clip Space pause ,/. step +/- speed R reset U light drag rotate scroll zoom" \
% [clip_idx + 1, clips.size(), clip, player.current_animation_position, anim.length,
player.speed_scale, "SUN" if lights_on else "FLAT",
"PLAYING" if player.is_playing() else "PAUSED"]
func _process(_dt: float) -> void:
if interactive and player.is_playing():
_update_hud()
func _step_frame(dir: int) -> void:
if player.is_playing():
player.pause()
var anim := player.get_animation(clips[clip_idx])
var t := fposmod(player.current_animation_position + dir / 30.0, anim.length)
player.seek(t, true)
_update_hud()
func _unhandled_input(event: InputEvent) -> void:
if not interactive:
return
if event is InputEventKey and event.pressed and not event.echo:
match event.keycode:
KEY_RIGHT, KEY_D:
_play_clip(clip_idx + 1)
KEY_LEFT, KEY_A:
_play_clip(clip_idx - 1)
KEY_SPACE:
if player.is_playing():
player.pause()
else:
player.play()
_update_hud()
KEY_COMMA:
_step_frame(-1)
KEY_PERIOD:
_step_frame(1)
KEY_EQUAL, KEY_KP_ADD:
player.speed_scale = clampf(player.speed_scale * 1.25, 0.1, 4.0)
_update_hud()
KEY_MINUS, KEY_KP_SUBTRACT:
player.speed_scale = clampf(player.speed_scale / 1.25, 0.1, 4.0)
_update_hud()
KEY_R:
player.speed_scale = 1.0
_update_hud()
KEY_U:
_set_lights(not lights_on)
elif event is InputEventMouseButton:
if event.button_index == MOUSE_BUTTON_LEFT:
dragging = event.pressed
elif event.pressed and event.button_index == MOUSE_BUTTON_WHEEL_UP:
orbit_dist = clampf(orbit_dist * 0.90, 0.35, 10.0)
_update_cam()
elif event.pressed and event.button_index == MOUSE_BUTTON_WHEEL_DOWN:
orbit_dist = clampf(orbit_dist / 0.90, 0.35, 10.0)
_update_cam()
elif event is InputEventMouseMotion and dragging:
orbit_yaw -= event.relative.x * 0.008
orbit_pitch = clampf(orbit_pitch + event.relative.y * 0.008, -1.3, 1.3)
_update_cam()
func _update_cam() -> void:
var dir := Vector3(
sin(orbit_yaw) * cos(orbit_pitch),
sin(orbit_pitch),
cos(orbit_yaw) * cos(orbit_pitch))
cam.look_at_from_position(orbit_target + dir * orbit_dist, orbit_target)
+6
View File
@@ -0,0 +1,6 @@
[gd_scene load_steps=2 format=3 uid="uid://anim_male_ual_scene"]
[ext_resource type="Script" path="res://anim_male_ual.gd" id="1"]
[node name="AnimMaleUal" type="Node3D"]
script = ExtResource("1")
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
View File
Binary file not shown.
+50
View File
@@ -0,0 +1,50 @@
# Headless probe: compare the male body's pelvis rest height with the pelvis
# position keys in the baked UAL clips GLB.
extends SceneTree
const DIR := "C:/Users/CAN/tinqs-ltd/tattoo-test/male/"
func _init() -> void:
var body := _load(DIR + "male-lena-v15-quatnamed.glb")
var skel: Skeleton3D = _find(body, "Skeleton3D")
print("body bones=", skel.get_bone_count())
for i in [0, 1]:
print("bone ", i, " '", skel.get_bone_name(i), "' rest=", skel.get_bone_rest(i).origin,
" parent=", skel.get_bone_parent(i))
var pelvis := skel.find_bone("pelvis")
print("pelvis idx=", pelvis, " global_rest=", skel.get_bone_global_rest(pelvis).origin)
var clips := _load(DIR + "male-ual-clips.glb")
var ap: AnimationPlayer = _find(clips, "AnimationPlayer")
var anim := ap.get_animation("Idle_Loop")
for t in anim.get_track_count():
var path := String(anim.track_get_path(t))
if path.ends_with(":pelvis"):
var kind := anim.track_get_type(t)
if kind == Animation.TYPE_POSITION_3D:
print("POS track ", path, " key0=", anim.track_get_key_value(t, 0),
" keyN=", anim.track_get_key_value(t, anim.track_get_key_count(t) - 1))
elif kind == Animation.TYPE_ROTATION_3D:
print("ROT track ", path, " key0=", anim.track_get_key_value(t, 0))
# also inspect the clips GLB's own skeleton rest (exported armature)
var cskel: Skeleton3D = _find(clips, "Skeleton3D")
if cskel:
var cp := cskel.find_bone("pelvis")
print("clipsGLB pelvis rest=", cskel.get_bone_rest(cp).origin, " bones=", cskel.get_bone_count())
quit(0)
func _load(path: String) -> Node3D:
var doc := GLTFDocument.new()
var state := GLTFState.new()
doc.append_from_file(path, state)
return doc.generate_scene(state)
func _find(root: Node, klass: String) -> Node:
var stack: Array = [root]
while not stack.is_empty():
var n: Node = stack.pop_back()
if n.get_class() == klass:
return n
for c in n.get_children():
stack.push_back(c)
return null
+67
View File
@@ -0,0 +1,67 @@
# Headless GLB fixup: rotate a model to face +Z and scale to a target height,
# baking the transform into vertices via the root node transform on export.
# Env: ROT_IN (glb path), ROT_OUT (glb path), ROT_YAW_DEG, ROT_HEIGHT_M
extends SceneTree
func _init() -> void:
var in_path := OS.get_environment("ROT_IN")
var out_path := OS.get_environment("ROT_OUT")
var yaw_deg := float(OS.get_environment("ROT_YAW_DEG"))
var target_h := float(OS.get_environment("ROT_HEIGHT_M"))
var doc := GLTFDocument.new()
var state := GLTFState.new()
if doc.append_from_file(in_path, state) != OK:
push_error("load failed: " + in_path)
quit(1)
return
var scene := doc.generate_scene(state)
# measure current height from merged mesh AABB
var merged := AABB()
var first := true
var stack: Array = [scene]
while not stack.is_empty():
var n: Node = stack.pop_back()
if n is MeshInstance3D:
var box: AABB = (n as MeshInstance3D).get_aabb()
if first:
merged = box
first = false
else:
merged = merged.merge(box)
for c in n.get_children():
stack.push_back(c)
var h := merged.size.y
var s := target_h / h if h > 0.001 else 1.0
print("height=", h, " scale=", s)
var wrapper := Node3D.new()
wrapper.name = "Root"
wrapper.rotate_y(deg_to_rad(yaw_deg))
wrapper.scale = Vector3(s, s, s)
# move model so feet sit at y=0 after scaling
scene.position.y = -merged.position.y
var parent := scene.get_parent()
if parent != null:
parent.remove_child(scene)
wrapper.add_child(scene)
_own_recursive(scene, wrapper)
var out_doc := GLTFDocument.new()
var out_state := GLTFState.new()
if out_doc.append_from_scene(wrapper, out_state) != OK:
push_error("export append failed")
quit(1)
return
if out_doc.write_to_filesystem(out_state, out_path) != OK:
push_error("write failed: " + out_path)
quit(1)
return
print("WROTE ", out_path)
quit(0)
func _own_recursive(n: Node, owner: Node) -> void:
n.owner = owner
for c in n.get_children():
_own_recursive(c, owner)
+203
View File
@@ -0,0 +1,203 @@
# Generic GLB inspection viewer for AI-generated meshes (Tripo / Meshy rigged).
# Reads env vars:
# VIEW_GLB - absolute path to the .glb to load
# VIEW_SHOTS - absolute dir for screenshots
# VIEW_PREFIX - filename prefix for shots
# Takes turntable shots (auto-framed from the mesh AABB); if the GLB carries
# animations, also captures frames across the first clip. Quits when done.
# Set VIEW_INTERACTIVE=1 to skip shots and orbit live instead
# (left-drag orbits, wheel zooms; animation loops if present).
extends Node3D
var model: Node3D
var cam: Camera3D
var shot_dir: String
var prefix: String
var interactive := false
var dragging := false
var orbit_yaw := 0.0
var orbit_pitch := 0.08
var orbit_dist := 3.4
var orbit_target := Vector3(0, 1.0, 0)
func _ready() -> void:
var glb_path := OS.get_environment("VIEW_GLB")
shot_dir = OS.get_environment("VIEW_SHOTS")
prefix = OS.get_environment("VIEW_PREFIX")
interactive = OS.get_environment("VIEW_INTERACTIVE") == "1"
if interactive and shot_dir == "":
shot_dir = "C:/Users/CAN/AppData/Local/Temp/"
if glb_path == "" or shot_dir == "":
push_error("VIEW_GLB / VIEW_SHOTS not set")
get_tree().quit(1)
return
if not shot_dir.ends_with("/"):
shot_dir += "/"
DirAccess.make_dir_recursive_absolute(shot_dir)
var env := Environment.new()
env.background_mode = Environment.BG_COLOR
env.background_color = Color(0.12, 0.13, 0.16)
env.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR
env.ambient_light_color = Color(0.75, 0.78, 0.85)
env.ambient_light_energy = 0.7
env.tonemap_mode = Environment.TONE_MAPPER_FILMIC
var we := WorldEnvironment.new()
we.environment = env
add_child(we)
var sun := DirectionalLight3D.new()
sun.rotation_degrees = Vector3(-40, -35, 0)
sun.light_energy = 1.5
add_child(sun)
var fill := DirectionalLight3D.new()
fill.rotation_degrees = Vector3(-15, 140, 0)
fill.light_energy = 0.6
add_child(fill)
var doc := GLTFDocument.new()
var state := GLTFState.new()
if doc.append_from_file(glb_path, state) != OK:
push_error("GLB load failed: " + glb_path)
get_tree().quit(1)
return
model = doc.generate_scene(state)
add_child(model)
# inventory: meshes, verts, skeleton bones, animations
var anim_player: AnimationPlayer = null
var skel: Skeleton3D = null
var tri_count := 0
var stack: Array = [model]
while not stack.is_empty():
var n: Node = stack.pop_back()
if n is AnimationPlayer and anim_player == null:
anim_player = n
if n is Skeleton3D and skel == null:
skel = n
if n is MeshInstance3D and n.mesh != null:
for s in n.mesh.get_surface_count():
tri_count += n.mesh.surface_get_arrays(s)[Mesh.ARRAY_INDEX].size() / 3
for c in n.get_children():
stack.push_back(c)
print("INFO tris=", tri_count)
if skel != null:
var names := PackedStringArray()
for i in skel.get_bone_count():
names.append(skel.get_bone_name(i))
print("INFO bones=", skel.get_bone_count(), " names=", ",".join(names))
else:
print("INFO bones=0")
if anim_player != null:
print("INFO anims=", ",".join(anim_player.get_animation_list()))
# auto-frame from merged AABB (skinned meshes report bogus tiny AABBs —
# fall back to skeleton bone rest positions)
await get_tree().process_frame
var aabb := _merged_aabb()
if skel != null and max(aabb.size.x, max(aabb.size.y, aabb.size.z)) < 0.5:
aabb = _skeleton_aabb(skel)
print("INFO aabb pos=", aabb.position, " size=", aabb.size)
var center := aabb.get_center()
var radius: float = max(aabb.size.x, max(aabb.size.y, aabb.size.z)) * 0.5
var dist := radius * 2.6
cam = Camera3D.new()
cam.fov = 50
add_child(cam)
cam.current = true
if interactive:
orbit_target = center
orbit_dist = dist
_update_cam()
if anim_player != null and anim_player.get_animation_list().size() > 0:
var loop_anim: String = anim_player.get_animation_list()[0]
anim_player.get_animation(loop_anim).loop_mode = Animation.LOOP_LINEAR
anim_player.play(loop_anim)
print("INTERACTIVE — left-drag orbit, wheel zoom")
return
# turntable
var angles := {"01_front": 0.0, "02_three_quarter": PI * 0.25, "03_side": PI * 0.5, "04_back": PI}
for shot_name in angles:
var a: float = angles[shot_name]
cam.look_at_from_position(center + Vector3(sin(a) * dist, radius * 0.35, cos(a) * dist), center)
await _snap(shot_name)
# head closeup (top of AABB)
var head := center + Vector3(0, aabb.size.y * 0.38, 0)
cam.look_at_from_position(head + Vector3(0, 0.05 * radius, dist * 0.35), head)
await _snap("05_head")
# animation frames
if anim_player != null and anim_player.get_animation_list().size() > 0:
var anim_name: String = anim_player.get_animation_list()[0]
var anim := anim_player.get_animation(anim_name)
var frames := 6
for i in frames:
var t := anim.length * float(i) / float(frames)
anim_player.play(anim_name)
anim_player.seek(t, true)
anim_player.pause()
cam.look_at_from_position(center + Vector3(sin(PI * 0.2) * dist, radius * 0.35, cos(PI * 0.2) * dist), center)
await _snap("anim_f%d" % i)
print("SHOTS_DONE")
get_tree().quit(0)
func _unhandled_input(event: InputEvent) -> void:
if not interactive:
return
if event is InputEventMouseButton:
if event.button_index == MOUSE_BUTTON_LEFT:
dragging = event.pressed
elif event.pressed and event.button_index == MOUSE_BUTTON_WHEEL_UP:
orbit_dist = clampf(orbit_dist * 0.90, 0.35, 12.0)
_update_cam()
elif event.pressed and event.button_index == MOUSE_BUTTON_WHEEL_DOWN:
orbit_dist = clampf(orbit_dist / 0.90, 0.35, 12.0)
_update_cam()
elif event is InputEventMouseMotion and dragging:
orbit_yaw -= event.relative.x * 0.008
orbit_pitch = clampf(orbit_pitch + event.relative.y * 0.008, -1.3, 1.3)
_update_cam()
func _update_cam() -> void:
var dir := Vector3(
sin(orbit_yaw) * cos(orbit_pitch),
sin(orbit_pitch),
cos(orbit_yaw) * cos(orbit_pitch))
cam.look_at_from_position(orbit_target + dir * orbit_dist, orbit_target)
func _skeleton_aabb(skel: Skeleton3D) -> AABB:
var box := AABB(skel.global_transform * skel.get_bone_global_rest(0).origin, Vector3.ZERO)
for i in skel.get_bone_count():
box = box.expand(skel.global_transform * skel.get_bone_global_rest(i).origin)
box = box.grow(box.size.y * 0.12)
return box
func _merged_aabb() -> AABB:
var merged := AABB()
var first := true
var stack: Array = [model]
while not stack.is_empty():
var n: Node = stack.pop_back()
if n is MeshInstance3D:
var box: AABB = n.global_transform * n.get_aabb()
if first:
merged = box
first = false
else:
merged = merged.merge(box)
for c in n.get_children():
stack.push_back(c)
return merged
func _snap(shot_name: String) -> void:
await get_tree().process_frame
await get_tree().process_frame
await RenderingServer.frame_post_draw
var img := get_viewport().get_texture().get_image()
img.save_png(shot_dir + prefix + shot_name + ".png")
print("shot saved: ", shot_dir + prefix + shot_name + ".png")
+6
View File
@@ -0,0 +1,6 @@
[gd_scene load_steps=2 format=3 uid="uid://view_tripo_scene"]
[ext_resource type="Script" path="res://view_tripo.gd" id="1"]
[node name="ViewTripo" type="Node3D"]
script = ExtResource("1")