Last active
November 17, 2023 06:23
-
-
Save nightcycle/dba37e7241aac9873d92edc8105f3019 to your computer and use it in GitHub Desktop.
I wrote this because Synty Studios uses texture maps for their assets, but it's more convenient to recolor the mesh directly in Roblox Studio. This splits it into multiple OBJ based on the color the UV points to.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
--!strict | |
-- Run this in command line after you import the meshes | |
-- Services | |
-- Packages | |
-- Modules | |
-- Types | |
-- Constants | |
local SEPARATION = 1 | |
local MAX_X_OFFSET = 50 | |
-- Variables | |
local largestWidth = 0 | |
local xOffset = 0 | |
local yOffset = 0 | |
-- References | |
-- Private Functions | |
-- Class | |
local groups: {[string]: {[number]: BasePart}} = {} | |
for i, part in ipairs(workspace:GetChildren()) do | |
if part:IsA("MeshPart") then | |
local groupName = part.Name:gsub("Meshes/", ""):split("___")[1] | |
groups[groupName] = groups[groupName] or {} | |
table.insert(groups[groupName], part) | |
end | |
end | |
for groupName, partList in pairs(groups) do | |
local model = Instance.new("Model") | |
model.Name = groupName | |
for i, part in ipairs(partList) do | |
part.Name = part.Name:gsub(groupName.."___", "") | |
part.Parent = model | |
end | |
model.Parent = workspace | |
end | |
for i, model in ipairs(workspace:GetChildren()) do | |
if model:IsA("Model") then | |
xOffset += SEPARATION | |
local _cf, size = model:GetBoundingBox() | |
local width = math.max(size.X, size.Y, size.Z) | |
largestWidth = math.max(largestWidth, width) | |
if MAX_X_OFFSET < xOffset then | |
xOffset = 0 | |
yOffset += largestWidth | |
end | |
model:PivotTo(CFrame.new(xOffset+width/2,0,yOffset)) | |
xOffset += width | |
for i, part in ipairs(model:GetChildren()) do | |
if part:IsA("BasePart") then | |
local hex = part.Name:gsub("Meshes/", ""):split(" ")[1] | |
part.Color = Color3.fromHex(`#{hex}`) | |
part.Material = Enum.Material.SmoothPlastic | |
-- part.Name = BrickColor.new(part.Color).Name:gsub(" ", "_") | |
end | |
end | |
end | |
end |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import os | |
import json | |
import shutil | |
import math | |
from PIL import Image | |
from typing import TypedDict, Tuple | |
TEXTURE_PATH = "scripts/mesh/texture.png" | |
SIMILARITY_THRESHOLD = 1.0 | |
NAME_TOKEN = "o " | |
VERTEX_TOKEN = "v " | |
UV_TOKEN = "vt" | |
FACE_TOKEN = "f " | |
NORMAL_TOKEN = "vn" | |
SMOOTHING_TOKEN = "s " | |
USE_MAT_TOKEN = "usemtl" | |
NEW_MAT_TOKEN = "newmtl" | |
MTL_COL_TOKEN = "Kd" | |
class Vector3: | |
x: float | |
y: float | |
z: float | |
def __init__(self, x: float, y: float, z: float): | |
self.x = x | |
self.y = y | |
self.z = z | |
def __add__(self, other): | |
return Vector3(self.x + other.x, self.y + other.y, self.z + other.z) | |
def __sub__(self, other): | |
return Vector3(self.x - other.x, self.y - other.y, self.z - other.z) | |
def __mul__(self, other): | |
if type(other) == Vector3: | |
return Vector3(self.x * other.x, self.y * other.y, self.z * other.z) | |
else: | |
return Vector3(self.x * other.x, self.y * other.y, self.z * other.z) | |
def __truediv__(self, other): | |
if type(other) == Vector3: | |
return Vector3(self.x / other.x, self.y / other.y, self.z / other.z) | |
else: | |
return Vector3(self.x / other.x, self.y / other.y, self.z / other.z) | |
def __eq__(self, other): | |
return self.x == other.x and self.y == other.y and self.z == other.z | |
def __str__(self): | |
return f"Vector3({self.x}, {self.y}, {self.z})" | |
def magnitude(self): | |
return math.sqrt(self.x**2 + self.y**2 + self.z**2) | |
def unit(self): | |
mag = self.magnitude() | |
return Vector3(self.x / mag, self.y / mag, self.z / mag) | |
def dot(self, other): | |
return self.x * other.x + self.y * other.y + self.z * other.z | |
def cross(self, other): | |
return Vector3( | |
self.y * other.z - self.z * other.y, | |
self.z * other.x - self.x * other.z, | |
self.x * other.y - self.y * other.x | |
) | |
class Vector2: | |
x: float | |
y: float | |
def __init__(self, x: float, y: float): | |
self.x = x | |
self.y = y | |
def __add__(self, other): | |
return Vector2(self.x + other.x, self.y + other.y) | |
def __sub__(self, other): | |
return Vector2(self.x - other.x, self.y - other.y) | |
def __mul__(self, other): | |
if type(other) == Vector2: | |
return Vector2(self.x * other.x, self.y * other.y) | |
else: | |
return Vector2(self.x * other.x, self.y * other.y) | |
def __truediv__(self, other): | |
if type(other) == Vector2: | |
return Vector2(self.x / other.x, self.y / other.y) | |
else: | |
return Vector2(self.x / other.x, self.y / other.y) | |
def __eq__(self, other): | |
return self.x == other.x and self.y == other.y | |
def __str__(self): | |
return f"Vector2({self.x}, {self.y})" | |
def magnitude(self): | |
return math.sqrt(self.x**2 + self.y**2) | |
def unit(self): | |
mag = self.magnitude() | |
return Vector2(self.x / mag, self.y / mag) | |
def dot(self, other): | |
return self.x * other.x + self.y * other.y | |
class Color3: | |
r: float | |
g: float | |
b: float | |
def __init__(self, r: float, g: float, b: float): | |
self.r = r | |
self.g = g | |
self.b = b | |
@classmethod | |
def from_rgb(cls, r: int, g: int, b: int): | |
return cls(float(r)/255, float(g)/255, float(b)/255) | |
@classmethod | |
def from_hex(cls, hex_str: str): | |
hex_str = hex_str.lstrip('#') | |
r, g, b = tuple(int(hex_str[i:i + 2], 16) / 255 for i in (0, 2, 4)) | |
return cls(r, g, b) | |
def to_hex(self) -> str: | |
return '#{:02x}{:02x}{:02x}'.format(int(self.r * 255), int(self.g * 255), int(self.b * 255)) | |
def to_rgb(self) -> Tuple[int, int, int]: | |
r: int = int(round(self.r*255)) | |
g: int = int(round(self.g*255)) | |
b: int = int(round(self.b*255)) | |
return (r,g,b) | |
def is_similar(self, other, threshold: float) -> bool: | |
# Convert hex codes to RGB | |
rgb1 = self.to_rgb() | |
rgb2 = other.to_rgb() | |
# Calculate Euclidean distance between the colors | |
distance = sum((a - b) ** 2 for a, b in zip(rgb1, rgb2)) ** 0.5 | |
# Normalize the distance by the maximum possible Euclidean distance | |
# which is the diagonal of the color cube, sqrt(255^2 * 3) | |
max_distance = 255 * (3 ** 0.5) | |
normalized_distance = distance / max_distance | |
# Invert the distance to get a similarity measure | |
similarity = 1 - normalized_distance | |
# Check against the provided threshold | |
return similarity >= threshold | |
def __str__(self): | |
r,g,b = self.to_rgb() | |
return f"Color3({r},{g},{b})" | |
def __eq__(self, other) -> bool: | |
r1,g1,b1 = self.to_rgb() | |
r2,g2,b2 = other.to_rgb() | |
return r1==r2 and g1 == g2 and b1 == b2 | |
class Point: | |
position: Vector3 | |
normal: Vector3 | |
uv: Vector2 | |
color: Color3 | None | |
def __init__(self, position: Vector3, normal: Vector3, uv: Vector2): | |
self.position = position | |
self.normal = normal | |
self.uv = uv | |
self.color = None | |
def __eq__(self, other) -> bool: | |
return self.position == other.position and self.normal == other.normal and self.color == other.color and self.uv == other.uv | |
class Texture: | |
def __init__(self, image): | |
self.image = image.convert('RGBA') | |
@classmethod | |
def read(cls, path: str): | |
return cls(Image.open(path)) | |
def get_color(self, uv: Vector2) -> Color3: | |
width, height = self.image.size | |
pixel_x = int(float(width) * uv.x) | |
pixel_y = int(1.0 - float(height) * uv.y) | |
r, g, b, a = self.image.getpixel((pixel_x, pixel_y)) | |
return Color3.from_rgb(r,g,b) | |
def __eq__(self, other) -> bool: | |
return self.image == other.image | |
class Material: | |
name: str | |
color: Color3 | |
def __init__(self, name: str, color: Color3): | |
self.name = name | |
self.color = color | |
def __eq__(self, other) -> bool: | |
return self.name == other.name and self.color == other.color | |
@classmethod | |
def from_content(cls, content: str): | |
name:str | |
color: Color3 | |
for line in content.splitlines(): | |
if NEW_MAT_TOKEN in line: | |
name = line.replace(NEW_MAT_TOKEN+" ", "") | |
elif MTL_COL_TOKEN in line: | |
color = Color3( | |
float(line.split(" ")[1]), | |
float(line.split(" ")[2]), | |
float(line.split(" ")[3]) | |
) | |
return cls(name, color) | |
class Face: | |
points: list[Point] | |
def __init__(self, points: list[Point]): | |
self.points = points | |
def __eq__(self, other) -> bool: | |
for i, point in enumerate(self.points): | |
if i in other.points: | |
if point != other.points[i]: | |
return False | |
else: | |
return False | |
return True | |
class MaterialSet: | |
name: str | |
materials: list[Material] | |
path: str | None | |
def __init__(self, name: str, materials: list[Material], path: str | None = None): | |
self.name = name | |
self.path = path | |
self.materials = materials | |
@classmethod | |
def read(cls, mtl_path: str): | |
materials: list[Material] = [] | |
with open(mtl_path, "r") as mtl_file: | |
content = mtl_file.read() | |
blocks: list[str] = [] | |
current_block = "" | |
for line in content.splitlines(): | |
if line[0:len(NEW_MAT_TOKEN)] == NEW_MAT_TOKEN: | |
if len(current_block) > 0: | |
blocks.append(current_block) | |
current_block = "" | |
current_block += f"\n{line}" | |
for block in blocks: | |
materials.append(Material.from_content(block)) | |
return cls(mtl_path, materials) | |
def __eq__(self, other) -> bool: | |
if self.name == other.name: | |
for i, material in enumerate(self.materials): | |
if i in other.materials: | |
if material != other.materials[i]: | |
return False | |
else: | |
return False | |
return True | |
else: | |
return False | |
class Mesh: | |
is_smooth: bool | |
name: str | None | |
texture: Texture | None | |
material: Material | None | |
color: Color3 | None | |
faces: list[Face] | |
def __init__( | |
self, | |
faces: list[Face], | |
is_smooth: bool=False, | |
name: str|None=None, | |
texture: Texture|None=None, | |
material: Material|None=None, | |
color: Color3|None=None | |
): | |
self.faces = faces | |
self.material = material | |
self.name = name | |
self.texture = texture | |
self.is_smooth = is_smooth | |
self.color = color | |
@classmethod | |
def from_content(cls, content: str, material_set: MaterialSet|None=None, texture: Texture|None=None): | |
vertex_count = 0 | |
uv_point_count = 0 | |
normal_count = 0 | |
vertices: dict[int, Vector3] = {} | |
uv_points: dict[int, Vector2] = {} | |
normals: dict[int, Vector3] = {} | |
faces: list[Face] = [] | |
is_smooth = True | |
obj_name: None | str = None | |
mat_name: None | str = None | |
face_lines: list[str] = [] | |
for line in content.splitlines(): | |
if len(line) > 0: | |
token = line[0:2] | |
if UV_TOKEN == token: | |
uv_point_count += 1 | |
uv_points[uv_point_count] = Vector2( | |
float(line.split(" ")[1]), | |
float(line.split(" ")[2]) | |
) | |
elif FACE_TOKEN == token: | |
face_lines.append(line) | |
elif NAME_TOKEN == token: | |
obj_name == line[3:] | |
elif NORMAL_TOKEN == token: | |
normal_count += 1 | |
normals[normal_count] = Vector3( | |
float(line.split(" ")[1]), | |
float(line.split(" ")[2]), | |
float(line.split(" ")[3]) | |
) | |
elif VERTEX_TOKEN == token: | |
vertex_count += 1 | |
vertices[vertex_count] = Vector3( | |
float(line.split(" ")[1]), | |
float(line.split(" ")[2]), | |
float(line.split(" ")[3]) | |
) | |
elif SMOOTHING_TOKEN == token: | |
if "off" in line: | |
is_smooth = False | |
elif len(line) >= len(USE_MAT_TOKEN) and line[0:len(USE_MAT_TOKEN)] == USE_MAT_TOKEN: | |
mat_name = line[len(USE_MAT_TOKEN):] | |
for face_line in face_lines: | |
points: list[Point] = [] | |
for j, point_text in enumerate(face_line.split(" ")): | |
if j > 0: | |
normal_index = int(point_text.split("/")[2]) | |
vertex_index = int(point_text.split("/")[0]) | |
uv_index = int(point_text.split("/")[1]) | |
points.append(Point(vertices[vertex_index], normals[normal_index], uv_points[uv_index])) | |
faces.append(Face(points)) | |
material: Material|None = None | |
if type(mat_name) == str and type(material_set) == MaterialSet: | |
for m in material_set.materials: | |
if m.name == mat_name: | |
material = m | |
break | |
return cls(faces, is_smooth, obj_name, texture, material) | |
def add_depth(self) -> None: | |
initial_face: Face = self.faces[0] | |
initial_point: Point = initial_face.points[0] | |
is_x_same = True | |
is_y_same = True | |
is_z_same = True | |
for face in self.faces: | |
for point in face.points: | |
if is_x_same and point.position.x != initial_point.position.x: | |
is_x_same = False | |
if is_y_same and point.position.y != initial_point.position.y: | |
is_y_same = False | |
if is_z_same and point.position.z != initial_point.position.z: | |
is_z_same = False | |
if len(self.faces) == 1: | |
self.faces.append(initial_face) | |
similarity_count = 0 | |
if is_x_same: | |
similarity_count += 1 | |
if is_y_same: | |
similarity_count += 1 | |
if is_z_same: | |
similarity_count += 1 | |
if similarity_count > 0: | |
if is_x_same: | |
initial_point.position.x += 0.000001 | |
if is_y_same: | |
initial_point.position.y += 0.000001 | |
if is_z_same: | |
initial_point.position.z += 0.000001 | |
return None | |
def split_by_color(self, threshold: str) -> list: | |
texture = self.texture | |
assert type(texture) == Texture | |
color_face_registry: dict[str, list[Face]] = {} | |
for face in self.faces: | |
ux: float = 0 | |
uy: float = 0 | |
for point in face.points: | |
ux += point.uv.x | |
uy += point.uv.y | |
ux /= len(face.points) | |
uy /= len(face.points) | |
color = texture.get_color(Vector2(ux, uy)) | |
for other_color_hex in color_face_registry: | |
other_color = Color3.from_hex(other_color_hex) | |
if other_color.is_similar(color, threshold): | |
color = other_color | |
if not color.to_hex() in color_face_registry: | |
color_face_registry[color.to_hex()] = [] | |
color_face_registry[color.to_hex()].append(face) | |
mesh_list: list[Mesh] = [] | |
for color_hex, face_list in color_face_registry.items(): | |
mesh = Mesh( | |
faces=face_list, | |
is_smooth=self.is_smooth, | |
name=color_hex.replace("#", ""), | |
texture=self.texture, | |
material=None, | |
color=Color3.from_hex(color_hex) | |
) | |
mesh.add_depth() | |
mesh_list.append(mesh) | |
return mesh_list | |
def dump(self) -> str: | |
lines: list[str] = [ | |
f"o {self.name}" | |
] | |
vertex_list: list[Vector3] = [] | |
normal_list: list[Vector3] = [] | |
uv_list: list[Vector2] = [] | |
face_lines: list[str] = [] | |
if self.is_smooth: | |
face_lines.append(f"{SMOOTHING_TOKEN} on") | |
else: | |
face_lines.append(f"{SMOOTHING_TOKEN} off") | |
material = self.material | |
if type(material) == Material: | |
face_lines.append(f"usemtl {material.name}") | |
for face in self.faces: | |
face_line = f"{FACE_TOKEN}" | |
for point in face.points: | |
# register vertex and get index for later | |
if not point.position in vertex_list: | |
vertex_list.append(point.position) | |
vertex_index = vertex_list.index(point.position) | |
if not point.normal in normal_list: | |
normal_list.append(point.normal) | |
normal_index = normal_list.index(point.normal) | |
if not point.uv in uv_list: | |
uv_list.append(point.uv) | |
uv_index = uv_list.index(point.uv) | |
face_line += f" {vertex_index+1}/{uv_index+1}/{normal_index+1}" | |
face_lines.append(face_line) | |
for vertex in vertex_list: | |
lines.append(f"{VERTEX_TOKEN} {vertex.x} {vertex.y} {vertex.z}") | |
for uv_point in uv_list: | |
lines.append(f"{UV_TOKEN} {uv_point.x} {uv_point.y}") | |
for normal in normal_list: | |
lines.append(f"{NORMAL_TOKEN} {normal.x} {normal.y} {normal.z}") | |
return "\n".join(lines + face_lines) | |
class MeshSet: | |
material_set: MaterialSet|None | |
texture: Texture|None | |
meshes: list[Mesh] | |
def __init__(self, meshes: list[Mesh], material_set:MaterialSet|None=None, texture:Texture|None=None): | |
self.material_set = material_set | |
self.meshes = meshes | |
self.texture = texture | |
@classmethod | |
def read(cls, obj_path: str, mtl_path:str | None, texture_path:str | None): | |
material_set: MaterialSet | None=None | |
if type(mtl_path) == str: | |
material_set = MaterialSet.read(mtl_path) | |
mesh_list: list[Mesh] = [] | |
texture: Texture | None=None | |
if type(texture_path) == str: | |
texture = Texture.read(texture_path) | |
with open(obj_path, "r") as obj_file: | |
content = obj_file.read() | |
blocks: list[str] = [] | |
current_block = "" | |
for line in content.splitlines(): | |
if line[0:2] == NAME_TOKEN: | |
if len(current_block) > 0: | |
blocks.append(current_block) | |
current_block = "" | |
current_block += f"\n{line}" | |
blocks.append(current_block) | |
for block in blocks: | |
mesh_list.append(Mesh.from_content(block, material_set, texture)) | |
return cls(mesh_list, material_set) | |
def dump(self) -> str: | |
lines: list[str] = [] | |
material_set = self.material_set | |
if type(material_set) == MaterialSet: | |
material_set_path = material_set.path | |
if material_set_path != None: | |
lines.append(f"mtllib {material_set_path}") | |
for mesh in self.meshes: | |
lines.append(mesh.dump()) | |
return "\n".join(lines) | |
def expand_by_texture(output_dir_path: str, texture_path: str, obj_path: str, theshold: float): | |
# if os.path.exists(output_dir_path): | |
# shutil.rmtree(output_dir_path) | |
# if not os.path.exists(output_dir_path): | |
# os.makedirs(output_dir_path) | |
mesh_set = MeshSet.read(obj_path, None, texture_path) | |
main_mesh = mesh_set.meshes[0] | |
for sub_mesh in main_mesh.split_by_color(theshold): | |
name = os.path.basename(obj_path).replace(".obj", "") | |
with open(f"{output_dir_path}/{name}___{sub_mesh.name}.obj", "w") as sub_file: | |
sub_file.write(sub_mesh.dump()) | |
OBJ_DIR_PATH = "scripts/mesh/objs" | |
for name in os.listdir(OBJ_DIR_PATH): | |
expand_by_texture( | |
f"scripts/mesh/dump", | |
TEXTURE_PATH, | |
f"{OBJ_DIR_PATH}/{name}", | |
SIMILARITY_THRESHOLD | |
) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment