feat: adding the skin_shader_generator, and gacha base barebone
This commit is contained in:
@@ -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
|
||||
Reference in New Issue
Block a user