# De-mirrors Lena's limb UVs (v2 — triangle-level split). # Her left arm/leg share the right side's UV islands (arms 100%, legs 99%), so # any baked tattoo prints on both sides. v1 moved whole islands and left 4%/29% # residual overlap (mixed torso+limb islands stayed put). v2 moves exactly the # triangles a left-limb bake would paint (max left-chain weight ≥ 0.28), splits # mixed islands by duplicating boundary vertices (seam is invisible: positions/ # normals identical, texels copied 1:1), widens the atlas 4096→8192 and packs # the moved pieces into the new right half. Exports a modified GLB. # Run headless: # tinqs.console.exe --headless --path tattoo-test -s res://demirror_lena.gd extends SceneTree const GLB_IN := "C:/Users/CAN/tinqs-ltd/ariki-game/assets/quaternius/derived-bodies/Ariki_Female_QuatSkin.glb" const GLB_OUT := "C:/Users/CAN/tinqs-ltd/tattoo-test/lena_demirrored.glb" const MOVE_W := 0.28 # move tris whose max left-chain weight ≥ this (bake gate is 0.30) const PAD := 16 # texel padding around moved pieces var skel: Skeleton3D func _init() -> void: var doc := GLTFDocument.new() var state := GLTFState.new() if doc.append_from_file(GLB_IN, state) != OK: push_error("GLB load failed") quit(1) return var scene := doc.generate_scene(state) var body: MeshInstance3D = null var stack: Array = [scene] while not stack.is_empty(): var n: Node = stack.pop_back() if n is Skeleton3D and skel == null: skel = n if n is MeshInstance3D and String(n.name).to_lower().contains("lena"): body = n for c in n.get_children(): stack.push_back(c) if skel == null or body == null: push_error("skeleton or Lena mesh not found") quit(1) return var mesh: ArrayMesh = body.mesh var arrays: Array = mesh.surface_get_arrays(0) var verts: PackedVector3Array = arrays[Mesh.ARRAY_VERTEX] var norms: PackedVector3Array = arrays[Mesh.ARRAY_NORMAL] var tangents: PackedFloat32Array = arrays[Mesh.ARRAY_TANGENT] if arrays[Mesh.ARRAY_TANGENT] != null else PackedFloat32Array() var uvs: PackedVector2Array = arrays[Mesh.ARRAY_TEX_UV] var vbones: PackedInt32Array = arrays[Mesh.ARRAY_BONES] var vweights: PackedFloat32Array = arrays[Mesh.ARRAY_WEIGHTS] var indices: PackedInt32Array = arrays[Mesh.ARRAY_INDEX] var nverts := verts.size() var influences := int(float(vbones.size()) / float(nverts)) var orig_uvs := uvs.duplicate() var skin: Skin = body.skin var bind_bone := PackedInt32Array() for i in skin.get_bind_count(): var b := skin.get_bind_bone(i) if b < 0: b = skel.find_bone(skin.get_bind_name(i)) bind_bone.append(b) # per-vertex combined LEFT-limb weight (arm incl. fingers + leg incl. ball) var left_bones := ["upperarm_l", "lowerarm_l", "hand_l", "thigh_l", "calf_l", "foot_l", "ball_l", "ball_leaf_l"] for f in ["index", "middle", "pinky", "ring", "thumb"]: for seg in ["01", "02", "03", "04_leaf"]: left_bones.append("%s_%s_l" % [f, seg]) var left_binds := _bind_set(bind_bone, left_bones) var lw := PackedFloat32Array() lw.resize(nverts) for v in nverts: for k in influences: if left_binds.has(vbones[v * influences + k]): lw[v] += vweights[v * influences + k] # moved triangles = what a left-limb bake would touch var ntris := indices.size() / 3 var tri_moved := PackedByteArray() tri_moved.resize(ntris) var n_moved_tris := 0 for t in ntris: var w := maxf(lw[indices[t * 3]], maxf(lw[indices[t * 3 + 1]], lw[indices[t * 3 + 2]])) if w >= MOVE_W: tri_moved[t] = 1 n_moved_tris += 1 print("moved tris: %d / %d" % [n_moved_tris, ntris]) # vertex usage: moved-only, unmoved-only, or boundary (both) var used_moved := PackedByteArray() used_moved.resize(nverts) var used_unmoved := PackedByteArray() used_unmoved.resize(nverts) for t in ntris: for j in 3: var v := indices[t * 3 + j] if tri_moved[t] == 1: used_moved[v] = 1 else: used_unmoved[v] = 1 # connected components of moved tris (via shared verts) var parent := PackedInt32Array() parent.resize(nverts) for v in nverts: parent[v] = v for t in ntris: if tri_moved[t] == 0: continue var a := _find(parent, indices[t * 3]) var b := _find(parent, indices[t * 3 + 1]) var c := _find(parent, indices[t * 3 + 2]) if b != a: parent[b] = a if c != a: parent[c] = a # duplicate boundary verts; moved tris rebind to the copies var dup_of := {} var comp_of_new := {} # new/moved vert index -> component root for t in ntris: if tri_moved[t] == 0: continue var root := _find(parent, indices[t * 3]) for j in 3: var v := indices[t * 3 + j] if used_unmoved[v] == 1: if not dup_of.has(v): var nv := verts.size() verts.append(verts[v]) norms.append(norms[v]) if tangents.size() > 0: for k in 4: tangents.append(tangents[v * 4 + k]) uvs.append(uvs[v]) orig_uvs.append(orig_uvs[v]) for k in influences: vbones.append(vbones[v * influences + k]) vweights.append(vweights[v * influences + k]) dup_of[v] = nv comp_of_new[nv] = root indices[t * 3 + j] = dup_of[v] else: comp_of_new[v] = root print("boundary verts duplicated: ", dup_of.size()) # source texture → widened atlas var mat: BaseMaterial3D = mesh.surface_get_material(0) var img: Image = mat.albedo_texture.get_image() img.clear_mipmaps() img.convert(Image.FORMAT_RGBA8) var tw := img.get_width() var th := img.get_height() var wide := Image.create(tw * 2, th, false, Image.FORMAT_RGBA8) wide.blit_rect(img, Rect2i(0, 0, tw, th), Vector2i.ZERO) # everyone's u compresses into the left half for v in uvs.size(): uvs[v].x *= 0.5 # component bboxes in ORIGINAL texel space var comps := {} for v in comp_of_new: var root: int = comp_of_new[v] if not comps.has(root): comps[root] = {"verts": [], "mn": Vector2(1e9, 1e9), "mx": Vector2(-1e9, -1e9)} comps[root]["verts"].append(v) comps[root]["mn"] = (comps[root]["mn"] as Vector2).min(orig_uvs[v]) comps[root]["mx"] = (comps[root]["mx"] as Vector2).max(orig_uvs[v]) var pieces := [] for root in comps: var m: Dictionary = comps[root] m["px0"] = maxi(int(m["mn"].x * tw) - PAD, 0) m["py0"] = maxi(int(m["mn"].y * th) - PAD, 0) m["px1"] = mini(int(m["mx"].x * tw) + PAD, tw - 1) m["py1"] = mini(int(m["mx"].y * th) + PAD, th - 1) m["h"] = m["py1"] - m["py0"] pieces.append(m) pieces.sort_custom(func(a, b): return a["h"] > b["h"]) print("moved pieces: ", pieces.size()) # shelf-pack into the right half, blit texels, remap UVs var cursor_x := tw + PAD var cursor_y := PAD var row_h := 0 for m in pieces: var w_px: int = m["px1"] - m["px0"] var h_px: int = m["py1"] - m["py0"] if cursor_x + w_px >= tw * 2 - PAD: cursor_x = tw + PAD cursor_y += row_h + PAD row_h = 0 if cursor_y + h_px >= th - PAD: push_error("packing overflow — atlas half too small") quit(1) return wide.blit_rect(img, Rect2i(m["px0"], m["py0"], w_px, h_px), Vector2i(cursor_x, cursor_y)) for v in m["verts"]: var opx: float = orig_uvs[v].x * tw var opy: float = orig_uvs[v].y * th uvs[v] = Vector2( (cursor_x + (opx - m["px0"])) / (tw * 2.0), (cursor_y + (opy - m["py0"])) / float(th)) cursor_x += w_px + PAD row_h = maxi(row_h, h_px) # rebuild surface + texture, export arrays[Mesh.ARRAY_VERTEX] = verts arrays[Mesh.ARRAY_NORMAL] = norms if tangents.size() > 0: arrays[Mesh.ARRAY_TANGENT] = tangents arrays[Mesh.ARRAY_TEX_UV] = uvs arrays[Mesh.ARRAY_BONES] = vbones arrays[Mesh.ARRAY_WEIGHTS] = vweights arrays[Mesh.ARRAY_INDEX] = indices var new_mesh := ArrayMesh.new() new_mesh.add_surface_from_arrays(Mesh.PRIMITIVE_TRIANGLES, arrays) mat.albedo_texture = ImageTexture.create_from_image(wide) new_mesh.surface_set_material(0, mat) body.mesh = new_mesh var out_doc := GLTFDocument.new() var out_state := GLTFState.new() if out_doc.append_from_scene(scene, out_state) != OK: push_error("export append failed") quit(1) return if out_doc.write_to_filesystem(out_state, GLB_OUT) != OK: push_error("export write failed") quit(1) return print("verts %d -> %d, atlas %dx%d" % [nverts, verts.size(), tw * 2, th]) print("saved: ", GLB_OUT) quit() func _bind_set(bind_bone: PackedInt32Array, names: Array) -> Dictionary: var bone_ids := {} for bn in names: var idx := skel.find_bone(bn) if idx < 0: push_error("bone not found: " + str(bn)) bone_ids[idx] = true var out := {} for i in bind_bone.size(): if bone_ids.has(bind_bone[i]): out[i] = true return out func _find(parent: PackedInt32Array, v: int) -> int: var r := v while parent[r] != r: parent[r] = parent[parent[r]] r = parent[r] return r