UV Export: add option to export UV tiles #104940
@ -5,7 +5,7 @@
|
||||
bl_info = {
|
||||
"name": "UV Layout",
|
||||
"author": "Campbell Barton, Matt Ebb",
|
||||
"version": (1, 1, 6),
|
||||
"version": (1, 2, 0),
|
||||
"blender": (3, 0, 0),
|
||||
"location": "UV Editor > UV > Export UV Layout",
|
||||
"description": "Export the UV layout as a 2D graphic",
|
||||
@ -54,6 +54,19 @@ class ExportUVLayout(bpy.types.Operator):
|
||||
description="Export all UVs in this mesh (not just visible ones)",
|
||||
default=False,
|
||||
)
|
||||
export_tiles: EnumProperty(
|
||||
name="Export Tiles",
|
||||
items=(
|
||||
('NONE', "None",
|
||||
"Export only UVs in the [0, 1] range"),
|
||||
('UDIM', "UDIM",
|
||||
"Export tiles in the UDIM numbering scheme: 1001 + u-tile + 10*v-tile"),
|
||||
('UV', "UVTILE",
|
||||
"Export tiles in the UVTILE numbering scheme: u(u-tile + 1)_v(v-tile + 1)"),
|
||||
deadpin marked this conversation as resolved
Outdated
|
||||
),
|
||||
description="Choose whether to export only the [0, 1 range], or all UV tiles",
|
||||
default='NONE',
|
||||
)
|
||||
modified: BoolProperty(
|
||||
name="Modified",
|
||||
description="Exports UVs from the modified mesh",
|
||||
@ -73,6 +86,7 @@ class ExportUVLayout(bpy.types.Operator):
|
||||
default='PNG',
|
||||
)
|
||||
size: IntVectorProperty(
|
||||
name="Size",
|
||||
size=2,
|
||||
default=(1024, 1024),
|
||||
min=8, max=32768,
|
||||
@ -123,9 +137,6 @@ class ExportUVLayout(bpy.types.Operator):
|
||||
if is_editmode:
|
||||
bpy.ops.object.mode_set(mode='OBJECT', toggle=False)
|
||||
|
||||
filepath = self.filepath
|
||||
filepath = bpy.path.ensure_ext(filepath, "." + self.mode.lower())
|
||||
|
||||
meshes = list(self.iter_meshes_to_export(context))
|
||||
polygon_data = list(self.iter_polygon_data_to_draw(context, meshes))
|
||||
different_colors = set(color for _, color in polygon_data)
|
||||
@ -135,8 +146,35 @@ class ExportUVLayout(bpy.types.Operator):
|
||||
obj_eval = obj.evaluated_get(depsgraph)
|
||||
obj_eval.to_mesh_clear()
|
||||
|
||||
tiles = self.tiles_to_export(polygon_data)
|
||||
export = self.get_exporter()
|
||||
export(filepath, polygon_data, different_colors, self.size[0], self.size[1], self.opacity)
|
||||
dirname, filename = os.path.split(self.filepath)
|
||||
|
||||
# Strip UDIM or UV numbering, and extension
|
||||
import re
|
||||
name_regex = r"^(.*?)"
|
||||
udim_regex = r"(?:\.[0-9]{4})?"
|
||||
uv_regex = r"(?:\.u[0-9]+_v[0-9]+)?"
|
||||
ext_regex = r"(?:\.png|\.eps|\.svg)?$"
|
||||
if self.export_tiles == 'NONE':
|
||||
match = re.match(name_regex + ext_regex, filename)
|
||||
elif self.export_tiles == 'UDIM':
|
||||
match = re.match(name_regex + udim_regex + ext_regex, filename)
|
||||
elif self.export_tiles == 'UV':
|
||||
match = re.match(name_regex + uv_regex + ext_regex, filename)
|
||||
if match:
|
||||
filename = match.groups()[0]
|
||||
|
||||
for tile in sorted(tiles):
|
||||
filepath = os.path.join(dirname, filename)
|
||||
if self.export_tiles == 'UDIM':
|
||||
filepath += f".{1001 + tile[0] + tile[1] * 10:04}"
|
||||
elif self.export_tiles == 'UV':
|
||||
filepath += f".u{tile[0] + 1}_v{tile[1] + 1}"
|
||||
filepath = bpy.path.ensure_ext(filepath, "." + self.mode.lower())
|
||||
|
||||
export(filepath, tile, polygon_data, different_colors,
|
||||
self.size[0], self.size[1], self.opacity)
|
||||
|
||||
if is_editmode:
|
||||
bpy.ops.object.mode_set(mode='EDIT', toggle=False)
|
||||
@ -161,6 +199,30 @@ class ExportUVLayout(bpy.types.Operator):
|
||||
continue
|
||||
yield obj
|
||||
|
||||
def tiles_to_export(self, polygon_data):
|
||||
"""Get a set of tiles containing UVs.
|
||||
This assumes there is no UV edge crossing an otherwise empty tile.
|
||||
"""
|
||||
if self.export_tiles == 'NONE':
|
||||
return {(0, 0)}
|
||||
|
||||
from math import floor
|
||||
tiles = set()
|
||||
for poly in polygon_data:
|
||||
for uv in poly[0]:
|
||||
# Ignore UVs at corners - precisely touching the right or upper edge
|
||||
pioverfour marked this conversation as resolved
Outdated
Jesse Yurkovich
commented
There's a small gotcha here in that UV coords that "touch" the upper edge or the right edge of a tile might be categorized as existing in the tile above or to the right of its "real" tile. I.e. export the default Cube in UDIM layout and you'll get 2 tiles, 1001 and 1011. You can mimic what's done in Cycles here to lie about such UV coords: https://projects.blender.org/blender/blender/blame/branch/main/intern/cycles/scene/attribute.cpp#L461 There's a small gotcha here in that UV coords that "touch" the upper edge or the right edge of a tile might be categorized as existing in the tile above or to the right of its "real" tile. I.e. export the default Cube in UDIM layout and you'll get 2 tiles, 1001 and 1011.
You can mimic what's done in Cycles here to lie about such UV coords: https://projects.blender.org/blender/blender/blame/branch/main/intern/cycles/scene/attribute.cpp#L461
Damien Picard
commented
This works well on the cube! This works well on the cube!
|
||||
# of a tile should not load its right/upper neighbor as well.
|
||||
# From intern/cycles/scene/attribute.cpp
|
||||
u, v = uv[0], uv[1]
|
||||
x, y = floor(u), floor(v)
|
||||
if x > 0 and u < x + 1e-6:
|
||||
x -= 1
|
||||
if y > 0 and v < y + 1e-6:
|
||||
y -= 1
|
||||
if x >= 0 and y >= 0:
|
||||
tiles.add((x, y))
|
||||
return tiles
|
||||
|
||||
@staticmethod
|
||||
def currently_image_image_editor(context):
|
||||
return isinstance(context.space_data, bpy.types.SpaceImageEditor)
|
||||
|
@ -5,19 +5,19 @@
|
||||
import bpy
|
||||
|
||||
|
||||
def export(filepath, face_data, colors, width, height, opacity):
|
||||
def export(filepath, tile, face_data, colors, width, height, opacity):
|
||||
with open(filepath, 'w', encoding='utf-8') as file:
|
||||
for text in get_file_parts(face_data, colors, width, height, opacity):
|
||||
for text in get_file_parts(tile, face_data, colors, width, height, opacity):
|
||||
file.write(text)
|
||||
|
||||
|
||||
def get_file_parts(face_data, colors, width, height, opacity):
|
||||
def get_file_parts(tile, face_data, colors, width, height, opacity):
|
||||
yield from header(width, height)
|
||||
if opacity > 0.0:
|
||||
name_by_color = {}
|
||||
yield from prepare_colors(colors, name_by_color)
|
||||
yield from draw_colored_polygons(face_data, name_by_color, width, height)
|
||||
yield from draw_lines(face_data, width, height)
|
||||
yield from draw_colored_polygons(tile, face_data, name_by_color, width, height)
|
||||
yield from draw_lines(tile, face_data, width, height)
|
||||
yield from footer()
|
||||
|
||||
|
||||
@ -53,24 +53,24 @@ def prepare_colors(colors, out_name_by_color):
|
||||
yield "} def\n"
|
||||
|
||||
|
||||
def draw_colored_polygons(face_data, name_by_color, width, height):
|
||||
def draw_colored_polygons(tile, face_data, name_by_color, width, height):
|
||||
for uvs, color in face_data:
|
||||
yield from draw_polygon_path(uvs, width, height)
|
||||
yield from draw_polygon_path(tile, uvs, width, height)
|
||||
yield "closepath\n"
|
||||
yield "%s\n" % name_by_color[color]
|
||||
|
||||
|
||||
def draw_lines(face_data, width, height):
|
||||
def draw_lines(tile, face_data, width, height):
|
||||
for uvs, _ in face_data:
|
||||
yield from draw_polygon_path(uvs, width, height)
|
||||
yield from draw_polygon_path(tile, uvs, width, height)
|
||||
yield "closepath\n"
|
||||
yield "stroke\n"
|
||||
|
||||
|
||||
def draw_polygon_path(uvs, width, height):
|
||||
def draw_polygon_path(tile, uvs, width, height):
|
||||
yield "newpath\n"
|
||||
for j, uv in enumerate(uvs):
|
||||
uv_scale = (uv[0] * width, uv[1] * height)
|
||||
uv_scale = ((uv[0] - tile[0]) * width, (uv[1] - tile[1]) * height)
|
||||
if j == 0:
|
||||
yield "%.5f %.5f moveto\n" % uv_scale
|
||||
else:
|
||||
|
@ -15,14 +15,14 @@ except ImportError:
|
||||
oiio = None
|
||||
|
||||
|
||||
def export(filepath, face_data, colors, width, height, opacity):
|
||||
def export(filepath, tile, face_data, colors, width, height, opacity):
|
||||
offscreen = gpu.types.GPUOffScreen(width, height)
|
||||
offscreen.bind()
|
||||
|
||||
try:
|
||||
fb = gpu.state.active_framebuffer_get()
|
||||
fb.clear(color=(0.0, 0.0, 0.0, 0.0))
|
||||
draw_image(face_data, opacity)
|
||||
draw_image(tile, face_data, opacity)
|
||||
|
||||
pixel_data = fb.read_color(0, 0, width, height, 4, 0, 'UBYTE')
|
||||
pixel_data.dimensions = width * height * 4
|
||||
@ -32,11 +32,11 @@ def export(filepath, face_data, colors, width, height, opacity):
|
||||
offscreen.free()
|
||||
|
||||
|
||||
def draw_image(face_data, opacity):
|
||||
def draw_image(tile, face_data, opacity):
|
||||
gpu.state.blend_set('ALPHA')
|
||||
|
||||
with gpu.matrix.push_pop():
|
||||
gpu.matrix.load_matrix(get_normalize_uvs_matrix())
|
||||
gpu.matrix.load_matrix(get_normalize_uvs_matrix(tile))
|
||||
gpu.matrix.load_projection_matrix(Matrix.Identity(4))
|
||||
|
||||
draw_background_colors(face_data, opacity)
|
||||
@ -45,11 +45,11 @@ def draw_image(face_data, opacity):
|
||||
gpu.state.blend_set('NONE')
|
||||
|
||||
|
||||
def get_normalize_uvs_matrix():
|
||||
def get_normalize_uvs_matrix(tile):
|
||||
'''matrix maps x and y coordinates from [0, 1] to [-1, 1]'''
|
||||
matrix = Matrix.Identity(4)
|
||||
matrix.col[3][0] = -1
|
||||
matrix.col[3][1] = -1
|
||||
matrix.col[3][0] = -1 - (tile[0]) * 2
|
||||
matrix.col[3][1] = -1 - (tile[1]) * 2
|
||||
matrix[0][0] = 2
|
||||
matrix[1][1] = 2
|
||||
|
||||
|
@ -7,15 +7,15 @@ from os.path import basename
|
||||
from xml.sax.saxutils import escape
|
||||
|
||||
|
||||
def export(filepath, face_data, colors, width, height, opacity):
|
||||
def export(filepath, tile, face_data, colors, width, height, opacity):
|
||||
with open(filepath, 'w', encoding='utf-8') as file:
|
||||
for text in get_file_parts(face_data, colors, width, height, opacity):
|
||||
for text in get_file_parts(tile, face_data, colors, width, height, opacity):
|
||||
file.write(text)
|
||||
|
||||
|
||||
def get_file_parts(face_data, colors, width, height, opacity):
|
||||
def get_file_parts(tile, face_data, colors, width, height, opacity):
|
||||
yield from header(width, height)
|
||||
yield from draw_polygons(face_data, width, height, opacity)
|
||||
yield from draw_polygons(tile, face_data, width, height, opacity)
|
||||
yield from footer()
|
||||
|
||||
|
||||
@ -29,7 +29,7 @@ def header(width, height):
|
||||
yield f'<desc>{escape(desc)}</desc>\n'
|
||||
|
||||
|
||||
def draw_polygons(face_data, width, height, opacity):
|
||||
def draw_polygons(tile, face_data, width, height, opacity):
|
||||
for uvs, color in face_data:
|
||||
fill = f'fill="{get_color_string(color)}"'
|
||||
|
||||
@ -39,7 +39,7 @@ def draw_polygons(face_data, width, height, opacity):
|
||||
yield ' points="'
|
||||
|
||||
for uv in uvs:
|
||||
x, y = uv[0], 1.0 - uv[1]
|
||||
x, y = uv[0] - tile[0], 1.0 - uv[1] + tile[1]
|
||||
yield f'{x*width:.3f},{y*height:.3f} '
|
||||
yield '" />\n'
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user
I wonder if we should be more descriptive here? Maybe call the items "UDIM format" and "UVTILE format" as these are the 2 formats that both Blender and MaterialX supports and their names are somewhat known in the ecosystem [1]. Also because there's another "uvtile format" (lowercase) which is slightly different [2]
[1] https://materialx.org/assets/MaterialX.v1.38.Spec.pdf (Page 18)
[2] https://openimageio.readthedocs.io/en/v2.5.4.0/texturesys.html#udim-texture-atlases
Thanks, I wasn’t aware of that! I agree that “UVTILE” should be used instead of “UV Tiles”, but I’m not sure about adding “Format”, it seems redundant to me for the label. From your link, OIIO seems to call these “numbering schemes”, which is more descriptive than “notation”.
How about: