@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