diff --git a/anim_male.gd b/anim_male.gd new file mode 100644 index 0000000..af2111c --- /dev/null +++ b/anim_male.gd @@ -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) diff --git a/anim_male.tscn b/anim_male.tscn new file mode 100644 index 0000000..12f8c33 --- /dev/null +++ b/anim_male.tscn @@ -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") diff --git a/anim_male_ual.gd b/anim_male_ual.gd new file mode 100644 index 0000000..b282172 --- /dev/null +++ b/anim_male_ual.gd @@ -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) diff --git a/anim_male_ual.tscn b/anim_male_ual.tscn new file mode 100644 index 0000000..820c976 --- /dev/null +++ b/anim_male_ual.tscn @@ -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") diff --git a/male/male-lena-v15-quatnamed.glb b/male/male-lena-v15-quatnamed.glb new file mode 100644 index 0000000..75f82ac --- /dev/null +++ b/male/male-lena-v15-quatnamed.glb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c52e336ecbc1591747f32d4a2ebe88b1ecd884fd35c33dd8600a91d399612e98 +size 10873544 diff --git a/male/male-lena-v15-rigged.fbx b/male/male-lena-v15-rigged.fbx new file mode 100644 index 0000000..b7bb3fd --- /dev/null +++ b/male/male-lena-v15-rigged.fbx @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:35ed8aa1793289e1414bd7f382f21f524b207b248aaf768b0955a192170f1821 +size 12195980 diff --git a/male/male-lena-v15-rigged.glb b/male/male-lena-v15-rigged.glb new file mode 100644 index 0000000..ca3f33d --- /dev/null +++ b/male/male-lena-v15-rigged.glb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ae8aa0044fd25f08eeb64d8c570634b7af04fd5dc2595ea03df3db767deb0ecb +size 10889624 diff --git a/male/male-lena-v15-running.glb b/male/male-lena-v15-running.glb new file mode 100644 index 0000000..31cdd8a --- /dev/null +++ b/male/male-lena-v15-running.glb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:cd0d08eebba309a884f73f705b17ae8a12b65f90c05770f8d338105b2a5b34c0 +size 10897800 diff --git a/male/male-lena-v15-tripo-raw.glb b/male/male-lena-v15-tripo-raw.glb new file mode 100644 index 0000000..2708a2c --- /dev/null +++ b/male/male-lena-v15-tripo-raw.glb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:eb7667215cb8d0fde9ef5e781c18098f59672cd3e0a36f644d67eb28d1d50d7f +size 6122544 diff --git a/male/male-lena-v15-tripo-zfacing.glb b/male/male-lena-v15-tripo-zfacing.glb new file mode 100644 index 0000000..9a3bc80 --- /dev/null +++ b/male/male-lena-v15-tripo-zfacing.glb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a774d83f2d991d235e127681ee9ccefd14d9cd9e82fd5f43fd26703e68cd5dfe +size 9322704 diff --git a/male/male-lena-v15-walking.glb b/male/male-lena-v15-walking.glb new file mode 100644 index 0000000..8d0cca5 --- /dev/null +++ b/male/male-lena-v15-walking.glb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:45771d09c892d9db033e7407c8e619f77689d2c2e60a83edb711e715a11f205d +size 10902400 diff --git a/male/male-ual-clips.glb b/male/male-ual-clips.glb new file mode 100644 index 0000000..ae9b2b7 --- /dev/null +++ b/male/male-ual-clips.glb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:dd857b4cfd4c129c337d1325a94428eeebbcd51e081eae004d095e6d04a74a0a +size 1155008 diff --git a/probe_male_clips.gd b/probe_male_clips.gd new file mode 100644 index 0000000..68a75ca --- /dev/null +++ b/probe_male_clips.gd @@ -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 diff --git a/rotate_glb.gd b/rotate_glb.gd new file mode 100644 index 0000000..eb63299 --- /dev/null +++ b/rotate_glb.gd @@ -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) diff --git a/view_tripo.gd b/view_tripo.gd new file mode 100644 index 0000000..3978b53 --- /dev/null +++ b/view_tripo.gd @@ -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") diff --git a/view_tripo.tscn b/view_tripo.tscn new file mode 100644 index 0000000..0d1e7d7 --- /dev/null +++ b/view_tripo.tscn @@ -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")