204 lines
6.8 KiB
GDScript
204 lines
6.8 KiB
GDScript
|
|
# 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")
|