UV Export: add option to export UV tiles #104940

Merged
Damien Picard merged 4 commits from pioverfour/blender-addons:dp_export_uv_udim into main 2023-10-09 22:32:24 +02:00
4 changed files with 91 additions and 29 deletions

View File

@ -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

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

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:

            ('UDIM', "UDIM",
             "Export tiles in the UDIM numbering scheme: 1001 + u-tile + 10*v-tile"),
            ('UVTILE', "UVTILE",
             "Export tiles in the UVTILE numbering scheme: u(u-tile + 1)_v(v-tile + 1)"),
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: ``` ('UDIM', "UDIM", "Export tiles in the UDIM numbering scheme: 1001 + u-tile + 10*v-tile"), ('UVTILE', "UVTILE", "Export tiles in the UVTILE numbering scheme: u(u-tile + 1)_v(v-tile + 1)"), ```
),
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

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

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)

View File

@ -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:

View File

@ -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

View File

@ -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'