Skip to content

Instantly share code, notes, and snippets.

@nightcycle
Last active November 17, 2023 06:23
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save nightcycle/dba37e7241aac9873d92edc8105f3019 to your computer and use it in GitHub Desktop.
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.
--!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
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