From bcfb80481146c9b52162fb69723e76399f96847c Mon Sep 17 00:00:00 2001 From: Damien Picard Date: Sat, 7 Oct 2023 03:04:42 +0200 Subject: [PATCH 1/4] UV Export: add option to export UV tiles Tiles can now be exported, with either the UDIM or UV numbering scheme. Exporters for PNG, SVG and EPS were updated. The vector formats simply offset the view, so all UVs can end up being exported multiple times. Only tiles containing UV points will be exported. In theory, this may result in some polygons being ignored, if they cross a whole otherwise empty tile, but this should not happen in practice. Fixes #74325 --- io_mesh_uv_layout/__init__.py | 62 +++++++++++++++++++++++++++--- io_mesh_uv_layout/export_uv_eps.py | 22 +++++------ io_mesh_uv_layout/export_uv_png.py | 14 +++---- io_mesh_uv_layout/export_uv_svg.py | 12 +++--- 4 files changed, 81 insertions(+), 29 deletions(-) diff --git a/io_mesh_uv_layout/__init__.py b/io_mesh_uv_layout/__init__.py index e875a22ee..d5b2d3c21 100644 --- a/io_mesh_uv_layout/__init__.py +++ b/io_mesh_uv_layout/__init__.py @@ -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 notation: 1001 + u-tile + 10*v-tile"), + ('UV', "UV Tiles", + "Export tiles in the UV notation: 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 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,20 @@ 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]: + tiles.add((floor(uv[0]), floor(uv[1]))) + return tiles + @staticmethod def currently_image_image_editor(context): return isinstance(context.space_data, bpy.types.SpaceImageEditor) diff --git a/io_mesh_uv_layout/export_uv_eps.py b/io_mesh_uv_layout/export_uv_eps.py index 679211f9c..dffb30747 100644 --- a/io_mesh_uv_layout/export_uv_eps.py +++ b/io_mesh_uv_layout/export_uv_eps.py @@ -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: diff --git a/io_mesh_uv_layout/export_uv_png.py b/io_mesh_uv_layout/export_uv_png.py index 784337be4..b0c13f6d1 100644 --- a/io_mesh_uv_layout/export_uv_png.py +++ b/io_mesh_uv_layout/export_uv_png.py @@ -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 diff --git a/io_mesh_uv_layout/export_uv_svg.py b/io_mesh_uv_layout/export_uv_svg.py index ac9712b23..a4811ed11 100644 --- a/io_mesh_uv_layout/export_uv_svg.py +++ b/io_mesh_uv_layout/export_uv_svg.py @@ -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'{escape(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' -- 2.30.2 From 688f388caeb5238648f3c2da23c3ddf080292558 Mon Sep 17 00:00:00 2001 From: Damien Picard Date: Mon, 9 Oct 2023 11:42:46 +0200 Subject: [PATCH 2/4] Address review - Tweak enum items - Add a check for UVs at the edge of a tile Also sort tiles before export so they are exported in a predictable order. --- io_mesh_uv_layout/__init__.py | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/io_mesh_uv_layout/__init__.py b/io_mesh_uv_layout/__init__.py index d5b2d3c21..46a87d840 100644 --- a/io_mesh_uv_layout/__init__.py +++ b/io_mesh_uv_layout/__init__.py @@ -60,9 +60,9 @@ class ExportUVLayout(bpy.types.Operator): ('NONE', "None", "Export only UVs in the [0, 1] range"), ('UDIM', "UDIM", - "Export tiles in the UDIM notation: 1001 + u-tile + 10*v-tile"), - ('UV', "UV Tiles", - "Export tiles in the UV notation: u(u-tile + 1)_v(v-tile + 1)"), + "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)"), ), description="Choose whether to export only the [0, 1 range], or all UV tiles", default='NONE', @@ -165,7 +165,7 @@ class ExportUVLayout(bpy.types.Operator): if match: filename = match.groups()[0] - for tile in tiles: + 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}" @@ -210,7 +210,16 @@ class ExportUVLayout(bpy.types.Operator): tiles = set() for poly in polygon_data: for uv in poly[0]: - tiles.add((floor(uv[0]), floor(uv[1]))) + # Ignore UVs at corners - precisely touching the right or upper edge + # 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 + tiles.add((x, y)) return tiles @staticmethod -- 2.30.2 From aca4e34fb9e6ef60e5d47025ccf8f3e55bd45cf3 Mon Sep 17 00:00:00 2001 From: Damien Picard Date: Mon, 9 Oct 2023 14:13:48 +0200 Subject: [PATCH 3/4] Format --- io_mesh_uv_layout/__init__.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/io_mesh_uv_layout/__init__.py b/io_mesh_uv_layout/__init__.py index 46a87d840..1b7f35486 100644 --- a/io_mesh_uv_layout/__init__.py +++ b/io_mesh_uv_layout/__init__.py @@ -213,11 +213,11 @@ class ExportUVLayout(bpy.types.Operator): # Ignore UVs at corners - precisely touching the right or upper edge # 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)): + 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)): + if y > 0 and v < y + 1e-6: y -= 1 tiles.add((x, y)) return tiles -- 2.30.2 From 15902b29186a8ee8f7f47f83099df2ba3b1e64dc Mon Sep 17 00:00:00 2001 From: Damien Picard Date: Mon, 9 Oct 2023 15:25:52 +0200 Subject: [PATCH 4/4] Do not export negative tiles --- io_mesh_uv_layout/__init__.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/io_mesh_uv_layout/__init__.py b/io_mesh_uv_layout/__init__.py index 1b7f35486..72c583a2c 100644 --- a/io_mesh_uv_layout/__init__.py +++ b/io_mesh_uv_layout/__init__.py @@ -219,7 +219,8 @@ class ExportUVLayout(bpy.types.Operator): x -= 1 if y > 0 and v < y + 1e-6: y -= 1 - tiles.add((x, y)) + if x >= 0 and y >= 0: + tiles.add((x, y)) return tiles @staticmethod -- 2.30.2