diff --git a/io_export_paper_model.py b/io_export_paper_model.py index a5818339ac9cd726c0dd21d8198ee3b4cc93d965..39d6b508d3ff24b1fae2865ea2fc0eca224a0af2 100644 --- a/io_export_paper_model.py +++ b/io_export_paper_model.py @@ -1,41 +1,37 @@ # -*- coding: utf-8 -*- -# ##### BEGIN GPL LICENSE BLOCK ##### -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 2 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but without any warranty; without even the implied warranty of -# merchantability or fitness for a particular purpose. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see <http://www.gnu.org/licenses/>. -# -# ##### END GPL LICENSE BLOCK ##### +# This script is Free software. Please share and reuse. +# ♡2010-2019 Adam Dominec <adominec@gmail.com> + +## Code structure +# This file consists of several components, in this order: +# * Unfolding and baking +# * Export (SVG or PDF) +# * User interface +# During the unfold process, the mesh is mirrored into a 2D structure: UVFace, UVEdge, UVVertex. bl_info = { "name": "Export Paper Model", "author": "Addam Dominec", - "version": (0, 9), - "blender": (2, 73, 0), + "version": (1, 0), + "blender": (2, 80, 0), "location": "File > Export > Paper Model", "warning": "", "description": "Export printable net of the active mesh", "category": "Import-Export", "wiki_url": "http://wiki.blender.org/index.php/Extensions:2.6/Py/" - "Scripts/Import-Export/Paper_Model", - "tracker_url": "https://developer.blender.org/T38441" + "Scripts/Import-Export/Paper_Model" } +# Task: split into four files (SVG and PDF separately) + # does any portion of baking belong into the export module? + # sketch out the code for GCODE and two-sided export + # TODO: # sanitize the constructors Edge, Face, UVFace so that they don't edit their parent object # The Exporter classes should take parameters as a whole pack, and parse it themselves # remember objects selected before baking (except selected to active) # add 'estimated number of pages' to the export UI -# profile QuickSweepline vs. BruteSweepline with/without blist: for which nets is it faster? +# QuickSweepline is very much broken -- it throws GeometryError for all nets > ~15 faces # rotate islands to minimize area -- and change that only if necessary to fill the page size # Sticker.vertices should be of type Vector @@ -43,30 +39,14 @@ bl_info = { # * append a number to the conflicting names or # * enumerate faces uniquely within all islands of the same name (requires a check that both label and abbr. equals) - -""" - -Additional links: - e-mail: adominec {at} gmail {dot} com - -""" import bpy import bl_operators -import bgl +import bmesh import mathutils as M from re import compile as re_compile -from itertools import chain, repeat -from math import pi, ceil - -try: - import os.path as os_path -except ImportError: - os_path = None - -try: - from blist import blist -except ImportError: - blist = list +from itertools import chain, repeat, product, combinations +from math import pi, ceil, asin, atan2 +import os.path as os_path default_priority_effect = { 'CONVEX': 0.5, @@ -108,18 +88,6 @@ def pairs(sequence): yield this, first -def argmax_pair(array, key): - """Find an (unordered) pair of indices that maximize the given function""" - n = len(array) - mi, mj, m = None, None, None - for i in range(n): - for j in range(i+1, n): - k = key(array[i], array[j]) - if not m or k > m: - mi, mj, m = i, j, k - return mi, mj - - def fitting_matrix(v1, v2): """Get a matrix that rotates v1 to the same direction as v2""" return (1 / v1.length_squared) * M.Matrix(( @@ -146,6 +114,45 @@ def z_up_matrix(n): )) +def cage_fit(points, aspect): + """Find rotation for a minimum bounding box with a given aspect ratio + returns a tuple: rotation angle, box height""" + def guesses(polygon): + """Yield all tentative extrema of the bounding box height wrt. polygon rotation""" + for a, b in pairs(polygon): + if a == b: + continue + direction = (b - a).normalized() + sinx, cosx = -direction.y, direction.x + rot = M.Matrix(((cosx, -sinx), (sinx, cosx))) + rot_polygon = [rot @ p for p in polygon] + left, right = [fn(rot_polygon, key=lambda p: p.to_tuple()) for fn in (min, max)] + bottom, top = [fn(rot_polygon, key=lambda p: p.yx.to_tuple()) for fn in (min, max)] + #print(f"{rot_polygon.index(left)}-{rot_polygon.index(right)}, {rot_polygon.index(bottom)}-{rot_polygon.index(top)}") + horz, vert = right - left, top - bottom + # solve (rot * a).y == (rot * b).y + yield max(aspect * horz.x, vert.y), sinx, cosx + # solve (rot * a).x == (rot * b).x + yield max(horz.x, aspect * vert.y), -cosx, sinx + # solve aspect * (rot * (right - left)).x == (rot * (top - bottom)).y + # using substitution t = tan(rot / 2) + q = aspect * horz.x - vert.y + r = vert.x + aspect * horz.y + t = ((r**2 + q**2)**0.5 - r) / q if q != 0 else 0 + t = -1 / t if abs(t) > 1 else t # pick the positive solution + siny, cosy = 2 * t / (1 + t**2), (1 - t**2) / (1 + t**2) + rot = M.Matrix(((cosy, -siny), (siny, cosy))) + for p in rot_polygon: + p[:] = rot @ p # note: this also modifies left, right, bottom, top + #print(f"solve {aspect * (right - left).x} == {(top - bottom).y} with aspect = {aspect}") + if left.x < right.x and bottom.y < top.y and all(left.x <= p.x <= right.x and bottom.y <= p.y <= top.y for p in rot_polygon): + #print(f"yield {max(aspect * (right - left).x, (top - bottom).y)}") + yield max(aspect * (right - left).x, (top - bottom).y), sinx*cosy + cosx*siny, cosx*cosy - sinx*siny + polygon = [points[i] for i in M.geometry.convex_hull_2d(points)] + height, sinx, cosx = min(guesses(polygon)) + return atan2(sinx, cosx), height + + def create_blank_image(image_name, dimensions, alpha=1): """Create a new image and assign white color to all its pixels""" image_name = image_name[:64] @@ -160,79 +167,43 @@ def create_blank_image(image_name, dimensions, alpha=1): return image -def bake(face_indices, uvmap, image): - import bpy - is_cycles = (bpy.context.scene.render.engine == 'CYCLES') - if is_cycles: - # please excuse the following mess. Cycles baking API does not seem to allow better. - ob = bpy.context.active_object - me = ob.data - # add a disconnected image node that defines the bake target - temp_nodes = dict() - for mat in me.materials: - mat.use_nodes = True - img = mat.node_tree.nodes.new('ShaderNodeTexImage') - img.image = image - temp_nodes[mat] = img - mat.node_tree.nodes.active = img - uvmap.active = True - # move all excess faces to negative numbers (that is the only way to disable them) - loop = me.uv_layers[me.uv_layers.active_index].data - face_indices = set(face_indices) - ignored_uvs = [ - face.loop_start + i - for face in me.polygons if face.index not in face_indices - for i, v in enumerate(face.vertices)] - for vid in ignored_uvs: - loop[vid].uv *= -1 - bake_type = bpy.context.scene.cycles.bake_type - sta = bpy.context.scene.render.bake.use_selected_to_active - try: - bpy.ops.object.bake(type=bake_type, margin=0, use_selected_to_active=sta, cage_extrusion=100, use_clear=False) - except RuntimeError as e: - raise UnfoldError(*e.args) - finally: - for mat, node in temp_nodes.items(): - mat.node_tree.nodes.remove(node) - for vid in ignored_uvs: - loop[vid].uv *= -1 - else: - texfaces = uvmap.data - for fid in face_indices: - texfaces[fid].image = image - bpy.ops.object.bake_image() - for fid in face_indices: - texfaces[fid].image = None - - class UnfoldError(ValueError): - pass + def mesh_select(self): + if len(self.args) > 1: + elems, bm = self.args[1:3] + bpy.context.tool_settings.mesh_select_mode = [bool(elems[key]) for key in ("verts", "edges", "faces")] + for elem in chain(bm.verts, bm.edges, bm.faces): + elem.select = False + for elem in chain(*elems.values()): + elem.select_set(True) + bmesh.update_edit_mesh(bpy.context.object.data, False, False) class Unfolder: def __init__(self, ob): - self.ob = ob - self.mesh = Mesh(ob.data, ob.matrix_world) + self.do_create_uvmap = False + bm = bmesh.from_edit_mesh(ob.data) + self.mesh = Mesh(bm, ob.matrix_world) + self.mesh.copy_freestyle_marks(ob.data) self.mesh.check_correct() - self.tex = None + + def __del__(self): + if not self.do_create_uvmap: + self.mesh.delete_uvmap() - def prepare(self, cage_size=None, create_uvmap=False, mark_seams=False, priority_effect=default_priority_effect, scale=1): + def prepare(self, cage_size=None, priority_effect=default_priority_effect, scale=1, limit_by_page=False): """Create the islands of the net""" - self.mesh.generate_cuts(cage_size / scale if cage_size else None, priority_effect) - is_landscape = cage_size and cage_size.x > cage_size.y - self.mesh.finalize_islands(is_landscape) + self.mesh.generate_cuts(cage_size / scale if limit_by_page and cage_size else None, priority_effect) + self.mesh.finalize_islands(cage_size or M.Vector((1, 1))) self.mesh.enumerate_islands() - if create_uvmap: - self.tex = self.mesh.save_uv() - if mark_seams: - self.mesh.mark_cuts() + self.mesh.save_uv() def copy_island_names(self, island_list): """Copy island label and abbreviation from the best matching island in the list""" orig_islands = [{face.id for face in item.faces} for item in island_list] matching = list() for i, island in enumerate(self.mesh.islands): - islfaces = {uvface.face.index for uvface in island.faces} + islfaces = {face.index for face in island.faces} matching.extend((len(islfaces.intersection(item)), i, j) for j, item in enumerate(orig_islands)) matching.sort(reverse=True) available_new = [True for island in self.mesh.islands] @@ -245,7 +216,7 @@ class Unfolder: def save(self, properties): """Export the document""" - # Note about scale: input is directly in blender length + # Note about scale: input is direcly in blender length # Mesh.scale_islands multiplies everything by a user-defined ratio # exporters (SVG or PDF) multiply everything by 1000 (output in millimeters) Exporter = SVG if properties.file_format == 'SVG' else PDF @@ -267,54 +238,49 @@ class Unfolder: self.mesh.generate_numbers_alone(properties.sticker_width) text_height = properties.sticker_width if (properties.do_create_numbers and len(self.mesh.islands) > 1) else 0 - aspect_ratio = printable_size.x / printable_size.y # title height must be somewhat larger that text size, glyphs go below the baseline - self.mesh.finalize_islands(is_landscape=(printable_size.x > printable_size.y), title_height=text_height * 1.2) - self.mesh.fit_islands(cage_size=printable_size) + self.mesh.finalize_islands(printable_size, title_height=text_height * 1.2) + self.mesh.fit_islands(printable_size) if properties.output_type != 'NONE': # bake an image and save it as a PNG to disk or into memory image_packing = properties.image_packing if properties.file_format == 'SVG' else 'ISLAND_EMBED' use_separate_images = image_packing in ('ISLAND_LINK', 'ISLAND_EMBED') - tex = self.mesh.save_uv(cage_size=printable_size, separate_image=use_separate_images, tex=self.tex) - if not tex: - raise UnfoldError("The mesh has no UV Map slots left. Either delete a UV Map or export the net without textures.") + self.mesh.save_uv(cage_size=printable_size, separate_image=use_separate_images) sce = bpy.context.scene rd = sce.render bk = rd.bake - if rd.engine == 'CYCLES': - recall = sce.cycles.bake_type, bk.use_selected_to_active, bk.margin, bk.cage_extrusion, bk.use_cage, bk.use_clear, bk.use_pass_direct, bk.use_pass_indirect - # recall use_pass... - lookup = {'TEXTURE': 'DIFFUSE', 'AMBIENT_OCCLUSION': 'AO', 'RENDER': 'COMBINED', 'SELECTED_TO_ACTIVE': 'COMBINED'} - sce.cycles.bake_type = lookup[properties.output_type] - bk.use_pass_direct = bk.use_pass_indirect = (properties.output_type != 'TEXTURE') - bk.use_selected_to_active = (properties.output_type == 'SELECTED_TO_ACTIVE') - bk.margin, bk.cage_extrusion, bk.use_cage, bk.use_clear = 0, 10, False, False + # TODO: do we really need all this recollection? + recall = rd.engine, sce.cycles.bake_type, sce.cycles.samples, bk.use_selected_to_active, bk.margin, bk.cage_extrusion, bk.use_cage, bk.use_clear + rd.engine = 'CYCLES' + recall_pass = {p: getattr(bk, f"use_pass_{p}") for p in ('ambient_occlusion', 'color', 'diffuse', 'direct', 'emit', 'glossy', 'indirect', 'subsurface', 'transmission')} + for p in recall_pass: + setattr(bk, f"use_pass_{p}", (properties.output_type != 'TEXTURE')) + lookup = {'TEXTURE': 'DIFFUSE', 'AMBIENT_OCCLUSION': 'AO', 'RENDER': 'COMBINED', 'SELECTED_TO_ACTIVE': 'COMBINED'} + sce.cycles.bake_type = lookup[properties.output_type] + bk.use_selected_to_active = (properties.output_type == 'SELECTED_TO_ACTIVE') + bk.margin, bk.cage_extrusion, bk.use_cage, bk.use_clear = 1, 10, False, False + if properties.output_type == 'TEXTURE': + bk.use_pass_direct, bk.use_pass_indirect, bk.use_pass_color = False, False, True + sce.cycles.samples = 1 else: - recall = rd.engine, rd.bake_type, rd.use_bake_to_vertex_color, rd.use_bake_selected_to_active, rd.bake_distance, rd.bake_bias, rd.bake_margin, rd.use_bake_clear - rd.engine = 'BLENDER_RENDER' - lookup = {'TEXTURE': 'TEXTURE', 'AMBIENT_OCCLUSION': 'AO', 'RENDER': 'FULL', 'SELECTED_TO_ACTIVE': 'FULL'} - rd.bake_type = lookup[properties.output_type] - rd.use_bake_selected_to_active = (properties.output_type == 'SELECTED_TO_ACTIVE') - rd.bake_margin, rd.bake_distance, rd.bake_bias, rd.use_bake_to_vertex_color, rd.use_bake_clear = 0, 0, 0.001, False, False + sce.cycles.samples = properties.bake_samples + if sce.cycles.bake_type == 'COMBINED': + bk.use_pass_direct, bk.use_pass_indirect = True, True + bk.use_pass_diffuse, bk.use_pass_glossy, bk.use_pass_transmission, bk.use_pass_subsurface, bk.use_pass_ambient_occlusion, bk.use_pass_emit = True, False, False, True, True, True if image_packing == 'PAGE_LINK': - self.mesh.save_image(tex, printable_size * ppm, filepath) + self.mesh.save_image(printable_size * ppm, filepath) elif image_packing == 'ISLAND_LINK': image_dir = filepath[:filepath.rfind(".")] - self.mesh.save_separate_images(tex, ppm, image_dir) + self.mesh.save_separate_images(ppm, image_dir) elif image_packing == 'ISLAND_EMBED': - self.mesh.save_separate_images(tex, ppm, filepath, embed=Exporter.encode_image) + self.mesh.save_separate_images(ppm, filepath, embed=Exporter.encode_image) - # revoke settings - if rd.engine == 'CYCLES': - sce.cycles.bake_type, bk.use_selected_to_active, bk.margin, bk.cage_extrusion, bk.use_cage, bk.use_clear, bk.use_pass_direct, bk.use_pass_indirect = recall - else: - rd.engine, rd.bake_type, rd.use_bake_to_vertex_color, rd.use_bake_selected_to_active, rd.bake_distance, rd.bake_bias, rd.bake_margin, rd.use_bake_clear = recall - if not properties.do_create_uvmap: - tex.active = True - bpy.ops.mesh.uv_texture_remove() + rd.engine, sce.cycles.bake_type, sce.cycles.samples, bk.use_selected_to_active, bk.margin, bk.cage_extrusion, bk.use_cage, bk.use_clear = recall + for p, v in recall_pass.items(): + setattr(bk, f"use_pass_{p}", v) exporter = Exporter(page_size, properties.style, properties.output_margin, (properties.output_type == 'NONE'), properties.angle_epsilon) exporter.do_create_stickers = properties.do_create_stickers @@ -325,59 +291,68 @@ class Unfolder: class Mesh: """Wrapper for Bpy Mesh""" - def __init__(self, mesh, matrix): - self.vertices = dict() - self.edges = dict() - self.edges_by_verts_indices = dict() - self.faces = dict() + def __init__(self, bmesh, matrix): + self.data = bmesh + self.matrix = matrix.to_3x3() + self.looptex = bmesh.loops.layers.uv.new("Unfolded") + self.edges = {bmedge: Edge(bmedge) for bmedge in bmesh.edges} self.islands = list() - self.data = mesh self.pages = list() - for bpy_vertex in mesh.vertices: - self.vertices[bpy_vertex.index] = Vertex(bpy_vertex, matrix) - for bpy_edge in mesh.edges: - edge = Edge(bpy_edge, self, matrix) - self.edges[bpy_edge.index] = edge - self.edges_by_verts_indices[(edge.va.index, edge.vb.index)] = edge - self.edges_by_verts_indices[(edge.vb.index, edge.va.index)] = edge - for bpy_face in mesh.polygons: - face = Face(bpy_face, self) - self.faces[bpy_face.index] = face for edge in self.edges.values(): edge.choose_main_faces() if edge.main_faces: edge.calculate_angle() + + def delete_uvmap(self): + self.data.loops.layers.uv.remove(self.looptex) if self.looptex else None + + def copy_freestyle_marks(self, mesh): + for bmedge, edge in self.edges.items(): + edge.freestyle = mesh.edges[bmedge.index].use_freestyle_mark + + def mark_cuts(self): + for bmedge, edge in self.edges.items(): + if edge.is_main_cut and not bmedge.is_boundary: + bmedge.seam = True def check_correct(self, epsilon=1e-6): """Check for invalid geometry""" - null_edges = {i for i, e in self.edges.items() if e.vector.length < epsilon and e.faces} - null_faces = {i for i, f in self.faces.items() if f.normal.length_squared < epsilon} - twisted_faces = {i for i, f in self.faces.items() if f.is_twisted()} + def is_twisted(face): + if len(face.verts) > 3: + center = sum((vertex.co for vertex in face.verts), M.Vector((0, 0, 0))) / len(face.verts) + plane_d = center.dot(face.normal) + diameter = max((center - vertex.co).length for vertex in face.verts) + for vertex in face.verts: + # check coplanarity + if abs(vertex.co.dot(face.normal) - plane_d) > diameter * 0.01: + return True + return False + + null_edges = {e for e in self.edges.keys() if e.calc_length() < epsilon and e.link_faces} + null_faces = {f for f in self.data.faces if f.calc_area() < epsilon} + twisted_faces = {f for f in self.data.faces if is_twisted(f)} if not (null_edges or null_faces or twisted_faces): return - bpy.context.tool_settings.mesh_select_mode = False, bool(null_edges), bool(null_faces or twisted_faces) - for vertex in self.data.vertices: - vertex.select = False - for edge in self.data.edges: - edge.select = (edge.index in null_edges) - for face in self.data.polygons: - face.select = (face.index in null_faces or face.index in twisted_faces) - cure = ("Remove Doubles and Triangulate" if (null_edges or null_faces) and twisted_faces - else "Triangulate" if twisted_faces - else "Remove Doubles") + disease = [("Remove Doubles", null_edges or null_faces), ("Triangulate", twisted_faces)] + cure = " and ".join(s for s, k in disease if k) raise UnfoldError( "The model contains:\n" + (" {} zero-length edge(s)\n".format(len(null_edges)) if null_edges else "") + (" {} zero-area face(s)\n".format(len(null_faces)) if null_faces else "") + (" {} twisted polygon(s)\n".format(len(twisted_faces)) if twisted_faces else "") + - "The offenders are selected and you can use {} to fix them. Export failed.".format(cure)) + "The offenders are selected and you can use {} to fix them. Export failed.".format(cure), + {"verts": set(), "edges": null_edges, "faces": null_faces | twisted_faces}, self.data) def generate_cuts(self, page_size, priority_effect): """Cut the mesh so that it can be unfolded to a flat net.""" - # warning: this constructor modifies its parameter (face) - islands = {Island(face) for face in self.faces.values()} + normal_matrix = self.matrix.inverted().transposed() + islands = {Island(self, face, self.matrix, normal_matrix) for face in self.data.faces} + uvfaces = {face: uvface for island in islands for face, uvface in island.faces.items()} + uvedges = {loop: uvedge for island in islands for loop, uvedge in island.edges.items()} + for loop, uvedge in uvedges.items(): + self.edges[loop.edge].uvedges.append(uvedge) # check for edges that are cut permanently - edges = [edge for edge in self.edges.values() if not edge.force_cut and len(edge.faces) > 1] + edges = [edge for edge in self.edges.values() if not edge.force_cut and edge.main_faces] if edges: average_length = sum(edge.vector.length for edge in edges) / len(edges) @@ -385,28 +360,25 @@ class Mesh: edge.generate_priority(priority_effect, average_length) edges.sort(reverse=False, key=lambda edge: edge.priority) for edge in edges: - if edge.vector.length_squared == 0: + if not edge.vector: continue - face_a, face_b = edge.main_faces - island_a, island_b = face_a.uvface.island, face_b.uvface.island - if island_a is not island_b: - if len(island_b.faces) > len(island_a.faces): - island_a, island_b = island_b, island_a - if island_a.join(island_b, edge, size_limit=page_size): - islands.remove(island_b) + edge_a, edge_b = (uvedges[l] for l in edge.main_faces) + old_island = join(edge_a, edge_b, size_limit=page_size) + if old_island: + islands.remove(old_island) self.islands = sorted(islands, reverse=True, key=lambda island: len(island.faces)) for edge in self.edges.values(): # some edges did not know until now whether their angle is convex or concave - if edge.main_faces and (edge.main_faces[0].uvface.flipped or edge.main_faces[1].uvface.flipped): + if edge.main_faces and (uvfaces[edge.main_faces[0].face].flipped or uvfaces[edge.main_faces[1].face].flipped): edge.calculate_angle() # ensure that the order of faces corresponds to the order of uvedges if edge.main_faces: reordered = [None, None] for uvedge in edge.uvedges: try: - index = edge.main_faces.index(uvedge.uvface.face) + index = edge.main_faces.index(uvedge.loop) reordered[index] = uvedge except ValueError: reordered.append(uvedge) @@ -414,9 +386,9 @@ class Mesh: for island in self.islands: # if the normals are ambiguous, flip them so that there are more convex edges than concave ones - if any(uvface.flipped for uvface in island.faces): - island_edges = {uvedge.edge for uvedge in island.edges if not uvedge.edge.is_cut(uvedge.uvface.face)} - balance = sum((+1 if edge.angle > 0 else -1) for edge in island_edges) + if any(uvface.flipped for uvface in island.faces.values()): + island_edges = {self.edges[uvedge.edge] for uvedge in island.edges} + balance = sum((+1 if edge.angle > 0 else -1) for edge in island_edges if not edge.is_cut(uvedge.uvface.face)) if balance < 0: island.is_inside_out = True @@ -463,54 +435,49 @@ class Mesh: right.neighbor_left = left return True - def mark_cuts(self): - """Mark cut edges in the original mesh so that the user can see""" - for bpy_edge in self.data.edges: - edge = self.edges[bpy_edge.index] - bpy_edge.use_seam = len(edge.uvedges) > 1 and edge.is_main_cut - def generate_stickers(self, default_width, do_create_numbers=True): """Add sticker faces where they are needed.""" def uvedge_priority(uvedge): - """Returns whether it is a good idea to stick something on this edge's face""" + """Retuns whether it is a good idea to stick something on this edge's face""" # TODO: it should take into account overlaps with faces and with other stickers - return uvedge.uvface.face.area / sum((vb.co - va.co).length for (va, vb) in pairs(uvedge.uvface.vertices)) - - def add_sticker(uvedge, index, target_island): - uvedge.sticker = Sticker(uvedge, default_width, index, target_island) - uvedge.island.add_marker(uvedge.sticker) + face = uvedge.uvface.face + return face.calc_area() / face.calc_perimeter() + + def add_sticker(uvedge, index, target_uvedge): + uvedge.sticker = Sticker(uvedge, default_width, index, target_uvedge) + uvedge.uvface.island.add_marker(uvedge.sticker) + + def is_index_obvious(uvedge, target): + if uvedge in (target.neighbor_left, target.neighbor_right): + return True + if uvedge.neighbor_left.loop.edge is target.neighbor_right.loop.edge and uvedge.neighbor_right.loop.edge is target.neighbor_left.loop.edge: + return True + return False for edge in self.edges.values(): + index = None if edge.is_main_cut and len(edge.uvedges) >= 2 and edge.vector.length_squared > 0: - uvedge_a, uvedge_b = edge.uvedges[:2] - if uvedge_priority(uvedge_a) < uvedge_priority(uvedge_b): - uvedge_a, uvedge_b = uvedge_b, uvedge_a - target_island = uvedge_a.island - left_edge, right_edge = uvedge_a.neighbor_left.edge, uvedge_a.neighbor_right.edge + target, source = edge.uvedges[:2] + if uvedge_priority(target) < uvedge_priority(source): + target, source = source, target + target_island = target.uvface.island if do_create_numbers: - for uvedge in [uvedge_b] + edge.uvedges[2:]: - if ((uvedge.neighbor_left.edge is not right_edge or uvedge.neighbor_right.edge is not left_edge) and - uvedge not in (uvedge_a.neighbor_left, uvedge_a.neighbor_right)): + for uvedge in [source] + edge.uvedges[2:]: + if not is_index_obvious(uvedge, target): # it will not be clear to see that these uvedges should be sticked together # So, create an arrow and put the index on all stickers target_island.sticker_numbering += 1 index = str(target_island.sticker_numbering) if is_upsidedown_wrong(index): index += "." - target_island.add_marker(Arrow(uvedge_a, default_width, index)) + target_island.add_marker(Arrow(target, default_width, index)) break - else: - # if all uvedges to be sticked are easy to see, create no numbers - index = None - else: - index = None - add_sticker(uvedge_b, index, target_island) + add_sticker(source, index, target) elif len(edge.uvedges) > 2: - index = None - target_island = edge.uvedges[0].island + target = edge.uvedges[0] if len(edge.uvedges) > 2: - for uvedge in edge.uvedges[2:]: - add_sticker(uvedge, index, target_island) + for source in edge.uvedges[2:]: + add_sticker(source, index, target) def generate_numbers_alone(self, size): global_numbering = 0 @@ -521,7 +488,7 @@ class Mesh: if is_upsidedown_wrong(index): index += "." for uvedge in edge.uvedges: - uvedge.island.add_marker(NumberAlone(uvedge, index, size)) + uvedge.uvface.island.add_marker(NumberAlone(uvedge, index, size)) def enumerate_islands(self): for num, island in enumerate(self.islands, 1): @@ -530,37 +497,37 @@ class Mesh: def scale_islands(self, scale): for island in self.islands: - for point in chain((vertex.co for vertex in island.vertices), island.fake_vertices): + vertices = set(island.vertices.values()) + for point in chain((vertex.co for vertex in vertices), island.fake_vertices): point *= scale - def finalize_islands(self, is_landscape=False, title_height=0): + def finalize_islands(self, cage_size, title_height=0): for island in self.islands: if title_height: island.title = "[{}] {}".format(island.abbreviation, island.label) - points = list(vertex.co for vertex in island.vertices) + island.fake_vertices - angle = M.geometry.box_fit_2d(points) + points = [vertex.co for vertex in set(island.vertices.values())] + island.fake_vertices + angle, _ = cage_fit(points, (cage_size.y - title_height) / cage_size.x) rot = M.Matrix.Rotation(angle, 2) - # ensure that the island matches page orientation (portrait/landscape) - dimensions = M.Vector(max(r * v for v in points) - min(r * v for v in points) for r in rot) - if dimensions.x > dimensions.y != is_landscape: - rot = M.Matrix.Rotation(angle + pi / 2, 2) for point in points: # note: we need an in-place operation, and Vector.rotate() seems to work for 3d vectors only - point[:] = rot * point + point[:] = rot @ point for marker in island.markers: - marker.rot = rot * marker.rot + marker.rot = rot @ marker.rot bottom_left = M.Vector((min(v.x for v in points), min(v.y for v in points) - title_height)) + #DEBUG + top_right = M.Vector((max(v.x for v in points), max(v.y for v in points) - title_height)) + #print(f"fitted aspect: {(top_right.y - bottom_left.y) / (top_right.x - bottom_left.x)}") for point in points: point -= bottom_left island.bounding_box = M.Vector((max(v.x for v in points), max(v.y for v in points))) - def largest_island_ratio(self, page_size): - return max(i / p for island in self.islands for (i, p) in zip(island.bounding_box, page_size)) + def largest_island_ratio(self, cage_size): + return max(i / p for island in self.islands for (i, p) in zip(island.bounding_box, cage_size)) def fit_islands(self, cage_size): """Move islands so that they fit onto pages, based on their bounding boxes""" - def try_emplace(island, page_islands, cage_size, stops_x, stops_y, occupied_cache): + def try_emplace(island, page_islands, stops_x, stops_y, occupied_cache): """Tries to put island to each pair from stops_x, stops_y and checks if it overlaps with any islands present on the page. Returns True and positions the given island on success.""" @@ -607,7 +574,7 @@ class Mesh: "Export failed, sorry.") # sort islands by their diagonal... just a guess remaining_islands = sorted(self.islands, reverse=True, key=lambda island: island.bounding_box.length_squared) - page_num = 1 + page_num = 1 # TODO delete me while remaining_islands: # create a new page and try to fit as many islands onto it as possible @@ -616,7 +583,7 @@ class Mesh: occupied_cache = set() stops_x, stops_y = [0], [0] for island in remaining_islands: - try_emplace(island, page.islands, cage_size, stops_x, stops_y, occupied_cache) + try_emplace(island, page.islands, stops_x, stops_y, occupied_cache) # if overwhelmed with stops, drop a quarter of them if len(stops_x)**2 > 4 * len(self.islands) + 100: stops_x = drop_portion(stops_x, cage_size.x, 4) @@ -624,45 +591,29 @@ class Mesh: remaining_islands = [island for island in remaining_islands if island not in page.islands] self.pages.append(page) - def save_uv(self, cage_size=M.Vector((1, 1)), separate_image=False, tex=None): - # TODO: mode switching should be handled by higher-level code - bpy.ops.object.mode_set() - # note: assuming that the active object's data is self.mesh - if not tex: - tex = self.data.uv_textures.new() - if not tex: - return None - tex.name = "Unfolded" - tex.active = True - # TODO: this is somewhat dirty, but I do not see a nicer way in the API - loop = self.data.uv_layers[self.data.uv_layers.active_index] + def save_uv(self, cage_size=M.Vector((1, 1)), separate_image=False): if separate_image: for island in self.islands: - island.save_uv_separate(loop) + island.save_uv_separate(self.looptex) else: for island in self.islands: - island.save_uv(loop, cage_size) - return tex + island.save_uv(self.looptex, cage_size) - def save_image(self, tex, page_size_pixels: M.Vector, filename): + def save_image(self, page_size_pixels: M.Vector, filename): for page in self.pages: - image = create_blank_image("{} {} Unfolded".format(self.data.name[:14], page.name), page_size_pixels, alpha=1) + image = create_blank_image("Page {}".format(page.name), page_size_pixels, alpha=1) image.filepath_raw = page.image_path = "{}_{}.png".format(filename, page.name) - faces = [uvface.face.index for island in page.islands for uvface in island.faces] - bake(faces, tex, image) + faces = [face for island in page.islands for face in island.faces] + self.bake(faces, image) image.save() image.user_clear() bpy.data.images.remove(image) - def save_separate_images(self, tex, scale, filepath, embed=None): - # omitting this may cause a "Circular reference in texture stack" error - recall = {texface: texface.image for texface in tex.data} - for texface in tex.data: - texface.image = None - for i, island in enumerate(self.islands, 1): - image_name = "{} isl{}".format(self.data.name[:15], i) + def save_separate_images(self, scale, filepath, embed=None): + for i, island in enumerate(self.islands): + image_name = "Island {}".format(i) image = create_blank_image(image_name, island.bounding_box * scale, alpha=0) - bake([uvface.face.index for uvface in island.faces], tex, image) + self.bake(island.faces.keys(), image) if embed: island.embedded_image = embed(image) else: @@ -675,88 +626,86 @@ class Mesh: island.image_path = image_path image.user_clear() bpy.data.images.remove(image) - for texface, img in recall.items(): - texface.image = img - - -class Vertex: - """BPy Vertex wrapper""" - __slots__ = ('index', 'co', 'edges', 'uvs') - - def __init__(self, bpy_vertex, matrix): - self.index = bpy_vertex.index - self.co = matrix * bpy_vertex.co - self.edges = list() - self.uvs = list() - - def __hash__(self): - return hash(self.index) - - def __eq__(self, other): - return self.index == other.index + + def bake(self, faces, image): + if not self.looptex: + raise UnfoldError("The mesh has no UV Map slots left. Either delete a UV Map or export the net without textures.") + ob = bpy.context.active_object + me = ob.data + # in Cycles, the image for baking is defined by the active Image Node + temp_nodes = dict() + for mat in me.materials: + mat.use_nodes = True + img = mat.node_tree.nodes.new('ShaderNodeTexImage') + img.image = image + temp_nodes[mat] = img + mat.node_tree.nodes.active = img + # move all excess faces to negative numbers (that is the only way to disable them) + ignored_uvs = [loop[self.looptex].uv for f in self.data.faces if f not in faces for loop in f.loops] + for uv in ignored_uvs: + uv *= -1 + bake_type = bpy.context.scene.cycles.bake_type + sta = bpy.context.scene.render.bake.use_selected_to_active + try: + ob.update_from_editmode() + me.uv_layers.active = me.uv_layers[self.looptex.name] + bpy.ops.object.bake(type=bake_type, margin=1, use_selected_to_active=sta, cage_extrusion=100, use_clear=False) + except RuntimeError as e: + raise UnfoldError(*e.args) + finally: + for mat, node in temp_nodes.items(): + mat.node_tree.nodes.remove(node) + for uv in ignored_uvs: + uv *= -1 class Edge: """Wrapper for BPy Edge""" - __slots__ = ('va', 'vb', 'faces', 'main_faces', 'uvedges', + __slots__ = ('data', 'va', 'vb', 'main_faces', 'uvedges', 'vector', 'angle', 'is_main_cut', 'force_cut', 'priority', 'freestyle') - def __init__(self, edge, mesh, matrix=1): - self.va = mesh.vertices[edge.vertices[0]] - self.vb = mesh.vertices[edge.vertices[1]] + def __init__(self, edge): + self.data = edge + self.va, self.vb = edge.verts self.vector = self.vb.co - self.va.co - self.faces = list() # if self.main_faces is set, then self.uvedges[:2] must correspond to self.main_faces, in their order # this constraint is assured at the time of finishing mesh.generate_cuts self.uvedges = list() - self.force_cut = edge.use_seam # such edges will always be cut + self.force_cut = edge.seam # such edges will always be cut self.main_faces = None # two faces that may be connected in the island # is_main_cut defines whether the two main faces are connected # all the others will be assumed to be cut self.is_main_cut = True self.priority = None self.angle = None - self.freestyle = getattr(edge, "use_freestyle_mark", False) # freestyle edges will be highlighted - self.va.edges.append(self) # FIXME: editing foreign attribute - self.vb.edges.append(self) # FIXME: editing foreign attribute + self.freestyle = False def choose_main_faces(self): """Choose two main faces that might get connected in an island""" - if len(self.faces) == 2: - self.main_faces = self.faces - elif len(self.faces) > 2: - # find (with brute force) the pair of indices whose faces have the most similar normals - i, j = argmax_pair(self.faces, key=lambda a, b: abs(a.normal.dot(b.normal))) - self.main_faces = [self.faces[i], self.faces[j]] + from itertools import combinations + loops = self.data.link_loops + def score(pair): + return abs(pair[0].face.normal.dot(pair[1].face.normal)) + if len(loops) == 2: + self.main_faces = list(loops) + elif len(loops) > 2: + # find (with brute force) the pair of indices whose loops have the most similar normals + self.main_faces = max(combinations(loops, 2), key=score) + if self.main_faces and self.main_faces[1].vert == self.va: + self.main_faces = self.main_faces[::-1] def calculate_angle(self): """Calculate the angle between the main faces""" - face_a, face_b = self.main_faces - if face_a.normal.length_squared == 0 or face_b.normal.length_squared == 0: + loop_a, loop_b = self.main_faces + normal_a, normal_b = (l.face.normal for l in self.main_faces) + if not normal_a or not normal_b: self.angle = -3 # just a very sharp angle - return - # correction if normals are flipped - a_is_clockwise = ((face_a.vertices.index(self.va) - face_a.vertices.index(self.vb)) % len(face_a.vertices) == 1) - b_is_clockwise = ((face_b.vertices.index(self.va) - face_b.vertices.index(self.vb)) % len(face_b.vertices) == 1) - is_equal_flip = True - if face_a.uvface and face_b.uvface: - a_is_clockwise ^= face_a.uvface.flipped - b_is_clockwise ^= face_b.uvface.flipped - is_equal_flip = (face_a.uvface.flipped == face_b.uvface.flipped) - # TODO: maybe this need not be true in _really_ ugly cases: assert(a_is_clockwise != b_is_clockwise) - if a_is_clockwise != b_is_clockwise: - if (a_is_clockwise == (face_b.normal.cross(face_a.normal).dot(self.vector) > 0)) == is_equal_flip: - # the angle is convex - self.angle = face_a.normal.angle(face_b.normal) - else: - # the angle is concave - self.angle = -face_a.normal.angle(face_b.normal) else: - # normals are flipped, so we know nothing - # so let us assume the angle be convex - self.angle = face_a.normal.angle(-face_b.normal) + self.angle = asin(normal_a.cross(normal_b).dot(self.vector.normalized())) + if loop_a.link_loop_next.vert != loop_b.vert or loop_b.link_loop_next.vert != loop_a.vert: + self.angle = abs(self.angle) def generate_priority(self, priority_effect, average_length): """Calculate the priority value for cutting""" @@ -771,7 +720,7 @@ class Edge: """Return False if this edge will the given face to another one in the resulting net (useful for edges with more than two faces connected)""" # Return whether there is a cut between the two main faces - if self.main_faces and face in self.main_faces: + if self.main_faces and face in {loop.face for loop in self.main_faces}: return self.is_main_cut # All other faces (third and more) are automatically treated as cut else: @@ -783,53 +732,21 @@ class Edge: return self.uvedges[1] if this is self.uvedges[0] else self.uvedges[0] -class Face: - """Wrapper for BPy Face""" - __slots__ = ('index', 'edges', 'vertices', 'uvface', - 'loop_start', 'area', 'normal') - - def __init__(self, bpy_face, mesh): - self.index = bpy_face.index - self.edges = list() - self.vertices = [mesh.vertices[i] for i in bpy_face.vertices] - self.loop_start = bpy_face.loop_start - self.area = bpy_face.area - self.uvface = None - self.normal = M.geometry.normal(v.co for v in self.vertices) - for verts_indices in bpy_face.edge_keys: - edge = mesh.edges_by_verts_indices[verts_indices] - self.edges.append(edge) - edge.faces.append(self) # FIXME: editing foreign attribute - - def is_twisted(self): - if len(self.vertices) > 3: - center = sum((vertex.co for vertex in self.vertices), M.Vector((0, 0, 0))) / len(self.vertices) - plane_d = center.dot(self.normal) - diameter = max((center - vertex.co).length for vertex in self.vertices) - for vertex in self.vertices: - # check coplanarity - if abs(vertex.co.dot(self.normal) - plane_d) > diameter * 0.01: - return True - return False - - def __hash__(self): - return hash(self.index) - - class Island: """Part of the net to be exported""" - __slots__ = ('faces', 'edges', 'vertices', 'fake_vertices', 'uvverts_by_id', 'boundary', 'markers', + __slots__ = ('mesh', 'faces', 'edges', 'vertices', 'fake_vertices', 'boundary', 'markers', 'pos', 'bounding_box', 'image_path', 'embedded_image', 'number', 'label', 'abbreviation', 'title', 'has_safe_geometry', 'is_inside_out', 'sticker_numbering') - def __init__(self, face): + def __init__(self, mesh, face, matrix, normal_matrix): """Create an Island from a single Face""" - self.faces = list() - self.edges = set() - self.vertices = set() + self.mesh = mesh + self.faces = dict() # face -> uvface + self.edges = dict() # loop -> uvedge + self.vertices = dict() # loop -> uvvertex self.fake_vertices = list() self.markers = list() self.label = None @@ -841,315 +758,14 @@ class Island: self.is_inside_out = False # swaps concave <-> convex edges self.has_safe_geometry = True self.sticker_numbering = 0 - uvface = UVFace(face, self) + + uvface = UVFace(face, self, matrix, normal_matrix) self.vertices.update(uvface.vertices) self.edges.update(uvface.edges) - self.faces.append(uvface) - # speedup for Island.join - self.uvverts_by_id = {uvvertex.vertex.index: [uvvertex] for uvvertex in self.vertices} + self.faces[face] = uvface # UVEdges on the boundary - self.boundary = list(self.edges) - - def join(self, other, edge: Edge, size_limit=None, epsilon=1e-6) -> bool: - """ - Try to join other island on given edge - Returns False if they would overlap - """ - - class Intersection(Exception): - pass - - class GeometryError(Exception): - pass - - def is_below(self, other, correct_geometry=True): - if self is other: - return False - if self.top < other.bottom: - return True - if other.top < self.bottom: - return False - if self.max.tup <= other.min.tup: - return True - if other.max.tup <= self.min.tup: - return False - self_vector = self.max.co - self.min.co - min_to_min = other.min.co - self.min.co - cross_b1 = self_vector.cross(min_to_min) - cross_b2 = self_vector.cross(other.max.co - self.min.co) - if cross_b2 < cross_b1: - cross_b1, cross_b2 = cross_b2, cross_b1 - if cross_b2 > 0 and (cross_b1 > 0 or (cross_b1 == 0 and not self.is_uvface_upwards())): - return True - if cross_b1 < 0 and (cross_b2 < 0 or (cross_b2 == 0 and self.is_uvface_upwards())): - return False - other_vector = other.max.co - other.min.co - cross_a1 = other_vector.cross(-min_to_min) - cross_a2 = other_vector.cross(self.max.co - other.min.co) - if cross_a2 < cross_a1: - cross_a1, cross_a2 = cross_a2, cross_a1 - if cross_a2 > 0 and (cross_a1 > 0 or (cross_a1 == 0 and not other.is_uvface_upwards())): - return False - if cross_a1 < 0 and (cross_a2 < 0 or (cross_a2 == 0 and other.is_uvface_upwards())): - return True - if cross_a1 == cross_b1 == cross_a2 == cross_b2 == 0: - if correct_geometry: - raise GeometryError - elif self.is_uvface_upwards() == other.is_uvface_upwards(): - raise Intersection - return False - if self.min.tup == other.min.tup or self.max.tup == other.max.tup: - return cross_a2 > cross_b2 - raise Intersection - - class QuickSweepline: - """Efficient sweepline based on binary search, checking neighbors only""" - def __init__(self): - self.children = blist() - - def add(self, item, cmp=is_below): - low, high = 0, len(self.children) - while low < high: - mid = (low + high) // 2 - if cmp(self.children[mid], item): - low = mid + 1 - else: - high = mid - self.children.insert(low, item) - - def remove(self, item, cmp=is_below): - index = self.children.index(item) - self.children.pop(index) - if index > 0 and index < len(self.children): - # check for intersection - if cmp(self.children[index], self.children[index-1]): - raise GeometryError - - class BruteSweepline: - """Safe sweepline which checks all its members pairwise""" - def __init__(self): - self.children = set() - self.last_min = None, [] - self.last_max = None, [] - - def add(self, item, cmp=is_below): - for child in self.children: - if child.min is not item.min and child.max is not item.max: - cmp(item, child, False) - self.children.add(item) - - def remove(self, item): - self.children.remove(item) - - def sweep(sweepline, segments): - """Sweep across the segments and raise an exception if necessary""" - # careful, 'segments' may be a use-once iterator - events_add = sorted(segments, reverse=True, key=lambda uvedge: uvedge.min.tup) - events_remove = sorted(events_add, reverse=True, key=lambda uvedge: uvedge.max.tup) - while events_remove: - while events_add and events_add[-1].min.tup <= events_remove[-1].max.tup: - sweepline.add(events_add.pop()) - sweepline.remove(events_remove.pop()) - - def root_find(value, tree): - """Find the root of a given value in a forest-like dictionary - also updates the dictionary using path compression""" - parent, relink = tree.get(value), list() - while parent is not None: - relink.append(value) - value, parent = parent, tree.get(parent) - tree.update(dict.fromkeys(relink, value)) - return value - - def slope_from(position): - def slope(uvedge): - vec = (uvedge.vb.co - uvedge.va.co) if uvedge.va.tup == position else (uvedge.va.co - uvedge.vb.co) - return (vec.y / vec.length + 1) if ((vec.x, vec.y) > (0, 0)) else (-1 - vec.y / vec.length) - return slope - - # find edge in other and in self - for uvedge in edge.uvedges: - if uvedge.uvface.face in uvedge.edge.main_faces: - if uvedge.uvface.island is self and uvedge in self.boundary: - uvedge_a = uvedge - elif uvedge.uvface.island is other and uvedge in other.boundary: - uvedge_b = uvedge - else: - return False - - # check if vertices and normals are aligned correctly - verts_flipped = uvedge_b.va.vertex is uvedge_a.va.vertex - flipped = verts_flipped ^ uvedge_a.uvface.flipped ^ uvedge_b.uvface.flipped - # determine rotation - # NOTE: if the edges differ in length, the matrix will involve uniform scaling. - # Such situation may occur in the case of twisted n-gons - first_b, second_b = (uvedge_b.va, uvedge_b.vb) if not verts_flipped else (uvedge_b.vb, uvedge_b.va) - if not flipped: - rot = fitting_matrix(first_b.co - second_b.co, uvedge_a.vb.co - uvedge_a.va.co) - else: - flip = M.Matrix(((-1, 0), (0, 1))) - rot = fitting_matrix(flip * (first_b.co - second_b.co), uvedge_a.vb.co - uvedge_a.va.co) * flip - trans = uvedge_a.vb.co - rot * first_b.co - # extract and transform island_b's boundary - phantoms = {uvvertex: UVVertex(rot*uvvertex.co + trans, uvvertex.vertex) for uvvertex in other.vertices} - - # check the size of the resulting island - if size_limit: - # first check: bounding box - left = min(min(seg.min.co.x for seg in self.boundary), min(vertex.co.x for vertex in phantoms)) - right = max(max(seg.max.co.x for seg in self.boundary), max(vertex.co.x for vertex in phantoms)) - bottom = min(min(seg.bottom for seg in self.boundary), min(vertex.co.y for vertex in phantoms)) - top = max(max(seg.top for seg in self.boundary), max(vertex.co.y for vertex in phantoms)) - bbox_width = right - left - bbox_height = top - bottom - if min(bbox_width, bbox_height)**2 > size_limit.x**2 + size_limit.y**2: - return False - if (bbox_width > size_limit.x or bbox_height > size_limit.y) and (bbox_height > size_limit.x or bbox_width > size_limit.y): - # further checks (TODO!) - # for the time being, just throw this piece away - return False - - distance_limit = edge.vector.length_squared * epsilon - # try and merge UVVertices closer than sqrt(distance_limit) - merged_uvedges = set() - merged_uvedge_pairs = list() - - # merge all uvvertices that are close enough using a union-find structure - # uvvertices will be merged only in cases other->self and self->self - # all resulting groups are merged together to a uvvertex of self - is_merged_mine = False - shared_vertices = self.uvverts_by_id.keys() & other.uvverts_by_id.keys() - for vertex_id in shared_vertices: - uvs = self.uvverts_by_id[vertex_id] + other.uvverts_by_id[vertex_id] - len_mine = len(self.uvverts_by_id[vertex_id]) - merged = dict() - for i, a in enumerate(uvs[:len_mine]): - i = root_find(i, merged) - for j, b in enumerate(uvs[i+1:], i+1): - b = b if j < len_mine else phantoms[b] - j = root_find(j, merged) - if i == j: - continue - i, j = (j, i) if j < i else (i, j) - if (a.co - b.co).length_squared < distance_limit: - merged[j] = i - for source, target in merged.items(): - target = root_find(target, merged) - phantoms[uvs[source]] = uvs[target] - is_merged_mine |= (source < len_mine) # remember that a vertex of this island has been merged - - for uvedge in (chain(self.boundary, other.boundary) if is_merged_mine else other.boundary): - for partner in uvedge.edge.uvedges: - if partner is not uvedge: - paired_a, paired_b = phantoms.get(partner.vb, partner.vb), phantoms.get(partner.va, partner.va) - if (partner.uvface.flipped ^ flipped) != uvedge.uvface.flipped: - paired_a, paired_b = paired_b, paired_a - if phantoms.get(uvedge.va, uvedge.va) is paired_a and phantoms.get(uvedge.vb, uvedge.vb) is paired_b: - # if these two edges will get merged, add them both to the set - merged_uvedges.update((uvedge, partner)) - merged_uvedge_pairs.append((uvedge, partner)) - break - - if uvedge_b not in merged_uvedges: - raise UnfoldError("Export failed. Please report this error, including the model if you can.") - - boundary_other = [ - PhantomUVEdge(phantoms[uvedge.va], phantoms[uvedge.vb], flipped ^ uvedge.uvface.flipped) - for uvedge in other.boundary if uvedge not in merged_uvedges] - # TODO: if is_merged_mine, it might make sense to create a similar list from self.boundary as well - - incidence = {vertex.tup for vertex in phantoms.values()}.intersection(vertex.tup for vertex in self.vertices) - incidence = {position: list() for position in incidence} # from now on, 'incidence' is a dict - for uvedge in chain(boundary_other, self.boundary): - if uvedge.va.co == uvedge.vb.co: - continue - for vertex in (uvedge.va, uvedge.vb): - site = incidence.get(vertex.tup) - if site is not None: - site.append(uvedge) - for position, segments in incidence.items(): - if len(segments) <= 2: - continue - segments.sort(key=slope_from(position)) - for right, left in pairs(segments): - is_left_ccw = left.is_uvface_upwards() ^ (left.max.tup == position) - is_right_ccw = right.is_uvface_upwards() ^ (right.max.tup == position) - if is_right_ccw and not is_left_ccw and type(right) is not type(left) and right not in merged_uvedges and left not in merged_uvedges: - return False - if (not is_right_ccw and right not in merged_uvedges) ^ (is_left_ccw and left not in merged_uvedges): - return False - - # check for self-intersections - try: - try: - sweepline = QuickSweepline() if self.has_safe_geometry and other.has_safe_geometry else BruteSweepline() - sweep(sweepline, (uvedge for uvedge in chain(boundary_other, self.boundary))) - self.has_safe_geometry &= other.has_safe_geometry - except GeometryError: - sweep(BruteSweepline(), (uvedge for uvedge in chain(boundary_other, self.boundary))) - self.has_safe_geometry = False - except Intersection: - return False - - # mark all edges that connect the islands as not cut - for uvedge in merged_uvedges: - uvedge.edge.is_main_cut = False - - # include all trasformed vertices as mine - self.vertices.update(phantoms.values()) - - # update the uvverts_by_id dictionary - for source, target in phantoms.items(): - present = self.uvverts_by_id.get(target.vertex.index) - if not present: - self.uvverts_by_id[target.vertex.index] = [target] - else: - # emulation of set behavior... sorry, it is faster - if source in present: - present.remove(source) - if target not in present: - present.append(target) - - # re-link uvedges and uvfaces to their transformed locations - for uvedge in other.edges: - uvedge.island = self - uvedge.va = phantoms[uvedge.va] - uvedge.vb = phantoms[uvedge.vb] - uvedge.update() - if is_merged_mine: - for uvedge in self.edges: - uvedge.va = phantoms.get(uvedge.va, uvedge.va) - uvedge.vb = phantoms.get(uvedge.vb, uvedge.vb) - self.edges.update(other.edges) - - for uvface in other.faces: - uvface.island = self - uvface.vertices = [phantoms[uvvertex] for uvvertex in uvface.vertices] - uvface.uvvertex_by_id = { - index: phantoms[uvvertex] - for index, uvvertex in uvface.uvvertex_by_id.items()} - uvface.flipped ^= flipped - if is_merged_mine: - # there may be own uvvertices that need to be replaced by phantoms - for uvface in self.faces: - if any(uvvertex in phantoms for uvvertex in uvface.vertices): - uvface.vertices = [phantoms.get(uvvertex, uvvertex) for uvvertex in uvface.vertices] - uvface.uvvertex_by_id = { - index: phantoms.get(uvvertex, uvvertex) - for index, uvvertex in uvface.uvvertex_by_id.items()} - self.faces.extend(other.faces) - - self.boundary = [ - uvedge for uvedge in chain(self.boundary, other.boundary) - if uvedge not in merged_uvedges] - - for uvedge, partner in merged_uvedge_pairs: - # make sure that main faces are the ones actually merged (this changes nothing in most cases) - uvedge.edge.main_faces[:] = uvedge.uvface.face, partner.uvface.face - - # everything seems to be OK - return True - + self.boundary = list(self.edges.values()) + def add_marker(self, marker): self.fake_vertices.extend(marker.bounds) self.markers.append(marker) @@ -1165,25 +781,291 @@ class Island: def save_uv(self, tex, cage_size): """Save UV Coordinates of all UVFaces to a given UV texture - tex: UV Texture layer to use (BPy MeshUVLoopLayer struct) + tex: UV Texture layer to use (BMLayerItem) page_size: size of the page in pixels (vector)""" - texface = tex.data - for uvface in self.faces: - for i, uvvertex in enumerate(uvface.vertices): - uv = uvvertex.co + self.pos - texface[uvface.face.loop_start + i].uv[0] = uv.x / cage_size.x - texface[uvface.face.loop_start + i].uv[1] = uv.y / cage_size.y + scale_x, scale_y = 1 / cage_size.x, 1 / cage_size.y + for loop, uvvertex in self.vertices.items(): + uv = uvvertex.co + self.pos + loop[tex].uv = uv.x * scale_x, uv.y * scale_y def save_uv_separate(self, tex): """Save UV Coordinates of all UVFaces to a given UV texture, spanning from 0 to 1 - tex: UV Texture layer to use (BPy MeshUVLoopLayer struct) + tex: UV Texture layer to use (BMLayerItem) page_size: size of the page in pixels (vector)""" - texface = tex.data scale_x, scale_y = 1 / self.bounding_box.x, 1 / self.bounding_box.y - for uvface in self.faces: - for i, uvvertex in enumerate(uvface.vertices): - texface[uvface.face.loop_start + i].uv[0] = uvvertex.co.x * scale_x - texface[uvface.face.loop_start + i].uv[1] = uvvertex.co.y * scale_y + for loop, uvvertex in self.vertices.items(): + loop[tex].uv = uvvertex.co.x * scale_x, uvvertex.co.y * scale_y + +def join(uvedge_a, uvedge_b, size_limit=None, epsilon=1e-6): + """ + Try to join other island on given edge + Returns False if they would overlap + """ + + class Intersection(Exception): + pass + + class GeometryError(Exception): + pass + + def is_below(self, other, correct_geometry=True): + if self is other: + return False + if self.top < other.bottom: + return True + if other.top < self.bottom: + return False + if self.max.tup <= other.min.tup: + return True + if other.max.tup <= self.min.tup: + return False + self_vector = self.max.co - self.min.co + min_to_min = other.min.co - self.min.co + cross_b1 = self_vector.cross(min_to_min) + cross_b2 = self_vector.cross(other.max.co - self.min.co) + if cross_b2 < cross_b1: + cross_b1, cross_b2 = cross_b2, cross_b1 + if cross_b2 > 0 and (cross_b1 > 0 or (cross_b1 == 0 and not self.is_uvface_upwards())): + return True + if cross_b1 < 0 and (cross_b2 < 0 or (cross_b2 == 0 and self.is_uvface_upwards())): + return False + other_vector = other.max.co - other.min.co + cross_a1 = other_vector.cross(-min_to_min) + cross_a2 = other_vector.cross(self.max.co - other.min.co) + if cross_a2 < cross_a1: + cross_a1, cross_a2 = cross_a2, cross_a1 + if cross_a2 > 0 and (cross_a1 > 0 or (cross_a1 == 0 and not other.is_uvface_upwards())): + return False + if cross_a1 < 0 and (cross_a2 < 0 or (cross_a2 == 0 and other.is_uvface_upwards())): + return True + if cross_a1 == cross_b1 == cross_a2 == cross_b2 == 0: + if correct_geometry: + raise GeometryError + elif self.is_uvface_upwards() == other.is_uvface_upwards(): + raise Intersection + return False + if self.min.tup == other.min.tup or self.max.tup == other.max.tup: + return cross_a2 > cross_b2 + raise Intersection + + class QuickSweepline: + """Efficient sweepline based on binary search, checking neighbors only""" + def __init__(self): + self.children = list() + + def add(self, item, cmp=is_below): + low, high = 0, len(self.children) + while low < high: + mid = (low + high) // 2 + if cmp(self.children[mid], item): + low = mid + 1 + else: + high = mid + self.children.insert(low, item) + + def remove(self, item, cmp=is_below): + index = self.children.index(item) + self.children.pop(index) + if index > 0 and index < len(self.children): + # check for intersection + if cmp(self.children[index], self.children[index-1]): + raise GeometryError + + class BruteSweepline: + """Safe sweepline which checks all its members pairwise""" + def __init__(self): + self.children = set() + + def add(self, item, cmp=is_below): + for child in self.children: + if child.min is not item.min and child.max is not item.max: + cmp(item, child, False) + self.children.add(item) + + def remove(self, item): + self.children.remove(item) + + def sweep(sweepline, segments): + """Sweep across the segments and raise an exception if necessary""" + # careful, 'segments' may be a use-once iterator + events_add = sorted(segments, reverse=True, key=lambda uvedge: uvedge.min.tup) + events_remove = sorted(events_add, reverse=True, key=lambda uvedge: uvedge.max.tup) + while events_remove: + while events_add and events_add[-1].min.tup <= events_remove[-1].max.tup: + sweepline.add(events_add.pop()) + sweepline.remove(events_remove.pop()) + + def root_find(value, tree): + """Find the root of a given value in a forest-like dictionary + also updates the dictionary using path compression""" + parent, relink = tree.get(value), list() + while parent is not None: + relink.append(value) + value, parent = parent, tree.get(parent) + tree.update(dict.fromkeys(relink, value)) + return value + + def slope_from(position): + def slope(uvedge): + vec = (uvedge.vb.co - uvedge.va.co) if uvedge.va.tup == position else (uvedge.va.co - uvedge.vb.co) + return (vec.y / vec.length + 1) if ((vec.x, vec.y) > (0, 0)) else (-1 - vec.y / vec.length) + return slope + + island_a, island_b = (e.uvface.island for e in (uvedge_a, uvedge_b)) + if island_a is island_b: + return False + elif len(island_b.faces) > len(island_a.faces): + uvedge_a, uvedge_b = uvedge_b, uvedge_a + island_a, island_b = island_b, island_a + # check if vertices and normals are aligned correctly + verts_flipped = uvedge_b.loop.vert is uvedge_a.loop.vert + flipped = verts_flipped ^ uvedge_a.uvface.flipped ^ uvedge_b.uvface.flipped + # determine rotation + # NOTE: if the edges differ in length, the matrix will involve uniform scaling. + # Such situation may occur in the case of twisted n-gons + first_b, second_b = (uvedge_b.va, uvedge_b.vb) if not verts_flipped else (uvedge_b.vb, uvedge_b.va) + if not flipped: + rot = fitting_matrix(first_b.co - second_b.co, uvedge_a.vb.co - uvedge_a.va.co) + else: + flip = M.Matrix(((-1, 0), (0, 1))) + rot = fitting_matrix(flip @ (first_b.co - second_b.co), uvedge_a.vb.co - uvedge_a.va.co) @ flip + trans = uvedge_a.vb.co - rot @ first_b.co + # preview of island_b's vertices after the join operation + phantoms = {uvvertex: UVVertex(rot @ uvvertex.co + trans) for uvvertex in island_b.vertices.values()} + + # check the size of the resulting island + if size_limit: + points = [vert.co for vert in chain(island_a.vertices.values(), phantoms.values())] + left, right, bottom, top = (fn(co[i] for co in points) for i in (0, 1) for fn in (min, max)) + bbox_width = right - left + bbox_height = top - bottom + if min(bbox_width, bbox_height)**2 > size_limit.x**2 + size_limit.y**2: + return False + if (bbox_width > size_limit.x or bbox_height > size_limit.y) and (bbox_height > size_limit.x or bbox_width > size_limit.y): + _, height = cage_fit(points, size_limit.y / size_limit.x) + if height > size_limit.y: + return False + + distance_limit = uvedge_a.loop.edge.calc_length() * epsilon + # try and merge UVVertices closer than sqrt(distance_limit) + merged_uvedges = set() + merged_uvedge_pairs = list() + + # merge all uvvertices that are close enough using a union-find structure + # uvvertices will be merged only in cases island_b->island_a and island_a->island_a + # all resulting groups are merged together to a uvvertex of island_a + is_merged_mine = False + shared_vertices = {loop.vert for loop in chain(island_a.vertices, island_b.vertices)} + for vertex in shared_vertices: + uvs_a = {island_a.vertices.get(loop) for loop in vertex.link_loops} - {None} + uvs_b = {island_b.vertices.get(loop) for loop in vertex.link_loops} - {None} + for a, b in product(uvs_a, uvs_b): + if (a.co - phantoms[b].co).length_squared < distance_limit: + phantoms[b] = root_find(a, phantoms) + for a1, a2 in combinations(uvs_a, 2): + if (a1.co - a2.co).length_squared < distance_limit: + a1, a2 = (root_find(a, phantoms) for a in (a1, a2)) + if a1 is not a2: + phantoms[a2] = a1 + is_merged_mine = True + for source, target in phantoms.items(): + target = root_find(target, phantoms) + phantoms[source] = target + + for uvedge in (chain(island_a.boundary, island_b.boundary) if is_merged_mine else island_b.boundary): + for loop in uvedge.loop.link_loops: + partner = island_b.edges.get(loop) or island_a.edges.get(loop) + if partner is not None and partner is not uvedge: + paired_a, paired_b = phantoms.get(partner.vb, partner.vb), phantoms.get(partner.va, partner.va) + if (partner.uvface.flipped ^ flipped) != uvedge.uvface.flipped: + paired_a, paired_b = paired_b, paired_a + if phantoms.get(uvedge.va, uvedge.va) is paired_a and phantoms.get(uvedge.vb, uvedge.vb) is paired_b: + # if these two edges will get merged, add them both to the set + merged_uvedges.update((uvedge, partner)) + merged_uvedge_pairs.append((uvedge, partner)) + break + + if uvedge_b not in merged_uvedges: + raise UnfoldError("Export failed. Please report this error, including the model if you can.") + + boundary_other = [ + PhantomUVEdge(phantoms[uvedge.va], phantoms[uvedge.vb], flipped ^ uvedge.uvface.flipped) + for uvedge in island_b.boundary if uvedge not in merged_uvedges] + # TODO: if is_merged_mine, it might make sense to create a similar list from island_a.boundary as well + + incidence = {vertex.tup for vertex in phantoms.values()}.intersection(vertex.tup for vertex in island_a.vertices.values()) + incidence = {position: list() for position in incidence} # from now on, 'incidence' is a dict + for uvedge in chain(boundary_other, island_a.boundary): + if uvedge.va.co == uvedge.vb.co: + continue + for vertex in (uvedge.va, uvedge.vb): + site = incidence.get(vertex.tup) + if site is not None: + site.append(uvedge) + for position, segments in incidence.items(): + if len(segments) <= 2: + continue + segments.sort(key=slope_from(position)) + for right, left in pairs(segments): + is_left_ccw = left.is_uvface_upwards() ^ (left.max.tup == position) + is_right_ccw = right.is_uvface_upwards() ^ (right.max.tup == position) + if is_right_ccw and not is_left_ccw and type(right) is not type(left) and right not in merged_uvedges and left not in merged_uvedges: + return False + if (not is_right_ccw and right not in merged_uvedges) ^ (is_left_ccw and left not in merged_uvedges): + return False + + # check for self-intersections + try: + try: + sweepline = QuickSweepline() if island_a.has_safe_geometry and island_b.has_safe_geometry else BruteSweepline() + sweep(sweepline, (uvedge for uvedge in chain(boundary_other, island_a.boundary))) + island_a.has_safe_geometry &= island_b.has_safe_geometry + except GeometryError: + sweep(BruteSweepline(), (uvedge for uvedge in chain(boundary_other, island_a.boundary))) + island_a.has_safe_geometry = False + except Intersection: + return False + + # mark all edges that connect the islands as not cut + for uvedge in merged_uvedges: + island_a.mesh.edges[uvedge.loop.edge].is_main_cut = False + + # include all trasformed vertices as mine + island_a.vertices.update({loop: phantoms[uvvertex] for loop, uvvertex in island_b.vertices.items()}) + + # re-link uvedges and uvfaces to their transformed locations + for uvedge in island_b.edges.values(): + uvedge.va = phantoms[uvedge.va] + uvedge.vb = phantoms[uvedge.vb] + uvedge.update() + if is_merged_mine: + for uvedge in island_a.edges: + uvedge.va = phantoms.get(uvedge.va, uvedge.va) + uvedge.vb = phantoms.get(uvedge.vb, uvedge.vb) + island_a.edges.update(island_b.edges) + + for uvface in island_b.faces.values(): + uvface.island = island_a + uvface.vertices = {loop: phantoms[uvvertex] for loop, uvvertex in uvface.vertices.items()} + uvface.flipped ^= flipped + if is_merged_mine: + # there may be own uvvertices that need to be replaced by phantoms + for uvface in island_a.faces.values(): + if any(uvvertex in phantoms for uvvertex in uvface.vertices): + uvface.vertices = {loop: phantoms.get(uvvertex, uvvertex) for loop, uvvertex in uvface.vertices.items()} + island_a.faces.update(island_b.faces) + + island_a.boundary = [ + uvedge for uvedge in chain(island_a.boundary, island_b.boundary) + if uvedge not in merged_uvedges] + + for uvedge, partner in merged_uvedge_pairs: + # make sure that main faces are the ones actually merged (this changes nothing in most cases) + edge = island_a.mesh.edges[uvedge.loop.edge] + edge.main_faces = uvedge.loop, partner.loop + + # everything seems to be OK + return island_b class Page: @@ -1192,42 +1074,34 @@ class Page: def __init__(self, num=1): self.islands = list() - self.name = "page{}".format(num) + self.name = "page{}".format(num) # TODO delete me self.image_path = None class UVVertex: """Vertex in 2D""" - __slots__ = ('co', 'vertex', 'tup') + __slots__ = ('co', 'tup') - def __init__(self, vector, vertex=None): + def __init__(self, vector): self.co = vector.xy - self.vertex = vertex self.tup = tuple(self.co) - def __repr__(self): - if self.vertex: - return "UV {} [{:.3f}, {:.3f}]".format(self.vertex.index, self.co.x, self.co.y) - else: - return "UV * [{:.3f}, {:.3f}]".format(self.co.x, self.co.y) - class UVEdge: """Edge in 2D""" # Every UVEdge is attached to only one UVFace # UVEdges are doubled as needed because they both have to point clockwise around their faces - __slots__ = ('va', 'vb', 'island', 'uvface', 'edge', + __slots__ = ('va', 'vb', 'uvface', 'loop', 'min', 'max', 'bottom', 'top', 'neighbor_left', 'neighbor_right', 'sticker') - def __init__(self, vertex1: UVVertex, vertex2: UVVertex, island: Island, uvface, edge): + def __init__(self, vertex1: UVVertex, vertex2: UVVertex, uvface, loop): self.va = vertex1 self.vb = vertex2 self.update() - self.island = island self.uvface = uvface self.sticker = None - self.edge = edge + self.loop = loop def update(self): """Update data if UVVertices have moved""" @@ -1261,35 +1135,16 @@ class PhantomUVEdge: class UVFace: """Face in 2D""" - __slots__ = ('vertices', 'edges', 'face', 'island', 'flipped', 'uvvertex_by_id') - - def __init__(self, face: Face, island: Island): - """Creace an UVFace from a Face and a fixed edge. - face: Face to take coordinates from - island: Island to register itself in - fixed_edge: Edge to connect to (that already has UV coordinates)""" - self.vertices = list() + __slots__ = ('vertices', 'edges', 'face', 'island', 'flipped') + + def __init__(self, face: bmesh.types.BMFace, island: Island, matrix=1, normal_matrix=1): self.face = face - face.uvface = self self.island = island self.flipped = False # a flipped UVFace has edges clockwise - rot = z_up_matrix(face.normal) - self.uvvertex_by_id = dict() # link vertex id -> UVVertex - for vertex in face.vertices: - uvvertex = UVVertex(rot * vertex.co, vertex) - self.vertices.append(uvvertex) - self.uvvertex_by_id[vertex.index] = uvvertex - self.edges = list() - edge_by_verts = dict() - for edge in face.edges: - edge_by_verts[(edge.va.index, edge.vb.index)] = edge - edge_by_verts[(edge.vb.index, edge.va.index)] = edge - for va, vb in pairs(self.vertices): - edge = edge_by_verts[(va.vertex.index, vb.vertex.index)] - uvedge = UVEdge(va, vb, island, self, edge) - self.edges.append(uvedge) - edge.uvedges.append(uvedge) # FIXME: editing foreign attribute + flatten = z_up_matrix(normal_matrix @ face.normal) @ matrix + self.vertices = {loop: UVVertex(flatten @ loop.vert.co) for loop in face.loops} + self.edges = {loop: UVEdge(self.vertices[loop], self.vertices[loop.link_loop_next], self, loop) for loop in face.loops} class Arrow: @@ -1305,20 +1160,18 @@ class Arrow: cos, sin = tangent self.rot = M.Matrix(((cos, -sin), (sin, cos))) normal = M.Vector((sin, -cos)) - self.bounds = [self.center, self.center + (1.2*normal + tangent)*size, self.center + (1.2*normal - tangent)*size] + self.bounds = [self.center, self.center + (1.2 * normal + tangent) * size, self.center + (1.2 * normal - tangent) * size] class Sticker: """Mark in the document: sticker tab""" __slots__ = ('bounds', 'center', 'rot', 'text', 'width', 'vertices') - def __init__(self, uvedge, default_width=0.005, index=None, target_island=None): + def __init__(self, uvedge, default_width, index, other: UVEdge): """Sticker is directly attached to the given UVEdge""" first_vertex, second_vertex = (uvedge.va, uvedge.vb) if not uvedge.uvface.flipped else (uvedge.vb, uvedge.va) edge = first_vertex.co - second_vertex.co sticker_width = min(default_width, edge.length / 2) - other = uvedge.edge.other_uvedge(uvedge) # This is the other uvedge - the sticking target - other_first, other_second = (other.va, other.vb) if not other.uvface.flipped else (other.vb, other.va) other_edge = other_second.co - other_first.co @@ -1330,21 +1183,23 @@ class Sticker: # fix overlaps with the most often neighbour - its sticking target if first_vertex == other_second: - cos_a = max(cos_a, (edge*other_edge) / (edge.length**2)) # angles between pi/3 and 0 + cos_a = max(cos_a, edge.dot(other_edge) / (edge.length_squared)) # angles between pi/3 and 0 elif second_vertex == other_first: - cos_b = max(cos_b, (edge*other_edge) / (edge.length**2)) # angles between pi/3 and 0 + cos_b = max(cos_b, edge.dot(other_edge) / (edge.length_squared)) # angles between pi/3 and 0 # Fix tabs for sticking targets with small angles - # Index of other uvedge in its face (not in its island) - other_idx = other.uvface.edges.index(other) - # Left and right neighbors in the face - other_face_neighbor_left = other.uvface.edges[(other_idx+1) % len(other.uvface.edges)] - other_face_neighbor_right = other.uvface.edges[(other_idx-1) % len(other.uvface.edges)] - other_edge_neighbor_a = other_face_neighbor_left.vb.co - other.vb.co - other_edge_neighbor_b = other_face_neighbor_right.va.co - other.va.co - # Adjacent angles in the face - cos_a = max(cos_a, (-other_edge*other_edge_neighbor_a) / (other_edge.length*other_edge_neighbor_a.length)) - cos_b = max(cos_b, (other_edge*other_edge_neighbor_b) / (other_edge.length*other_edge_neighbor_b.length)) + try: + other_face_neighbor_left = other.neighbor_left + other_face_neighbor_right = other.neighbor_right + other_edge_neighbor_a = other_face_neighbor_left.vb.co - other.vb.co + other_edge_neighbor_b = other_face_neighbor_right.va.co - other.va.co + # Adjacent angles in the face + cos_a = max(cos_a, -other_edge.dot(other_edge_neighbor_a) / (other_edge.length*other_edge_neighbor_a.length)) + cos_b = max(cos_b, other_edge.dot(other_edge_neighbor_b) / (other_edge.length*other_edge_neighbor_b.length)) + except AttributeError: # neighbor data may be missing for edges with 3+ faces + pass + except ZeroDivisionError: + pass # Calculate the lengths of the glue tab edges using the possibly smaller angles sin_a = abs(1 - cos_a**2)**0.5 @@ -1355,8 +1210,8 @@ class Sticker: len_a = min(len_a, (edge.length * sin_b) / (sin_a * cos_b + sin_b * cos_a)) len_b = 0 if sin_b == 0 else min(sticker_width / sin_b, (edge.length - len_a * cos_a) / cos_b) - v3 = UVVertex(second_vertex.co + M.Matrix(((cos_b, -sin_b), (sin_b, cos_b))) * edge * len_b / edge.length) - v4 = UVVertex(first_vertex.co + M.Matrix(((-cos_a, -sin_a), (sin_a, -cos_a))) * edge * len_a / edge.length) + v3 = UVVertex(second_vertex.co + M.Matrix(((cos_b, -sin_b), (sin_b, cos_b))) @ edge * len_b / edge.length) + v4 = UVVertex(first_vertex.co + M.Matrix(((-cos_a, -sin_a), (sin_a, -cos_a))) @ edge * len_a / edge.length) if v3.co != v4.co: self.vertices = [second_vertex, v3, v4, first_vertex] else: @@ -1365,11 +1220,11 @@ class Sticker: sin, cos = edge.y / edge.length, edge.x / edge.length self.rot = M.Matrix(((cos, -sin), (sin, cos))) self.width = sticker_width * 0.9 - if index and target_island is not uvedge.island: - self.text = "{}:{}".format(target_island.abbreviation, index) + if index and uvedge.uvface.island is not other.uvface.island: + self.text = "{}:{}".format(other.uvface.island.abbreviation, index) else: self.text = index - self.center = (uvedge.va.co + uvedge.vb.co) / 2 + self.rot*M.Vector((0, self.width*0.2)) + self.center = (uvedge.va.co + uvedge.vb.co) / 2 + self.rot @ M.Vector((0, self.width * 0.2)) self.bounds = [v3.co, v4.co, self.center] if v3.co != v4.co else [v3.co, self.center] @@ -1385,7 +1240,7 @@ class NumberAlone: sin, cos = edge.y / edge.length, edge.x / edge.length self.rot = M.Matrix(((cos, -sin), (sin, cos))) self.text = index - self.center = (uvedge.va.co + uvedge.vb.co) / 2 - self.rot*M.Vector((0, self.size*1.2)) + self.center = (uvedge.va.co + uvedge.vb.co) / 2 - self.rot @ M.Vector((0, self.size * 1.2)) self.bounds = [self.center] @@ -1515,7 +1370,7 @@ class SVG: size=marker.width * 1000)) elif isinstance(marker, Arrow): size = marker.size * 1000 - position = marker.center + marker.rot*marker.size*M.Vector((0, -0.9)) + position = marker.center + marker.size * marker.rot @ M.Vector((0, -0.9)) data_markers.append(self.arrow_marker_tag.format( index=marker.text, arrow_pos=self.format_vertex(marker.center, island.pos), @@ -1548,8 +1403,9 @@ class SVG: break data_outer.append("M {} Z".format(line_through(data_loop))) - for uvedge in island.edges: - edge = uvedge.edge + visited_edges = set() + for loop, uvedge in island.edges.items(): + edge = mesh.edges[loop.edge] if edge.is_cut(uvedge.uvface.face) and not uvedge.sticker: continue data_uvedge = "M {}".format( @@ -1557,7 +1413,9 @@ class SVG: if edge.freestyle: data_freestyle.append(data_uvedge) # each uvedge is in two opposite-oriented variants; we want to add each only once - if uvedge.sticker or uvedge.uvface.flipped != (uvedge.va.vertex.index > uvedge.vb.vertex.index): + vertex_pair = frozenset((uvedge.va, uvedge.vb)) + if vertex_pair not in visited_edges: + visited_edges.add(vertex_pair) if edge.angle > self.angle_epsilon: data_convex.append(data_uvedge) elif edge.angle < -self.angle_epsilon: @@ -1647,7 +1505,7 @@ class SVG: fill-opacity: {sticker_alpha:.2}; }} path.arrow {{ - fill: #000; + fill: {text_color}; }} text {{ font-style: normal; @@ -1799,7 +1657,7 @@ class PDF: size=1000*marker.width)) elif isinstance(marker, Arrow): size = 1000 * marker.size - position = 1000 * (marker.center + marker.rot*marker.size*M.Vector((0, -0.9))) + position = 1000 * (marker.center + marker.size * marker.rot @ M.Vector((0, -0.9))) data_markers.append(self.command_arrow.format( index=marker.text, arrow_pos=1000 * marker.center, @@ -1830,15 +1688,15 @@ class PDF: break data_outer.append(line_through(data_loop) + "s") - for uvedge in island.edges: - edge = uvedge.edge + for loop, uvedge in island.edges.items(): + edge = mesh.edges[loop.edge] if edge.is_cut(uvedge.uvface.face) and not uvedge.sticker: continue data_uvedge = line_through((uvedge.va, uvedge.vb)) + "S" if edge.freestyle: data_freestyle.append(data_uvedge) - # each uvedge is in two opposite-oriented variants; we want to add each only once - if uvedge.sticker or uvedge.uvface.flipped != (uvedge.va.vertex.index > uvedge.vb.vertex.index): + # each uvedge exists in two opposite-oriented variants; we want to add each only once + if uvedge.sticker or uvedge.uvface.flipped != (id(uvedge.va) > id(uvedge.vb)): if edge.angle > self.angle_epsilon: data_convex.append(data_uvedge) elif edge.angle < -self.angle_epsilon: @@ -1926,7 +1784,7 @@ class Unfold(bpy.types.Operator): def draw(self, context): layout = self.layout col = layout.column() - col.active = not self.object or len(self.object.data.uv_textures) < 8 + col.active = not self.object or len(self.object.data.uv_layers) < 8 col.prop(self.properties, "do_create_uvmap") layout.label(text="Edge Cutting Factors:") col = layout.column(align=True) @@ -1939,30 +1797,30 @@ class Unfold(bpy.types.Operator): sce = bpy.context.scene settings = sce.paper_model recall_mode = context.object.mode - bpy.ops.object.mode_set(mode='OBJECT') - recall_display_islands, sce.paper_model.display_islands = sce.paper_model.display_islands, False + bpy.ops.object.mode_set(mode='EDIT') - self.object = context.active_object - mesh = self.object.data + self.object = context.object - cage_size = M.Vector((settings.output_size_x, settings.output_size_y)) if settings.limit_by_page else None + cage_size = M.Vector((settings.output_size_x, settings.output_size_y)) priority_effect = { 'CONVEX': self.priority_effect_convex, 'CONCAVE': self.priority_effect_concave, 'LENGTH': self.priority_effect_length} try: unfolder = Unfolder(self.object) - unfolder.prepare( - cage_size, self.do_create_uvmap, mark_seams=True, - priority_effect=priority_effect, scale=sce.unit_settings.scale_length/settings.scale) + unfolder.do_create_uvmap = self.do_create_uvmap + scale = sce.unit_settings.scale_length / settings.scale + unfolder.prepare(cage_size, priority_effect, scale, settings.limit_by_page) + unfolder.mesh.mark_cuts() except UnfoldError as error: self.report(type={'ERROR_INVALID_INPUT'}, message=error.args[0]) + error.mesh_select() bpy.ops.object.mode_set(mode=recall_mode) - sce.paper_model.display_islands = recall_display_islands return {'CANCELLED'} + mesh = self.object.data + mesh.update() if mesh.paper_island_list: unfolder.copy_island_names(mesh.paper_island_list) - island_list = mesh.paper_island_list attributes = {item.label: (item.abbreviation, item.auto_label, item.auto_abbrev) for item in island_list} island_list.clear() # remove previously defined islands @@ -1970,21 +1828,18 @@ class Unfold(bpy.types.Operator): # add islands to UI list and set default descriptions list_item = island_list.add() # add faces' IDs to the island - for uvface in island.faces: + for face in island.faces: lface = list_item.faces.add() - lface.id = uvface.face.index - + lface.id = face.index list_item["label"] = island.label list_item["abbreviation"], list_item["auto_label"], list_item["auto_abbrev"] = attributes.get( island.label, (island.abbreviation, True, True)) island_item_changed(list_item, context) + mesh.paper_island_index = -1 - mesh.paper_island_index = -1 - mesh.show_edge_seams = True - + del unfolder bpy.ops.object.mode_set(mode=recall_mode) - sce.paper_model.display_islands = recall_display_islands return {'FINISHED'} @@ -2152,6 +2007,9 @@ class ExportPaperModel(bpy.types.Operator): output_dpi: bpy.props.FloatProperty( name="Resolution (DPI)", description="Resolution of images in pixels per inch", default=90, min=1, soft_min=30, soft_max=600, subtype="UNSIGNED") + bake_samples: bpy.props.IntProperty( + name="Samples", description="Number of samples to render for each pixel", + default=64, min=1, subtype="UNSIGNED") file_format: bpy.props.EnumProperty( name="Document Format", description="File format of the exported net", default='PDF', items=[ @@ -2180,13 +2038,45 @@ class ExportPaperModel(bpy.types.Operator): style: bpy.props.PointerProperty(type=PaperModelStyle) unfolder = None - largest_island_ratio = 0 @classmethod def poll(cls, context): return context.active_object and context.active_object.type == 'MESH' + + def prepare(self, context): + sce = context.scene + self.recall_mode = context.object.mode + bpy.ops.object.mode_set(mode='EDIT') + + self.object = context.active_object + self.unfolder = Unfolder(self.object) + cage_size = M.Vector((sce.paper_model.output_size_x, sce.paper_model.output_size_y)) + self.unfolder.prepare(cage_size, scale=sce.unit_settings.scale_length/self.scale, limit_by_page=sce.paper_model.limit_by_page) + if self.scale == 1: + self.scale = ceil(self.get_scale_ratio(sce)) + + def recall(self): + if self.unfolder: + del self.unfolder + bpy.ops.object.mode_set(mode=self.recall_mode) + + def invoke(self, context, event): + self.scale = context.scene.paper_model.scale + try: + self.prepare(context) + except UnfoldError as error: + self.report(type={'ERROR_INVALID_INPUT'}, message=error.args[0]) + error.mesh_select() + self.recall() + return {'CANCELLED'} + wm = context.window_manager + wm.fileselect_add(self) + return {'RUNNING_MODAL'} def execute(self, context): + if not self.unfolder: + self.prepare(context) + self.unfolder.do_create_uvmap = self.do_create_uvmap try: if self.object.data.paper_island_list: self.unfolder.copy_island_names(self.object.data.paper_island_list) @@ -2196,41 +2086,17 @@ class ExportPaperModel(bpy.types.Operator): except UnfoldError as error: self.report(type={'ERROR_INVALID_INPUT'}, message=error.args[0]) return {'CANCELLED'} + finally: + self.recall() def get_scale_ratio(self, sce): - margin = self.output_margin + self.sticker_width + 1e-5 + margin = self.output_margin + self.sticker_width if min(self.output_size_x, self.output_size_y) <= 2 * margin: return False output_inner_size = M.Vector((self.output_size_x - 2*margin, self.output_size_y - 2*margin)) ratio = self.unfolder.mesh.largest_island_ratio(output_inner_size) return ratio * sce.unit_settings.scale_length / self.scale - def invoke(self, context, event): - sce = context.scene - recall_mode = context.object.mode - bpy.ops.object.mode_set(mode='OBJECT') - - self.scale = sce.paper_model.scale - self.object = context.active_object - cage_size = M.Vector((sce.paper_model.output_size_x, sce.paper_model.output_size_y)) if sce.paper_model.limit_by_page else None - try: - self.unfolder = Unfolder(self.object) - self.unfolder.prepare( - cage_size, create_uvmap=self.do_create_uvmap, - scale=sce.unit_settings.scale_length/self.scale) - except UnfoldError as error: - self.report(type={'ERROR_INVALID_INPUT'}, message=error.args[0]) - bpy.ops.object.mode_set(mode=recall_mode) - return {'CANCELLED'} - scale_ratio = self.get_scale_ratio(sce) - if scale_ratio > 1: - self.scale = ceil(self.scale * scale_ratio) - wm = context.window_manager - wm.fileselect_add(self) - - bpy.ops.object.mode_set(mode=recall_mode) - return {'RUNNING_MODAL'} - def draw(self, context): layout = self.layout @@ -2281,10 +2147,13 @@ class ExportPaperModel(bpy.types.Operator): box.prop(self.properties, "output_type") col = box.column() col.active = (self.output_type != 'NONE') - if len(self.object.data.uv_textures) == 8: + if len(self.object.data.uv_layers) == 8: col.label(text="No UV slots left, No Texture is the only option.", icon='ERROR') - elif context.scene.render.engine not in ('BLENDER_RENDER', 'CYCLES') and self.output_type != 'NONE': - col.label(text="Blender Internal engine will be used for texture baking.", icon='ERROR') + elif context.scene.render.engine != 'CYCLES' and self.output_type != 'NONE': + col.label(text="Cycles will be used for texture baking.", icon='ERROR') + row = col.row() + row.active = self.output_type in ('AMBIENT_OCCLUSION', 'RENDER', 'SELECTED_TO_ACTIVE') + row.prop(self.properties, "bake_samples") col.prop(self.properties, "output_dpi") row = col.row() row.active = self.file_format == 'SVG' @@ -2335,8 +2204,65 @@ class ExportPaperModel(bpy.types.Operator): box.prop(self.style, "text_color") -def menu_func(self, context): - self.layout.operator("export_mesh.paper_model", text="Paper Model (.svg)") +def menu_func_export(self, context): + self.layout.operator("export_mesh.paper_model", text="Paper Model (.pdf/.svg)") + + +def menu_func_unfold(self, context): + self.layout.operator("mesh.unfold", text="Unfold") + + +class SelectIsland(bpy.types.Operator): + """Blender Operator: select all faces of the active island""" + + bl_idname = "mesh.select_paper_island" + bl_label = "Select Island" + bl_description = "Select an island of the paper model net" + + operation: bpy.props.EnumProperty( + name="Operation", description="Operation with the current selection", + default='ADD', items=[ + ('ADD', "Add", "Add to current selection"), + ('REMOVE', "Remove", "Remove from selection"), + ('REPLACE', "Replace", "Select only the ") + ]) + + @classmethod + def poll(cls, context): + return context.active_object and context.active_object.type == 'MESH' and context.mode == 'EDIT_MESH' + + def execute(self, context): + ob = context.active_object + me = ob.data + bm = bmesh.from_edit_mesh(me) + island = me.paper_island_list[me.paper_island_index] + faces = {face.id for face in island.faces} + edges = set() + verts = set() + if self.operation == 'REPLACE': + for face in bm.faces: + selected = face.index in faces + face.select = selected + if selected: + edges.update(face.edges) + verts.update(face.verts) + for edge in bm.edges: + edge.select = edge in edges + for vert in bm.verts: + vert.select = vert in verts + else: + selected = (self.operation == 'ADD') + for index in faces: + face = bm.faces[index] + face.select = selected + edges.update(face.edges) + verts.update(face.verts) + for edge in edges: + edge.select = any(face.select for face in edge.link_faces) + for vert in verts: + vert.select = any(edge.select for edge in vert.link_edges) + bmesh.update_edit_mesh(me, False, False) + return {'FINISHED'} class VIEW3D_MT_paper_model_presets(bpy.types.Menu): @@ -2357,7 +2283,7 @@ class AddPresetPaperModel(bl_operators.presets.AddPresetBase, bpy.types.Operator @property def preset_values(self): op = bpy.ops.export_mesh.paper_model - properties = op.get_rna_type().properties.items() + properties = op.get_rna().bl_rna.properties.items() blacklist = bpy.types.Operator.bl_rna.properties.keys() return [ "op.{}".format(prop_id) for (prop_id, prop) in properties @@ -2402,11 +2328,12 @@ class VIEW3D_PT_paper_model_tools(bpy.types.Panel): sub.prop(props, "output_size_y") -class VIEW3D_PT_paper_model_islands(bpy.types.Panel): - bl_label = "Islands" - bl_space_type = "VIEW_3D" - bl_region_type = "TOOLS" - bl_category = "Paper Model" +class DATA_PT_paper_model_islands(bpy.types.Panel): + bl_space_type = 'PROPERTIES' + bl_region_type = 'WINDOW' + bl_context = "data" + bl_label = "Paper Model Islands" + COMPAT_ENGINES = {'BLENDER_RENDER', 'BLENDER_EEVEE', 'BLENDER_WORKBENCH'} def draw(self, context): layout = self.layout @@ -2414,6 +2341,7 @@ class VIEW3D_PT_paper_model_islands(bpy.types.Panel): obj = context.active_object mesh = obj.data if obj and obj.type == 'MESH' else None + layout.operator("mesh.unfold", icon='FILE_REFRESH') if mesh and mesh.paper_island_list: layout.label( text="1 island:" if len(mesh.paper_island_list) == 1 else @@ -2421,6 +2349,10 @@ class VIEW3D_PT_paper_model_islands(bpy.types.Panel): layout.template_list( 'UI_UL_list', 'paper_model_island_list', mesh, 'paper_island_list', mesh, 'paper_island_index', rows=1, maxrows=5) + sub = layout.split(align=True) + sub.operator("mesh.select_paper_island", text="Select").operation = 'ADD' + sub.operator("mesh.select_paper_island", text="Deselect").operation = 'REMOVE' + sub.prop(sce.paper_model, "sync_island", icon='UV_SYNC_SELECT', toggle=True) if mesh.paper_island_index >= 0: list_item = mesh.paper_island_list[mesh.paper_island_index] sub = layout.column(align=True) @@ -2431,62 +2363,7 @@ class VIEW3D_PT_paper_model_islands(bpy.types.Panel): row.active = not list_item.auto_abbrev row.prop(list_item, "abbreviation") else: - layout.label(text="Not unfolded") - layout.box().label(text="Use the 'Unfold' tool") - sub = layout.column(align=True) - sub.active = bool(mesh and mesh.paper_island_list) - sub.prop(sce.paper_model, "display_islands", icon='RESTRICT_VIEW_OFF') - row = sub.row(align=True) - row.active = bool(sce.paper_model.display_islands and mesh and mesh.paper_island_list) - row.prop(sce.paper_model, "islands_alpha", slider=True) - - -def display_islands(self, context): - # TODO: save the vertex positions and don't recalculate them always? - ob = context.active_object - if not ob or ob.type != 'MESH': - return - mesh = ob.data - if not mesh.paper_island_list or mesh.paper_island_index == -1: - return - - bgl.glMatrixMode(bgl.GL_PROJECTION) - perspMatrix = context.space_data.region_3d.perspective_matrix - perspBuff = bgl.Buffer(bgl.GL_FLOAT, (4, 4), perspMatrix.transposed()) - bgl.glLoadMatrixf(perspBuff) - bgl.glMatrixMode(bgl.GL_MODELVIEW) - objectBuff = bgl.Buffer(bgl.GL_FLOAT, (4, 4), ob.matrix_world.transposed()) - bgl.glLoadMatrixf(objectBuff) - bgl.glEnable(bgl.GL_BLEND) - bgl.glBlendFunc(bgl.GL_SRC_ALPHA, bgl.GL_ONE_MINUS_SRC_ALPHA) - bgl.glEnable(bgl.GL_POLYGON_OFFSET_FILL) - bgl.glPolygonOffset(0, -10) # offset in Zbuffer to remove flicker - bgl.glPolygonMode(bgl.GL_FRONT_AND_BACK, bgl.GL_FILL) - bgl.glColor4f(1.0, 0.4, 0.0, self.islands_alpha) - island = mesh.paper_island_list[mesh.paper_island_index] - for lface in island.faces: - face = mesh.polygons[lface.id] - bgl.glBegin(bgl.GL_POLYGON) - for vertex_id in face.vertices: - vertex = mesh.vertices[vertex_id] - bgl.glVertex4f(*vertex.co.to_4d()) - bgl.glEnd() - bgl.glPolygonOffset(0.0, 0.0) - bgl.glDisable(bgl.GL_POLYGON_OFFSET_FILL) - bgl.glLoadIdentity() -display_islands.handle = None - - -def display_islands_changed(self, context): - """Switch highlighting islands on/off""" - if self.display_islands: - if not display_islands.handle: - display_islands.handle = bpy.types.SpaceView3D.draw_handler_add( - display_islands, (self, context), 'WINDOW', 'POST_VIEW') - else: - if display_islands.handle: - bpy.types.SpaceView3D.draw_handler_remove(display_islands.handle, 'WINDOW') - display_islands.handle = None + layout.box().label(text="Not unfolded") def label_changed(self, context): @@ -2523,9 +2400,14 @@ def island_item_changed(self, context): self.abbreviation, self.label, len(self.faces), "faces" if len(self.faces) > 1 else "face") +def island_index_changed(self, context): + """The active island was changed""" + if context.scene.paper_model.sync_island and SelectIsland.poll(context): + bpy.ops.mesh.select_paper_island(operation='REPLACE') + + class FaceList(bpy.types.PropertyGroup): id: bpy.props.IntProperty(name="Face ID") -bpy.utils.register_class(FaceList) class IslandList(bpy.types.PropertyGroup): @@ -2543,16 +2425,12 @@ class IslandList(bpy.types.PropertyGroup): auto_abbrev: bpy.props.BoolProperty( name="Auto Abbreviation", description="Generate the abbreviation automatically", default=True, update=island_item_changed) -bpy.utils.register_class(IslandList) class PaperModelSettings(bpy.types.PropertyGroup): - display_islands: bpy.props.BoolProperty( - name="Highlight selected island", description="Highlight faces corresponding to the selected island in the 3D View", - options={'SKIP_SAVE'}, update=display_islands_changed) - islands_alpha: bpy.props.FloatProperty( - name="Opacity", description="Opacity of island highlighting", - min=0.0, max=1.0, default=0.3) + sync_island: bpy.props.BoolProperty( + name="Sync", description="Keep faces of the active island selected", + default=False, update=island_index_changed) limit_by_page: bpy.props.BoolProperty( name="Limit Island Size", description="Do not create islands larger than given dimensions", default=False, update=page_size_preset_changed) @@ -2568,12 +2446,26 @@ class PaperModelSettings(bpy.types.PropertyGroup): scale: bpy.props.FloatProperty( name="Scale", description="Divisor of all dimensions when exporting", default=1, soft_min=1.0, soft_max=10000.0, step=100, subtype='UNSIGNED', precision=1) -bpy.utils.register_class(PaperModelSettings) -def register(): - bpy.utils.register_module(__name__) +module_classes = ( + Unfold, + ExportPaperModel, + ClearAllSeams, + SelectIsland, + AddPresetPaperModel, + FaceList, + IslandList, + PaperModelSettings, + VIEW3D_MT_paper_model_presets, + DATA_PT_paper_model_islands, + #VIEW3D_PT_paper_model_tools, +) + +def register(): + for cls in module_classes: + bpy.utils.register_class(cls) bpy.types.Scene.paper_model = bpy.props.PointerProperty( name="Paper Model", description="Settings of the Export Paper Model script", type=PaperModelSettings, options={'SKIP_SAVE'}) @@ -2581,16 +2473,16 @@ def register(): name="Island List", type=IslandList) bpy.types.Mesh.paper_island_index = bpy.props.IntProperty( name="Island List Index", - default=-1, min=-1, max=100, options={'SKIP_SAVE'}) - bpy.types.TOPBAR_MT_file_export.append(menu_func) + default=-1, min=-1, max=100, options={'SKIP_SAVE'}, update=island_index_changed) + bpy.types.TOPBAR_MT_file_export.append(menu_func_export) + bpy.types.VIEW3D_MT_edit_mesh.prepend(menu_func_unfold) def unregister(): - bpy.utils.unregister_module(__name__) - bpy.types.TOPBAR_MT_file_export.remove(menu_func) - if display_islands.handle: - bpy.types.SpaceView3D.draw_handler_remove(display_islands.handle, 'WINDOW') - display_islands.handle = None + bpy.types.TOPBAR_MT_file_export.remove(menu_func_export) + bpy.types.VIEW3D_MT_edit_mesh.remove(menu_func_unfold) + for cls in reversed(module_classes): + bpy.utils.unregister_class(cls) if __name__ == "__main__":