Skip to content
Snippets Groups Projects
io_export_paper_model.py 117 KiB
Newer Older
  • Learn to ignore specific revisions
  • # -*- coding: utf-8 -*-
    
    # 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",
    
        "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"
    
    # 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
    
    # 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
    
    # check conflicts in island naming and either:
    #  * 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)
    
    import bpy
    import bl_operators
    
    import mathutils as M
    from re import compile as re_compile
    
    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,
        'CONCAVE': 1,
        'LENGTH': -0.05
    }
    
    
    global_paper_sizes = [
        ('USER', "User defined", "User defined paper size"),
        ('A4', "A4", "International standard paper size"),
        ('A3', "A3", "International standard paper size"),
        ('US_LETTER', "Letter", "North American paper size"),
        ('US_LEGAL', "Legal", "North American paper size")
    ]
    
    
    
    def first_letters(text):
        """Iterator over the first letter of each word"""
        for match in first_letters.pattern.finditer(text):
            yield text[match.start()]
    first_letters.pattern = re_compile("((?<!\w)\w)|\d")
    
    
    def is_upsidedown_wrong(name):
        """Tell if the string would get a different meaning if written upside down"""
        chars = set(name)
        mistakable = set("69NZMWpbqd")
        rotatable = set("80oOxXIl").union(mistakable)
        return chars.issubset(rotatable) and not chars.isdisjoint(mistakable)
    
    
    def pairs(sequence):
        """Generate consecutive pairs throughout the given sequence; at last, it gives elements last, first."""
        i = iter(sequence)
        previous = first = next(i)
        for this in i:
            yield previous, this
            previous = this
        yield this, first
    
    
    def fitting_matrix(v1, v2):
        """Get a matrix that rotates v1 to the same direction as v2"""
        return (1 / v1.length_squared) * M.Matrix((
            (v1.x*v2.x + v1.y*v2.y, v1.y*v2.x - v1.x*v2.y),
            (v1.x*v2.y - v1.y*v2.x, v1.x*v2.x + v1.y*v2.y)))
    
    
    def z_up_matrix(n):
        """Get a rotation matrix that aligns given vector upwards."""
        b = n.xy.length
    
                (n.x*n.z/(b*s), n.y*n.z/(b*s), -b/s),
    
                (-n.y/b, n.x/b, 0),
                (0, 0, 0)
            ))
        else:
            # no need for rotation
            return M.Matrix((
                (1, 0, 0),
                (0, (-1 if n.z < 0 else 1), 0),
                (0, 0, 0)
            ))
    
    
    
    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]
        width, height = int(dimensions.x), int(dimensions.y)
        image = bpy.data.images.new(image_name, width, height, alpha=True)
        if image.users > 0:
    
            raise UnfoldError(
                "There is something wrong with the material of the model. "
    
                "Please report this on the BlenderArtists forum. Export failed.")
        image.pixels = [1, 1, 1, alpha] * (width * height)
        image.file_format = 'PNG'
        return image
    
    
    class UnfoldError(ValueError):
    
        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.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()
    
        
        def __del__(self):
            if not self.do_create_uvmap:
                self.mesh.delete_uvmap()
    
        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 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()
    
    
        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 = {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]
            available_orig = [True for item in island_list]
            for face_count, i, j in matching:
                if available_new[i] and available_orig[j]:
                    available_new[i] = available_orig[j] = False
                    self.mesh.islands[i].label = island_list[j].label
                    self.mesh.islands[i].abbreviation = island_list[j].abbreviation
    
        def save(self, properties):
            """Export the document"""
    
            # Note about scale: input is directly 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
            filepath = properties.filepath
            extension = properties.file_format.lower()
            filepath = bpy.path.ensure_ext(filepath, "." + extension)
            # page size in meters
            page_size = M.Vector((properties.output_size_x, properties.output_size_y))
            # printable area size in meters
            printable_size = page_size - 2 * properties.output_margin * M.Vector((1, 1))
            unit_scale = bpy.context.scene.unit_settings.scale_length
            ppm = properties.output_dpi * 100 / 2.54  # pixels per meter
    
            # after this call, all dimensions will be in meters
            self.mesh.scale_islands(unit_scale/properties.scale)
            if properties.do_create_stickers:
                self.mesh.generate_stickers(properties.sticker_width, properties.do_create_numbers)
            elif properties.do_create_numbers:
                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
            # title height must be somewhat larger that text size, glyphs go below the baseline
    
            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')
    
                self.mesh.save_uv(cage_size=printable_size, separate_image=use_separate_images)
    
    
                sce = bpy.context.scene
                rd = sce.render
                bk = rd.bake
    
                # 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
    
                    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(printable_size * ppm, filepath)
    
                elif image_packing == 'ISLAND_LINK':
    
                    image_dir = filepath[:filepath.rfind(".")]
    
                    self.mesh.save_separate_images(ppm, image_dir)
    
                elif image_packing == 'ISLAND_EMBED':
    
                    self.mesh.save_separate_images(ppm, filepath, embed=Exporter.encode_image)
    
                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
            exporter.text_size = properties.sticker_width
            exporter.write(self.mesh, filepath)
    
    
    class Mesh:
        """Wrapper for Bpy Mesh"""
    
    
        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.pages = list()
            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"""
    
            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
    
            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),
                {"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."""
    
            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 edge.main_faces]
    
                average_length = sum(edge.vector.length for edge in edges) / len(edges)
    
                for edge in edges:
                    edge.generate_priority(priority_effect, average_length)
                edges.sort(reverse=False, key=lambda edge: edge.priority)
                for edge in edges:
    
                    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 (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.loop)
    
                            reordered[index] = uvedge
                        except ValueError:
                            reordered.append(uvedge)
                    edge.uvedges = reordered
    
            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.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
    
                # construct a linked list from each island's boundary
                # uvedge.neighbor_right is clockwise = forward = via uvedge.vb if not uvface.flipped
                neighbor_lookup, conflicts = dict(), dict()
                for uvedge in island.boundary:
                    uvvertex = uvedge.va if uvedge.uvface.flipped else uvedge.vb
                    if uvvertex not in neighbor_lookup:
                        neighbor_lookup[uvvertex] = uvedge
                    else:
                        if uvvertex not in conflicts:
                            conflicts[uvvertex] = [neighbor_lookup[uvvertex], uvedge]
                        else:
                            conflicts[uvvertex].append(uvedge)
    
                for uvedge in island.boundary:
                    uvvertex = uvedge.vb if uvedge.uvface.flipped else uvedge.va
                    if uvvertex not in conflicts:
                        # using the 'get' method so as to handle single-connected vertices properly
                        uvedge.neighbor_right = neighbor_lookup.get(uvvertex, uvedge)
                        uvedge.neighbor_right.neighbor_left = uvedge
                    else:
                        conflicts[uvvertex].append(uvedge)
    
                # resolve merged vertices with more boundaries crossing
                def direction_to_float(vector):
                    return (1 - vector.x/vector.length) if vector.y > 0 else (vector.x/vector.length - 1)
                for uvvertex, uvedges in conflicts.items():
                    def is_inwards(uvedge):
                        return uvedge.uvface.flipped == (uvedge.va is uvvertex)
    
                    def uvedge_sortkey(uvedge):
                        if is_inwards(uvedge):
                            return direction_to_float(uvedge.va.co - uvedge.vb.co)
                        else:
                            return direction_to_float(uvedge.vb.co - uvedge.va.co)
    
                    uvedges.sort(key=uvedge_sortkey)
    
                    for right, left in (
                            zip(uvedges[:-1:2], uvedges[1::2]) if is_inwards(uvedges[0])
                            else zip([uvedges[-1]] + uvedges[1::2], uvedges[:-1:2])):
    
                        left.neighbor_right = right
                        right.neighbor_left = left
            return True
    
        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"""
    
                # TODO: it should take into account overlaps with faces and with other stickers
    
                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():
    
                if edge.is_main_cut and len(edge.uvedges) >= 2 and edge.vector.length_squared > 0:
    
                    target, source = edge.uvedges[:2]
                    if uvedge_priority(target) < uvedge_priority(source):
                        target, source = source, target
                    target_island = target.uvface.island
    
                        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(target, default_width, index))
    
                    add_sticker(source, index, target)
    
                elif len(edge.uvedges) > 2:
    
                if len(edge.uvedges) > 2:
    
                    for source in edge.uvedges[2:]:
                        add_sticker(source, index, target)
    
    
        def generate_numbers_alone(self, size):
            global_numbering = 0
            for edge in self.edges.values():
                if edge.is_main_cut and len(edge.uvedges) >= 2:
                    global_numbering += 1
                    index = str(global_numbering)
                    if is_upsidedown_wrong(index):
                        index += "."
                    for uvedge in edge.uvedges:
    
                        uvedge.uvface.island.add_marker(NumberAlone(uvedge, index, size))
    
    
        def enumerate_islands(self):
            for num, island in enumerate(self.islands, 1):
                island.number = num
                island.generate_label()
    
        def scale_islands(self, scale):
            for island in self.islands:
    
                vertices = set(island.vertices.values())
                for point in chain((vertex.co for vertex in vertices), island.fake_vertices):
    
        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 = [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)
                for point in points:
                    # note: we need an in-place operation, and Vector.rotate() seems to work for 3d vectors only
    
                for marker in island.markers:
    
                    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, 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, 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."""
                bbox_x, bbox_y = island.bounding_box.xy
                for x in stops_x:
                    if x + bbox_x > cage_size.x:
                        continue
                    for y in stops_y:
                        if y + bbox_y > cage_size.y or (x, y) in occupied_cache:
                            continue
                        for i, obstacle in enumerate(page_islands):
                            # if this obstacle overlaps with the island, try another stop
                            if (x + bbox_x > obstacle.pos.x and
                                    obstacle.pos.x + obstacle.bounding_box.x > x and
                                    y + bbox_y > obstacle.pos.y and
                                    obstacle.pos.y + obstacle.bounding_box.y > y):
                                if x >= obstacle.pos.x and y >= obstacle.pos.y:
                                    occupied_cache.add((x, y))
                                # just a stupid heuristic to make subsequent searches faster
                                if i > 0:
                                    page_islands[1:i+1] = page_islands[:i]
                                    page_islands[0] = obstacle
                                break
                        else:
                            # if no obstacle called break, this position is okay
                            island.pos.xy = x, y
                            page_islands.append(island)
                            stops_x.append(x + bbox_x)
                            stops_y.append(y + bbox_y)
                            return True
                return False
    
            def drop_portion(stops, border, divisor):
                stops.sort()
                # distance from left neighbor to the right one, excluding the first stop
                distances = [right - left for left, right in zip(stops, chain(stops[2:], [border]))]
                quantile = sorted(distances)[len(distances) // divisor]
                return [stop for stop, distance in zip(stops, chain([quantile], distances)) if distance >= quantile]
    
            if any(island.bounding_box.x > cage_size.x or island.bounding_box.y > cage_size.y for island in self.islands):
    
                raise UnfoldError(
                    "An island is too big to fit onto page of the given size. "
    
                    "Either downscale the model or find and split that island manually.\n"
                    "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  # TODO delete me
    
    
            while remaining_islands:
                # create a new page and try to fit as many islands onto it as possible
                page = Page(page_num)
                page_num += 1
                occupied_cache = set()
                stops_x, stops_y = [0], [0]
                for island in remaining_islands:
    
                    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)
                        stops_y = drop_portion(stops_y, cage_size.y, 4)
                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):
    
            if separate_image:
                for island in self.islands:
    
                    island.save_uv_separate(self.looptex)
    
            else:
                for island in self.islands:
    
                    island.save_uv(self.looptex, cage_size)
    
        def save_image(self, page_size_pixels: M.Vector, filename):
    
                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 = [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, 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)
    
                self.bake(island.faces.keys(), image)
    
                if embed:
                    island.embedded_image = embed(image)
                else:
                    from os import makedirs
                    image_dir = filepath
                    makedirs(image_dir, exist_ok=True)
                    image_path = os_path.join(image_dir, "island{}.png".format(i))
                    image.filepath_raw = image_path
                    image.save()
    
                    island.image_path = image_path
    
                image.user_clear()
                bpy.data.images.remove(image)
    
        
        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__ = ('data', 'va', 'vb', 'main_faces', 'uvedges',
    
            'is_main_cut', 'force_cut', 'priority', 'freestyle')
    
    
        def __init__(self, edge):
            self.data = edge
            self.va, self.vb = edge.verts
    
            self.vector = self.vb.co - self.va.co
    
            # 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.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
    
    
        def choose_main_faces(self):
            """Choose two main faces that might get connected in an island"""
    
            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"""
    
            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
    
                s = normal_a.cross(normal_b).dot(self.vector.normalized())
                s = max(min(s, 1.0), -1.0) # deal with rounding errors
                self.angle = asin(s)
    
                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"""
            angle = self.angle
            if angle > 0:
                self.priority = priority_effect['CONVEX'] * angle / pi
            else:
                self.priority = priority_effect['CONCAVE'] * (-angle) / pi
    
            self.priority += (self.vector.length / average_length) * priority_effect['LENGTH']
    
    
        def is_cut(self, face):
            """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 {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:
                return True
    
        def other_uvedge(self, this):
            """Get an uvedge of this edge that is not the given one
            causes an IndexError if case of less than two adjacent edges"""
            return self.uvedges[1] if this is self.uvedges[0] else self.uvedges[0]
    
    
    class Island:
        """Part of the net to be exported"""
    
        __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, mesh, face, matrix, normal_matrix):
    
            """Create an Island from a single Face"""
    
            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
            self.abbreviation = None
            self.title = None
            self.pos = M.Vector((0, 0))
            self.image_path = None
            self.embedded_image = None
            self.is_inside_out = False  # swaps concave <-> convex edges
            self.has_safe_geometry = True
            self.sticker_numbering = 0
    
            
            uvface = UVFace(face, self, matrix, normal_matrix)
    
            self.vertices.update(uvface.vertices)
            self.edges.update(uvface.edges)
    
            self.faces[face] = uvface
    
            # UVEdges on the boundary
    
            self.boundary = list(self.edges.values())
        
    
        def add_marker(self, marker):
    
            self.fake_vertices.extend(marker.bounds)
    
            self.markers.append(marker)
    
        def generate_label(self, label=None, abbreviation=None):
            """Assign a name to this island automatically"""
            abbr = abbreviation or self.abbreviation or str(self.number)
            # TODO: dots should be added in the last instant when outputting any text
            if is_upsidedown_wrong(abbr):
                abbr += "."
            self.label = label or self.label or "Island {}".format(self.number)
            self.abbreviation = abbr
    
        def save_uv(self, tex, cage_size):
            """Save UV Coordinates of all UVFaces to a given UV texture
    
            tex: UV Texture layer to use (BMLayerItem)
    
            page_size: size of the page in pixels (vector)"""
    
            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 (BMLayerItem)
    
            page_size: size of the page in pixels (vector)"""
            scale_x, scale_y = 1 / self.bounding_box.x, 1 / self.bounding_box.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):