feat: adding the skin_shader_generator, and gacha base barebone

This commit is contained in:
2026-04-24 00:17:00 +08:00
parent 16c82a48b8
commit 7e4b707e84
196 changed files with 4883 additions and 200 deletions
+724
View File
@@ -0,0 +1,724 @@
@tool
extends Control
const SHADER_PATH = "res://scripts/shaders/character_skin.gdshader"
const BASE_TEX_DIR = "res://assets/characters/skins/tex/"
const PRESET_DIR = "res://assets/characters/skins/presets/"
const EXPORT_DIRS = {
"Clothing": "res://assets/characters/skins/clothing/",
"Gloves": "res://assets/characters/skins/gloves/",
"Hat": "res://assets/characters/skins/hat/",
"General / Tex": "res://assets/characters/skins/tex/"
}
const CAT_NAMES = ["Hat", "Gloves", "Cloth", "Skin"]
# ─── UI Refs (Scene Unique Nodes) ───────────────────────────────────────────
@onready var _character_opt: OptionButton = %CharacterOpt
@onready var _status_label: Label = %StatusLabel
@onready var _inspected_label: Label = %InspectedLabel
@onready var _mesh_list_vbox: VBoxContainer = %MeshList
@onready var _zoom_slider: HSlider = %ZoomSlider
@onready var _uv_preview: TextureRect = %UVPreview
@onready var _uv_overlay: Control = %UVOverlay
@onready var _viewport_3d: SubViewport = %Viewport3D
@onready var _pi_name_lbl: Label = %PIName
@onready var _pi_cat_opt: OptionButton = %PICategory
@onready var _pi_color_btn: ColorPickerButton = %PIColor
@onready var _pi_image_btn: Button = %PIImageBtn
@onready var _pi_image_clear: Button = %PIImageClear
@onready var _pi_ref_btn: Button = %PIRefBtn
@onready var _pi_ref_clear: Button = %PIRefClear
@onready var _pi_pos_x: HSlider = %PIPosX
@onready var _pi_pos_y: HSlider = %PIPosY
@onready var _pi_scale: HSlider = %PIScale
@onready var _pi_rot: HSlider = %PIRot
@onready var _export_name: LineEdit = %ExportName
@onready var _export_mode: OptionButton = %ExportMode
@onready var _export_folder_opt: OptionButton = %ExportFolder
@onready var _prefix_label: Label = %PrefixLabel
@onready var _wire_opacity_slider: HSlider = %WireOpacity
@onready var _wire_color_opt: OptionButton = %WireColor
var _char_preview: Node3D
var _file_dialog: FileDialog
# ─── State ───────────────────────────────────────────────────────────────────
var _mesh_data: Array = []
var _inspected_mesh_name: String = ""
var _is_rotating: bool = false
var _last_mouse_pos: Vector2
var _show_all_uvs: bool = false
var _flip_uv: bool = false
var _hide_ref: bool = false
var _debug_wireframe: MeshInstance3D
var _drawing_mask: bool = false
var _current_mask_idx: int = -1
var _mask_points: PackedVector2Array = PackedVector2Array()
var _dragged_point_idx: int = -1
var _char_cache: Dictionary = {}
var _gizmo_state: int = 0 # 0: None, 1: Move, 2: Scale, 3: Rotate
var _gizmo_start_uv: Vector2
var _gizmo_start_val: Variant
func _ready() -> void:
for d in EXPORT_DIRS.values():
if not DirAccess.dir_exists_absolute(d): DirAccess.make_dir_recursive_absolute(d)
if not DirAccess.dir_exists_absolute(PRESET_DIR): DirAccess.make_dir_recursive_absolute(PRESET_DIR)
_setup_ui_connections()
call_deferred("_update_character_preview")
_update_export_prefix()
func _setup_ui_connections():
# Presets
%SavePresetBtn.pressed.connect(_on_save_preset)
%LoadPresetBtn.pressed.connect(_on_load_preset)
# Character selection
_character_opt.clear()
for c in ["Oldpop", "Masbro", "Bob", "Gatot"]: _character_opt.add_item(c)
_character_opt.selected = 0
_character_opt.item_selected.connect(func(i): _on_char_changed(i); _update_export_prefix())
# UV Viewport
_uv_overlay.draw.connect(_on_uv_overlay_draw)
_uv_overlay.gui_input.connect(_on_uv_input)
%ShowAllUVs.toggled.connect(func(v): _show_all_uvs = v; _uv_overlay.queue_redraw())
%FlipUV.toggled.connect(func(v): _flip_uv = v; _update_character_preview())
%HideRef.toggled.connect(func(v): _hide_ref = v; _uv_preview.visible = !v; _uv_overlay.queue_redraw())
# 3D Viewport
%Viewport3D.get_parent().gui_input.connect(_on_3d_input)
_setup_3d_scene()
# Zoom controls
%ZoomMinus.pressed.connect(func(): _zoom_slider.value += 0.4)
%ZoomPlus.pressed.connect(func(): _zoom_slider.value -= 0.4)
_zoom_slider.value_changed.connect(_on_zoom_changed)
# Part Inspector
_pi_cat_opt.clear(); _pi_cat_opt.add_item("None / Skin", -1)
for ci in range(3): _pi_cat_opt.add_item(CAT_NAMES[ci], ci)
_pi_cat_opt.item_selected.connect(_on_pi_cat_selected)
_pi_color_btn.color_changed.connect(_on_pi_color_changed)
_pi_image_btn.pressed.connect(func(): _on_pick_image("sticker"))
_pi_image_clear.pressed.connect(_on_clear_image)
_pi_ref_btn.pressed.connect(func(): _on_pick_image("ref"))
_pi_ref_clear.pressed.connect(_on_clear_ref)
for s in [_pi_pos_x, _pi_pos_y, _pi_scale, _pi_rot]:
s.value_changed.connect(_on_transform_changed)
%FinishBtn.pressed.connect(_on_finish_mask)
%ClearBtn.pressed.connect(_on_clear_mask)
%AddMaskBtn.pressed.connect(_on_add_mask)
# Settings
_wire_opacity_slider.value_changed.connect(func(_v): _uv_overlay.queue_redraw())
_wire_color_opt.clear()
for c in ["Black", "White", "Yellow", "Green"]: _wire_color_opt.add_item(c)
_wire_color_opt.selected = 0 # Black default
_wire_color_opt.item_selected.connect(func(_i):
_uv_overlay.queue_redraw()
if _inspected_mesh_name != "": _select_mesh(_inspected_mesh_name)
)
# Export
_export_folder_opt.clear(); for key in EXPORT_DIRS.keys(): _export_folder_opt.add_item(key)
_export_folder_opt.selected = 3; _export_folder_opt.item_selected.connect(func(_i): _update_export_prefix())
_export_mode.clear(); _export_mode.add_item("Texture Only (.png)"); _export_mode.add_item("Texture + Material"); _export_mode.selected = 0
%GenerateBtn.pressed.connect(_on_generate_pressed)
func _update_export_prefix():
var cname = _current_char_name().to_lower()
var folder = _export_folder_opt.get_item_text(_export_folder_opt.selected)
var cat_slug = "skin"
if "Clothing" in folder: cat_slug = "cloth"
elif "Gloves" in folder: cat_slug = "gloves"
elif "Hat" in folder: cat_slug = "hat"
_prefix_label.text = "%s_%s_" % [cname, cat_slug]
# ─── 3D SCENE ────────────────────────────────────────────────────────────────
func _setup_3d_scene():
# world_3d is already unique because of 'own_world_3d = true' in tscn
var cam := Camera3D.new(); cam.position = Vector3(0, 0.8, 1.8); cam.far = 100.0; _viewport_3d.add_child(cam); cam.call_deferred("look_at", Vector3(0,0.6,0))
var sun := DirectionalLight3D.new(); sun.rotation_degrees = Vector3(-45,45,0); sun.light_energy = 1.2; _viewport_3d.add_child(sun)
var env := WorldEnvironment.new(); env.environment = Environment.new(); env.environment.background_mode = Environment.BG_COLOR; env.environment.background_color = Color(0.12,0.12,0.12); env.environment.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR; env.environment.ambient_light_color = Color.WHITE; env.environment.ambient_light_energy = 0.4; _viewport_3d.add_child(env)
func _on_zoom_changed(v: float):
var cam = _viewport_3d.get_camera_3d()
if cam: cam.position.z = v; cam.call_deferred("look_at", Vector3(0,0.6,0))
func _on_3d_input(event: InputEvent):
if event is InputEventMouseButton:
if event.button_index == MOUSE_BUTTON_LEFT: _is_rotating = event.pressed; _last_mouse_pos = event.position
elif event.button_index == MOUSE_BUTTON_RIGHT and event.pressed: _inspect_at_pos(event.position)
elif event.button_index == MOUSE_BUTTON_WHEEL_UP: _zoom_slider.value -= 0.2
elif event.button_index == MOUSE_BUTTON_WHEEL_DOWN: _zoom_slider.value += 0.2
if event is InputEventMouseMotion and _is_rotating:
var delta = event.position - _last_mouse_pos; _last_mouse_pos = event.position
if _char_preview: _char_preview.rotate_y(delta.x * 0.01)
func _inspect_at_pos(p: Vector2):
var cam = _viewport_3d.get_camera_3d(); if not cam or not _char_preview: return
var res = {"best": "", "min_dist": 1e10, "node": null}; _find_nearest_mesh(_char_preview, cam, p, res)
if res.node: _select_mesh(res.node.name)
func _find_nearest_mesh(n, c, p, r):
if n is MeshInstance3D and n.mesh and n.visible:
var center = n.global_transform * n.mesh.get_aabb().get_center()
if not c.is_position_behind(center):
var sp = c.unproject_position(center); var d = p.distance_to(sp)
if d < r.min_dist: r.min_dist = d; r.best = n.name; r.node = n
for ch in n.get_children(): _find_nearest_mesh(ch, c, p, r)
# ─── CHARACTER PREVIEW ───────────────────────────────────────────────────────
func _on_char_changed(_i): _update_character_preview()
func _current_char_name() -> String: return _character_opt.get_item_text(_character_opt.selected).to_lower()
func _update_character_preview():
if _char_preview: _char_preview.queue_free(); _char_preview = null
if _debug_wireframe: _debug_wireframe.queue_free(); _debug_wireframe = null
_inspected_mesh_name = ""; _inspected_label.text = ""
var cname = _current_char_name()
var path = "res://assets/characters/%s.glb" % cname.capitalize()
if not FileAccess.file_exists(path): return
var scene = load(path).instantiate(); _viewport_3d.add_child(scene); _char_preview = scene
# Force T-Pose
_force_t_pose(scene)
if not _char_cache.has(cname): _char_cache[cname] = {}
var tex = _get_embedded_texture(scene)
if not tex: tex = load("res://assets/characters/skins/tex/%s_cloth_ori_pant.png" % cname)
_uv_preview.texture = tex
_mesh_data.clear(); _collect_mesh_data(scene)
_build_scene_tree_ui(); _uv_overlay.queue_redraw()
func _collect_mesh_data(node: Node):
if node is MeshInstance3D and node.mesh:
var uvs := PackedVector2Array()
var tris := PackedVector2Array()
for s in node.mesh.get_surface_count():
var arr = node.mesh.surface_get_arrays(s)
var v_uvs = arr[Mesh.ARRAY_TEX_UV]; var idxs = arr[Mesh.ARRAY_INDEX]
if v_uvs:
var local_idxs = idxs if idxs else range(v_uvs.size())
for i in range(0, local_idxs.size()-2, 3):
var i0 = local_idxs[i]; var i1 = local_idxs[i+1]; var i2 = local_idxs[i+2]
var u0 = v_uvs[i0]; var u1 = v_uvs[i1]; var u2 = v_uvs[i2]
if _flip_uv: u0.y=1-u0.y; u1.y=1-u1.y; u2.y=1-u2.y
tris.append(u0); tris.append(u1); tris.append(u2)
var cname = _current_char_name()
var saved = _char_cache[cname].get(node.name, {})
var masks_arr = saved.get("masks", [])
if masks_arr.is_empty() and saved.has("mask"): masks_arr = [saved["mask"]] # Migration
var d = {
"name": node.name, "tris": tris, "node": node,
"category": saved.get("category", -1),
"fill_color": saved.get("fill_color", Color.WHITE),
"masks": masks_arr,
"img_path": saved.get("img_path", ""),
"img_pos": saved.get("img_pos", Vector2(0.5, 0.5)),
"img_scale": saved.get("img_scale", 1.0),
"img_rot": saved.get("img_rot", 0.0),
"tex": null,
"ref_path": saved.get("ref_path", ""),
"ref_tex": null,
"orig_tex": _get_material_texture(node)
}
if d.img_path != "": d.tex = load(d.img_path)
if d.ref_path != "": d.ref_tex = load(d.ref_path)
var mat = ShaderMaterial.new(); mat.shader = load(SHADER_PATH); mat.set_shader_parameter("albedo_texture", _uv_preview.texture)
node.set_surface_override_material(0, mat)
_mesh_data.append(d)
for c in node.get_children(): _collect_mesh_data(c)
func _get_mesh_data(name: String) -> Dictionary:
for d in _mesh_data:
if d.name == name: return d
return {}
# ─── SCENE TREE UI ───────────────────────────────────────────────────────────
func _build_scene_tree_ui():
for c in _mesh_list_vbox.get_children(): c.queue_free()
for d in _mesh_data:
var h := HBoxContainer.new(); _mesh_list_vbox.add_child(h)
var vis := Button.new(); vis.text = "👁"; vis.flat = true
vis.pressed.connect(_on_vis_pressed.bind(d.node, vis, d.name)); h.add_child(vis)
var col_dot := ColorRect.new(); col_dot.custom_minimum_size = Vector2(12, 20)
col_dot.color = d.fill_color if d.category >= 0 else Color(0.3,0.3,0.3,0.5); h.add_child(col_dot)
var btn := Button.new(); btn.text = d.name; btn.size_flags_horizontal = Control.SIZE_EXPAND_FILL; btn.alignment = HORIZONTAL_ALIGNMENT_LEFT
btn.pressed.connect(_select_mesh.bind(d.name)); h.add_child(btn)
func _on_vis_pressed(node: Node3D, vis_btn: Button, mesh_name: String):
node.visible = !node.visible; vis_btn.modulate = Color.WHITE if node.visible else Color.GRAY
if _inspected_mesh_name == mesh_name and _debug_wireframe: _debug_wireframe.visible = node.visible
func _select_mesh(mesh_name: String):
_inspected_mesh_name = mesh_name; _inspected_label.text = "Selected: " + mesh_name
if _debug_wireframe: _debug_wireframe.queue_free(); _debug_wireframe = null
var d = _get_mesh_data(mesh_name)
if d.is_empty(): return
var wire := MeshInstance3D.new(); wire.mesh = d.node.mesh
d.node.add_child(wire); _debug_wireframe = wire
if d.node.skeleton != NodePath(""):
var skel_node = d.node.get_node(d.node.skeleton)
if skel_node: wire.skeleton = wire.get_path_to(skel_node)
if "skin" in d.node: wire.skin = d.node.skin
var wm := ShaderMaterial.new()
wm.shader = shader_from_string("shader_type spatial; render_mode unshaded, wireframe; uniform vec4 albedo:source_color; void fragment() { ALBEDO = albedo.rgb; ALPHA = albedo.a; }")
var col = _get_current_wire_color(); col.a = 0.5
wm.set_shader_parameter("albedo", col)
wire.set_surface_override_material(0, wm)
wire.visible = d.node.visible
_update_part_inspector()
_uv_overlay.queue_redraw()
func _update_part_inspector():
var d = _get_mesh_data(_inspected_mesh_name)
if d.is_empty(): return
_pi_name_lbl.text = _inspected_mesh_name
_pi_cat_opt.selected = d.category + 1
_pi_color_btn.color = d.fill_color
_pi_image_btn.text = d.img_path.get_file() if d.img_path != "" else "None"
_pi_ref_btn.text = d.ref_path.get_file() if d.ref_path != "" else "Default"
_update_mask_list_ui()
if d.ref_tex: _uv_preview.texture = d.ref_tex
elif d.orig_tex: _uv_preview.texture = d.orig_tex
else: _uv_preview.texture = null
%TransformVBox.visible = (d.img_path != "")
_pi_pos_x.value = d.img_pos.x; _pi_pos_y.value = d.img_pos.y
_pi_scale.value = d.img_scale; _pi_rot.value = d.img_rot
# ─── PART INSPECTOR CALLBACKS ────────────────────────────────────────────────
var _picking_mode: String = "sticker"
func _save_mesh_cache(d: Dictionary):
_char_cache[_current_char_name()][d.name] = {
"category": d.category, "fill_color": d.fill_color,
"masks": d.masks, "img_path": d.img_path, "ref_path": d.ref_path,
"img_pos": d.img_pos, "img_scale": d.img_scale, "img_rot": d.img_rot
}
func _on_pi_cat_selected(idx: int):
var d = _get_mesh_data(_inspected_mesh_name); if d.is_empty(): return
d.category = idx - 1; _save_mesh_cache(d); _build_scene_tree_ui(); _uv_overlay.queue_redraw()
func _on_pi_color_changed(col: Color):
var d = _get_mesh_data(_inspected_mesh_name); if d.is_empty(): return
d.fill_color = col; _save_mesh_cache(d); _uv_overlay.queue_redraw()
func _on_pick_image(mode: String):
_picking_mode = mode
if not _file_dialog:
_file_dialog = FileDialog.new()
_file_dialog.file_mode = FileDialog.FILE_MODE_OPEN_FILE
_file_dialog.access = FileDialog.ACCESS_RESOURCES
_file_dialog.add_filter("*.png, *.jpg, *.webp", "Images")
add_child(_file_dialog)
if _file_dialog.file_selected.is_connected(_on_image_selected): _file_dialog.file_selected.disconnect(_on_image_selected)
_file_dialog.file_selected.connect(_on_image_selected); _file_dialog.popup_centered_ratio(0.5)
func _on_image_selected(path: String):
var d = _get_mesh_data(_inspected_mesh_name); if d.is_empty(): return
if _picking_mode == "sticker": d.img_path = path; d.tex = load(path)
else: d.ref_path = path; d.ref_tex = load(path)
_update_part_inspector(); _save_mesh_cache(d); _uv_overlay.queue_redraw()
func _on_clear_image():
var d = _get_mesh_data(_inspected_mesh_name); if d.is_empty(): return
d.img_path = ""; d.tex = null
_update_part_inspector(); _save_mesh_cache(d); _uv_overlay.queue_redraw()
func _on_clear_ref():
var d = _get_mesh_data(_inspected_mesh_name); if d.is_empty(): return
d.ref_path = ""; d.ref_tex = null
_update_part_inspector(); _save_mesh_cache(d); _uv_overlay.queue_redraw()
func _on_transform_changed(_v):
var d = _get_mesh_data(_inspected_mesh_name); if d.is_empty(): return
d.img_pos = Vector2(_pi_pos_x.value, _pi_pos_y.value)
d.img_scale = _pi_scale.value; d.img_rot = _pi_rot.value
_save_mesh_cache(d); _uv_overlay.queue_redraw()
# ─── MASK LAYERS ─────────────────────────────────────────────────────────────
func _update_mask_list_ui():
for c in %MaskList.get_children(): c.queue_free()
var d = _get_mesh_data(_inspected_mesh_name); if d.is_empty(): return
for i in range(d.masks.size()):
var hb = HBoxContainer.new()
var lbl = Label.new(); lbl.text = "Mask %s" % char(65 + i); lbl.size_flags_horizontal = Control.SIZE_EXPAND_FILL
var edit = Button.new(); edit.text = "Edit"; edit.pressed.connect(func(): _on_edit_mask(i))
var del = Button.new(); del.text = ""; del.pressed.connect(func(): _on_delete_mask(i))
hb.add_child(lbl); hb.add_child(edit); hb.add_child(del)
%MaskList.add_child(hb)
func _on_add_mask():
var d = _get_mesh_data(_inspected_mesh_name); if d.is_empty(): return
d.masks.append(PackedVector2Array())
_on_edit_mask(d.masks.size() - 1)
func _on_edit_mask(idx: int):
_current_mask_idx = idx
_drawing_mask = true
var d = _get_mesh_data(_inspected_mesh_name)
_mask_points = d.masks[idx]
%DrawingToolbar.visible = true
%DrawStatus.text = "Editing Mask %s..." % char(65 + idx)
_uv_overlay.queue_redraw()
func _on_delete_mask(idx: int):
var d = _get_mesh_data(_inspected_mesh_name); if d.is_empty(): return
d.masks.remove_at(idx)
if _current_mask_idx == idx: _on_finish_mask()
else: _update_mask_list_ui(); _save_mesh_cache(d); _uv_overlay.queue_redraw()
func _on_start_mask(): _on_add_mask()
func _on_finish_mask():
_drawing_mask = false
var d = _get_mesh_data(_inspected_mesh_name); if d.is_empty(): return
if _current_mask_idx >= 0:
d.masks.remove_at(_current_mask_idx)
d.masks.insert(_current_mask_idx, _mask_points)
%DrawingToolbar.visible = false
_current_mask_idx = -1; _mask_points = PackedVector2Array()
_update_mask_list_ui(); _save_mesh_cache(d); _uv_overlay.queue_redraw()
func _on_clear_mask():
_mask_points = PackedVector2Array(); _uv_overlay.queue_redraw()
func _on_uv_input(event: InputEvent):
if not _uv_preview or not _uv_preview.texture: return
var vs = _uv_overlay.size; var ts = _uv_preview.texture.get_size()
var sc = min(vs.x/ts.x, vs.y/ts.y); var rs = ts*sc; var off = (vs-rs)*0.5
if _drawing_mask:
var uv = (event.position - off) / rs
if event is InputEventMouseButton:
if event.button_index == MOUSE_BUTTON_LEFT:
if event.pressed:
# Check for drag
var best_dist = 0.05
_dragged_point_idx = -1
for i in range(_mask_points.size()):
var d = uv.distance_to(_mask_points[i])
if d < best_dist:
best_dist = d; _dragged_point_idx = i
if _dragged_point_idx == -1:
_mask_points.append(uv.clamp(Vector2.ZERO, Vector2.ONE))
else:
_dragged_point_idx = -1
_uv_overlay.queue_redraw()
elif event.button_index == MOUSE_BUTTON_RIGHT and event.pressed:
# Delete point
var best_dist = 0.05; var to_del = -1
for i in range(_mask_points.size()):
var d = uv.distance_to(_mask_points[i])
if d < best_dist: best_dist = d; to_del = i
if to_del != -1:
_mask_points.remove_at(to_del)
_uv_overlay.queue_redraw()
elif event is InputEventMouseMotion:
if _dragged_point_idx != -1:
_mask_points[_dragged_point_idx] = uv.clamp(Vector2.ZERO, Vector2.ONE)
_uv_overlay.queue_redraw()
return
var d = _get_mesh_data(_inspected_mesh_name)
if d.is_empty() or not d.tex: return
if event is InputEventMouseButton:
var uv = (event.position - off) / rs
if event.pressed and event.button_index == MOUSE_BUTTON_LEFT:
var dist = uv.distance_to(d.img_pos)
if dist < (0.1 / d.img_scale):
_gizmo_state = 1
if event.shift_pressed: _gizmo_state = 2
elif event.ctrl_pressed: _gizmo_state = 3
_gizmo_start_uv = uv
_gizmo_start_val = [d.img_pos, d.img_scale, d.img_rot]
else: _gizmo_state = 0
if event is InputEventMouseMotion and _gizmo_state > 0:
var uv = (event.position - off) / rs
var delta = uv - _gizmo_start_uv
if _gizmo_state == 1: d.img_pos = _gizmo_start_val[0] + delta
elif _gizmo_state == 2: d.img_pos = _gizmo_start_val[0]; d.img_scale = max(0.01, _gizmo_start_val[1] + delta.x * 2.0)
elif _gizmo_state == 3: d.img_rot = _gizmo_start_val[2] + delta.x * 180.0
_pi_pos_x.value = d.img_pos.x; _pi_pos_y.value = d.img_pos.y
_pi_scale.value = d.img_scale; _pi_rot.value = d.img_rot
_save_mesh_cache(d); _uv_overlay.queue_redraw()
# ─── UV OVERLAY DRAW ─────────────────────────────────────────────────────────
func _on_uv_overlay_draw():
if not _uv_preview or not _uv_preview.texture: return
var vs = _uv_overlay.size; var ts = _uv_preview.texture.get_size()
var sc = min(vs.x/ts.x, vs.y/ts.y); var rs = ts*sc; var off = (vs-rs)*0.5
if _hide_ref:
for d in _mesh_data:
if d.category < 0: continue
var tris = d.tris
for i in range(0, tris.size()-2, 3):
var poly = PackedVector2Array([off + tris[i]*rs, off + tris[i+1]*rs, off + tris[i+2]*rs])
_uv_overlay.draw_colored_polygon(poly, d.fill_color)
else:
if _show_all_uvs:
for d in _mesh_data:
if d.name == _inspected_mesh_name: continue
_draw_mesh_wireframe(d, off, rs, Color(0.2, 0.2, 0.2, 0.3))
for d in _mesh_data:
if d.category < 0 or d.name == _inspected_mesh_name: continue
_draw_mesh_filled(d, off, rs, Color(d.fill_color.r, d.fill_color.g, d.fill_color.b, 0.15))
_draw_mesh_wireframe(d, off, rs, Color(d.fill_color.r, d.fill_color.g, d.fill_color.b, 0.4))
if _inspected_mesh_name != "":
var d = _get_mesh_data(_inspected_mesh_name)
if not d.is_empty():
_draw_mesh_filled(d, off, rs, Color(1, 1, 1, 0.03))
_draw_mesh_wireframe(d, off, rs, Color(1, 0.8, 0.1, 1.0), 2.0)
for m in d.masks:
if m.size() >= 3:
var ps = _to_screen(m, rs, off)
if Geometry2D.triangulate_polygon(ps).size() > 0:
_uv_overlay.draw_colored_polygon(ps, Color(0, 1, 0, 0.2))
_uv_overlay.draw_polyline(ps, Color(0, 1, 0, 0.5), 1.5, true)
if _current_mask_idx >= 0 and _mask_points.size() > 0:
var ps = _to_screen(_mask_points, rs, off)
var mouse_uv = (_uv_overlay.get_local_mouse_position() - off) / rs
for i in range(ps.size()):
var is_hover = mouse_uv.distance_to(_mask_points[i]) < 0.05
_uv_overlay.draw_circle(ps[i], 5 if is_hover else 3, Color.CYAN if is_hover else Color.YELLOW)
if ps.size() > 1:
_uv_overlay.draw_polyline(ps, Color.YELLOW, 2.0)
_uv_overlay.draw_line(ps[-1], ps[0], Color(1, 1, 0, 0.3), 1.0)
for d in _mesh_data:
if d.category >= 0 and d.tex:
var rot_rad = deg_to_rad(d.img_rot)
var rect = Rect2(off + (d.img_pos - Vector2(0.5,0.5)*d.img_scale)*rs, Vector2(d.img_scale, d.img_scale)*rs)
_uv_overlay.draw_set_transform(rect.get_center(), rot_rad, Vector2.ONE)
_uv_overlay.draw_texture_rect(d.tex, Rect2(-rect.size*0.5, rect.size), false)
if d.name == _inspected_mesh_name:
var g_col = Color.YELLOW if _gizmo_state > 0 else Color.WHITE
_uv_overlay.draw_rect(Rect2(-rect.size*0.5, rect.size), g_col, false, 2.0)
_uv_overlay.draw_set_transform(Vector2.ZERO, 0, Vector2.ONE)
func _draw_mesh_wireframe(d: Dictionary, off: Vector2, rs: Vector2, col: Color, width: float = 1.0):
var tris = d.tris
var draw_col = _get_current_wire_color(); draw_col.a *= _wire_opacity_slider.value
for i in range(0, tris.size()-2, 3):
_uv_overlay.draw_line(off + tris[i]*rs, off + tris[i+1]*rs, draw_col, width)
_uv_overlay.draw_line(off + tris[i+1]*rs, off + tris[i+2]*rs, draw_col, width)
_uv_overlay.draw_line(off + tris[i+2]*rs, off + tris[i]*rs, draw_col, width)
func _draw_mesh_filled(d: Dictionary, off: Vector2, rs: Vector2, col: Color):
for i in range(0, d.tris.size()-2, 3):
_uv_overlay.draw_colored_polygon(PackedVector2Array([off+d.tris[i]*rs, off+d.tris[i+1]*rs, off+d.tris[i+2]*rs]), col)
# ─── GENERATE & EXPORT ───────────────────────────────────────────────────────
func _on_generate_pressed() -> void:
var img = _uv_preview.texture.get_image()
if img.is_compressed(): img.decompress()
var w = img.get_width(); var h = img.get_height()
# Transparent output — only assigned regions get color
var out = Image.create(w, h, false, Image.FORMAT_RGBA8)
for d in _mesh_data:
if d.category < 0: continue
var fill: Color = d.fill_color
var masks = d.masks
var tris = d.tris
var sticker: Image = null
if d.tex: sticker = d.tex.get_image(); if sticker.is_compressed(): sticker.decompress()
for ti in range(0, tris.size()-2, 3):
_rasterize_tri_aa(img, out, tris[ti], tris[ti+1], tris[ti+2], fill, masks, w, h, sticker, d)
var target_key = _export_folder_opt.get_item_text(_export_folder_opt.selected)
var out_dir = EXPORT_DIRS.get(target_key, BASE_TEX_DIR)
for d in [out_dir, BASE_TEX_DIR]:
if not DirAccess.dir_exists_absolute(d):
DirAccess.make_dir_recursive_absolute(d)
var prefix = _prefix_label.text
var ename = _export_name.text.strip_edges()
if ename.is_empty(): ename = "unnamed"
var png_name = prefix + ename
var png_path = BASE_TEX_DIR + png_name + ".png"
var err = out.save_png(png_path)
var result_msg = ""
if err == OK:
result_msg = "✅ Saved: " + png_name + ".png"
else:
result_msg = "❌ PNG Save Error: " + str(err)
_status_label.text = result_msg; _status_label.modulate = Color.RED; return
if _export_mode.selected == 1:
var tex = ImageTexture.create_from_image(out)
var mat := StandardMaterial3D.new(); mat.albedo_texture = tex
mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA_SCISSOR; mat.alpha_scissor_threshold = 0.5
var parts = prefix.split("_")
var mat_name = parts[0] + "_mat_" + parts[1] + "_" + ename
var mat_err = ResourceSaver.save(mat, out_dir + mat_name + ".tres")
if mat_err == OK:
result_msg += " + " + mat_name + ".tres"
else:
result_msg += " (❌ Mat Error: " + str(mat_err) + ")"
_status_label.text = result_msg; _status_label.modulate = Color.GREEN
# Force filesystem refresh so they show up in editor
if Engine.is_editor_hint():
var ep = EditorInterface.get_resource_filesystem()
if ep: ep.scan()
func _rasterize_tri_aa(src: Image, dst: Image, u0: Vector2, u1: Vector2, u2: Vector2, col: Color, masks: Array, w: int, h: int, sticker: Image = null, d: Dictionary = {}):
var min_x = int(min(u0.x, min(u1.x, u2.x)) * w); var max_x = int(max(u0.x, max(u1.x, u2.x)) * w)
var min_y = int(min(u0.y, min(u1.y, u2.y)) * h); var max_y = int(max(u0.y, max(u1.y, u2.y)) * h)
min_x = clamp(min_x-1,0,w-1); max_x = clamp(max_x+1,0,w-1); min_y = clamp(min_y-1,0,h-1); max_y = clamp(max_y+1,0,h-1)
for y in range(min_y, max_y + 1):
for x in range(min_x, max_x + 1):
var uv = Vector2(float(x)/w, float(y)/h)
if _point_in_triangle(uv, u0, u1, u2):
var mask_alpha = 1.0
if not masks.is_empty():
# 4x4 Supersampling
var hits = 0.0
for sy in range(4):
for sx in range(4):
var sub_uv = uv + Vector2(sx-1.5, sy-1.5) / Vector2(w*4, h*4)
var inside = false
for m in masks:
if _point_in_polygon(sub_uv, m): inside = true; break
if inside: hits += 1.0
mask_alpha = hits / 16.0
if mask_alpha > 0.001:
var final_col = col
if sticker:
var s_uv = (uv - d.img_pos).rotated(-deg_to_rad(d.img_rot)) / d.img_scale + Vector2(0.5, 0.5)
if s_uv.x >= 0 and s_uv.x <= 1 and s_uv.y >= 0 and s_uv.y <= 1:
var sc = sticker.get_pixelv(s_uv * Vector2(sticker.get_width()-1, sticker.get_height()-1))
final_col = col.lerp(sc, sc.a)
var old = dst.get_pixel(x, y)
dst.set_pixel(x, y, old.lerp(final_col, mask_alpha))
func _point_in_triangle(p: Vector2, a: Vector2, b: Vector2, c: Vector2) -> bool:
var d0 = (b-a).cross(p-a); var d1 = (c-b).cross(p-b); var d2 = (a-c).cross(p-c)
return ((d0>=0 and d1>=0 and d2>=0) or (d0<=0 and d1<=0 and d2<=0))
func _point_in_polygon(p: Vector2, poly: PackedVector2Array) -> bool:
var inside = false; var n = poly.size(); var j = n - 1
for i in n:
var pi = poly[i]; var pj = poly[j]
if ((pi.y > p.y) != (pj.y > p.y)) and (p.x < (pj.x - pi.x) * (p.y - pi.y) / (pj.y - pi.y) + pi.x):
inside = !inside
j = i
return inside
# ─── HELPERS ─────────────────────────────────────────────────────────────────
func _get_embedded_texture(n):
if n is MeshInstance3D and n.mesh:
var m = n.get_surface_override_material(0); if not m: m = n.mesh.surface_get_material(0)
if m and "albedo_texture" in m and m.albedo_texture: return m.albedo_texture
for ch in n.get_children(): var t = _get_embedded_texture(ch); if t: return t
return null
func _mk_label(t: String) -> Label:
var l := Label.new(); l.text = t; return l
# ─── PRESETS ─────────────────────────────────────────────────────────────────
func _on_save_preset():
var cname = _current_char_name(); if cname == "": return
var data = _char_cache[cname]
var out_data = {}
for mesh_name in data:
var d = data[mesh_name]
out_data[mesh_name] = {
"category": d.category,
"fill_color": d.fill_color.to_html(),
"masks": d.masks.map(func(m): return Array(m)),
"img_path": d.img_path,
"ref_path": d.ref_path,
"img_pos": [d.img_pos.x, d.img_pos.y],
"img_scale": d.img_scale,
"img_rot": d.img_rot
}
var f = FileAccess.open(PRESET_DIR + cname + "_preset.json", FileAccess.WRITE)
f.store_string(JSON.stringify(out_data, "\t"))
_status_label.text = "Preset Saved: " + cname + "_preset.json"; _status_label.modulate = Color.GREEN
func _on_load_preset():
var cname = _current_char_name(); if cname == "": return
var path = PRESET_DIR + cname + "_preset.json"
if not FileAccess.file_exists(path): _status_label.text = "No preset found for " + cname; _status_label.modulate = Color.RED; return
var f = FileAccess.open(path, FileAccess.READ)
var raw = JSON.parse_string(f.get_as_text())
if not raw: return
var cache = {}
for mesh_name in raw:
var d = raw[mesh_name]
var masks_arr = d.get("masks", [])
if masks_arr.is_empty() and d.has("mask"): masks_arr = [d["mask"]]
cache[mesh_name] = {
"category": int(d.category),
"fill_color": Color.html(d.fill_color),
"masks": masks_arr.map(func(m): return PackedVector2Array(m)),
"img_path": d.img_path,
"ref_path": d.get("ref_path", ""),
"img_pos": Vector2(d.img_pos[0], d.img_pos[1]),
"img_scale": float(d.img_scale),
"img_rot": float(d.img_rot)
}
_char_cache[cname] = cache
_update_character_preview()
_status_label.text = "Preset Loaded: " + cname; _status_label.modulate = Color.CYAN
func _force_t_pose(node: Node):
if node is AnimationPlayer:
if node.has_animation("RESET"):
node.play("RESET")
node.advance(0)
node.stop()
if node is Skeleton3D:
for i in node.get_bone_count():
node.reset_bone_pose(i)
for c in node.get_children(): _force_t_pose(c)
func _get_material_texture(node: Node) -> Texture2D:
if node is MeshInstance3D and node.mesh:
var mat = node.get_surface_override_material(0)
if not mat: mat = node.mesh.surface_get_material(0)
if mat is StandardMaterial3D or mat is ORMMaterial3D:
return mat.albedo_texture
elif mat is ShaderMaterial:
# Try to find a parameter named albedo or main_texture
for p in ["albedo_texture", "main_texture", "texture_albedo"]:
var t = mat.get_shader_parameter(p)
if t is Texture2D: return t
return null
func shader_from_string(code: String) -> Shader:
var s = Shader.new(); s.code = code; return s
func _get_current_wire_color() -> Color:
match _wire_color_opt.selected:
0: return Color.BLACK
1: return Color.WHITE
2: return Color.YELLOW
3: return Color.GREEN
return Color.YELLOW
func _to_screen(pts: PackedVector2Array, rs: Vector2, off: Vector2) -> PackedVector2Array:
var out := PackedVector2Array()
for p in pts: out.append(p * rs + off)
return out