Skip to content
Snippets Groups Projects
io_export_paper_model.py 117 KiB
Newer Older
  • Learn to ignore specific revisions
  •             ('NONE', "No Texture", "Export the net only"),
                ('TEXTURE', "From Materials", "Render the diffuse color and all painted textures"),
                ('AMBIENT_OCCLUSION', "Ambient Occlusion", "Render the Ambient Occlusion pass"),
                ('RENDER', "Full Render", "Render the material in actual scene illumination"),
                ('SELECTED_TO_ACTIVE', "Selected to Active", "Render all selected surrounding objects as a texture")
            ])
    
        do_create_stickers: bpy.props.BoolProperty(
    
            name="Create Tabs", description="Create gluing tabs around the net (useful for paper)",
    
        do_create_numbers: bpy.props.BoolProperty(
    
            name="Create Numbers", description="Enumerate edges to make it clear which edges should be sticked together",
    
        sticker_width: bpy.props.FloatProperty(
    
            name="Tabs and Text Size", description="Width of gluing tabs and their numbers",
    
            default=0.005, soft_min=0, soft_max=0.05, step=0.1, subtype="UNSIGNED", unit="LENGTH")
    
        angle_epsilon: bpy.props.FloatProperty(
    
            name="Hidden Edge Angle", description="Folds with angle below this limit will not be drawn",
    
            default=pi/360, min=0, soft_max=pi/4, step=0.01, subtype="ANGLE", unit="ROTATION")
    
        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=[
                ('PDF', "PDF", "Adobe Portable Document Format 1.4"),
                ('SVG', "SVG", "W3C Scalable Vector Graphics"),
            ])
    
        image_packing: bpy.props.EnumProperty(
    
            name="Image Packing Method", description="Method of attaching baked image(s) to the SVG",
    
            default='ISLAND_EMBED', items=[
                ('PAGE_LINK', "Single Linked", "Bake one image per page of output and save it separately"),
                ('ISLAND_LINK', "Linked", "Bake images separately for each island and save them in a directory"),
                ('ISLAND_EMBED', "Embedded", "Bake images separately for each island and embed them into the SVG")
            ])
    
        scale: bpy.props.FloatProperty(
    
            name="Scale", description="Divisor of all dimensions when exporting",
    
            default=1, soft_min=1.0, soft_max=100.0, subtype='FACTOR', precision=1)
    
        do_create_uvmap: bpy.props.BoolProperty(
    
            name="Create UVMap",
            description="Create a new UV Map showing the islands and page layout",
    
            default=False, options={'SKIP_SAVE'})
    
        ui_expanded_document: bpy.props.BoolProperty(
    
            name="Show Document Settings Expanded",
            description="Shows the box 'Document Settings' expanded in user interface",
    
            default=True, options={'SKIP_SAVE'})
    
        ui_expanded_style: bpy.props.BoolProperty(
    
            name="Show Style Settings Expanded",
            description="Shows the box 'Colors and Style' expanded in user interface",
    
            default=False, options={'SKIP_SAVE'})
    
        style: bpy.props.PointerProperty(type=PaperModelStyle)
    
    
        unfolder = None
    
        @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))
    
            unfolder_scale = sce.unit_settings.scale_length/self.scale
            self.unfolder.prepare(cage_size, scale=unfolder_scale, limit_by_page=sce.paper_model.limit_by_page)
            if sce.paper_model.use_auto_scale:
    
                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'}
    
            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)
                self.unfolder.save(self.properties)
                self.report({'INFO'}, "Saved a {}-page document".format(len(self.unfolder.mesh.pages)))
                return {'FINISHED'}
            except UnfoldError as error:
                self.report(type={'ERROR_INVALID_INPUT'}, message=error.args[0])
                return {'CANCELLED'}
    
    
        def get_scale_ratio(self, sce):
    
            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 draw(self, context):
            layout = self.layout
            layout.prop(self.properties, "do_create_uvmap")
    
            layout.prop(self.properties, "scale", text="Scale: 1/")
    
            scale_ratio = self.get_scale_ratio(context.scene)
            if scale_ratio > 1:
    
                layout.label(
                    text="An island is roughly {:.1f}x bigger than page".format(scale_ratio),
                    icon="ERROR")
    
            elif scale_ratio > 0:
                layout.label(text="Largest island is roughly 1/{:.1f} of page".format(1 / scale_ratio))
    
    
            if context.scene.unit_settings.scale_length != 1:
                layout.label(
                    text="Unit scale {:.1f} makes page size etc. not display correctly".format(
                        context.scene.unit_settings.scale_length), icon="ERROR")
    
            box = layout.box()
            row = box.row(align=True)
    
            row.prop(
                self.properties, "ui_expanded_document", text="",
    
                icon=('TRIA_DOWN' if self.ui_expanded_document else 'TRIA_RIGHT'), emboss=False)
            row.label(text="Document Settings")
    
            if self.ui_expanded_document:
                box.prop(self.properties, "file_format", text="Format")
                box.prop(self.properties, "page_size_preset")
                col = box.column(align=True)
                col.active = self.page_size_preset == 'USER'
                col.prop(self.properties, "output_size_x")
                col.prop(self.properties, "output_size_y")
                box.prop(self.properties, "output_margin")
                col = box.column()
                col.prop(self.properties, "do_create_stickers")
                col.prop(self.properties, "do_create_numbers")
                col = box.column()
                col.active = self.do_create_stickers or self.do_create_numbers
                col.prop(self.properties, "sticker_width")
                box.prop(self.properties, "angle_epsilon")
    
                box.prop(self.properties, "output_type")
                col = box.column()
                col.active = (self.output_type != 'NONE')
    
                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 != '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'
                row.prop(self.properties, "image_packing", text="Images")
    
            box = layout.box()
            row = box.row(align=True)
    
            row.prop(
                self.properties, "ui_expanded_style", text="",
    
                icon=('TRIA_DOWN' if self.ui_expanded_style else 'TRIA_RIGHT'), emboss=False)
            row.label(text="Colors and Style")
    
            if self.ui_expanded_style:
                box.prop(self.style, "line_width", text="Default line width")
                col = box.column()
                col.prop(self.style, "outer_color")
                col.prop(self.style, "outer_width", text="Relative width")
                col.prop(self.style, "outer_style", text="Style")
                col = box.column()
                col.active = self.output_type != 'NONE'
                col.prop(self.style, "use_outbg", text="Outer Lines Highlight:")
                sub = col.column()
                sub.active = self.output_type != 'NONE' and self.style.use_outbg
                sub.prop(self.style, "outbg_color", text="")
                sub.prop(self.style, "outbg_width", text="Relative width")
                col = box.column()
                col.prop(self.style, "convex_color")
                col.prop(self.style, "convex_width", text="Relative width")
                col.prop(self.style, "convex_style", text="Style")
                col = box.column()
                col.prop(self.style, "concave_color")
                col.prop(self.style, "concave_width", text="Relative width")
                col.prop(self.style, "concave_style", text="Style")
                col = box.column()
                col.prop(self.style, "freestyle_color")
                col.prop(self.style, "freestyle_width", text="Relative width")
                col.prop(self.style, "freestyle_style", text="Style")
                col = box.column()
                col.active = self.output_type != 'NONE'
                col.prop(self.style, "use_inbg", text="Inner Lines Highlight:")
                sub = col.column()
                sub.active = self.output_type != 'NONE' and self.style.use_inbg
                sub.prop(self.style, "inbg_color", text="")
                sub.prop(self.style, "inbg_width", text="Relative width")
                col = box.column()
                col.active = self.do_create_stickers
    
                col.prop(self.style, "sticker_color")
    
    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, loop_triangles=False, destructive=False)
    
    
    
    class VIEW3D_PT_paper_model_tools(bpy.types.Panel):
    
        bl_space_type = 'VIEW_3D'
        bl_region_type = 'UI'
        bl_category = 'Paper'
        bl_label = "Unfold"
    
    
        def draw(self, context):
            layout = self.layout
            sce = context.scene
            obj = context.active_object
            mesh = obj.data if obj and obj.type == 'MESH' else None
    
    
            layout.operator("mesh.unfold")
    
    
            if context.mode == 'EDIT_MESH':
                row = layout.row(align=True)
                row.operator("mesh.mark_seam", text="Mark Seam").clear = False
                row.operator("mesh.mark_seam", text="Clear Seam").clear = True
            else:
                layout.operator("mesh.clear_all_seams")
    
    
    
    class VIEW3D_PT_paper_model_settings(bpy.types.Panel):
        bl_space_type = 'VIEW_3D'
        bl_region_type = 'UI'
        bl_category = 'Paper'
        bl_label = "Export"
    
        def draw(self, context):
            layout = self.layout
            sce = context.scene
            obj = context.active_object
            mesh = obj.data if obj and obj.type == 'MESH' else None
    
            layout.operator("export_mesh.paper_model")
    
            props = sce.paper_model
    
            layout.prop(props, "use_auto_scale")
            sub = layout.row()
            sub.active = not props.use_auto_scale
            sub.prop(props, "scale", text="Model Scale:  1/")
    
            layout.prop(props, "limit_by_page")
            col = layout.column()
            col.active = props.limit_by_page
            col.prop(props, "page_size_preset")
    
            sub = col.column(align=True)
    
            sub.active = props.page_size_preset == 'USER'
            sub.prop(props, "output_size_x")
            sub.prop(props, "output_size_y")
    
    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
            sce = context.scene
            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
    
                    "{} islands:".format(len(mesh.paper_island_list)))
    
                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)
                    sub.prop(list_item, "auto_label")
                    sub.prop(list_item, "label")
                    sub.prop(list_item, "auto_abbrev")
                    row = sub.row()
                    row.active = not list_item.auto_abbrev
                    row.prop(list_item, "abbreviation")
            else:
    
                layout.box().label(text="Not unfolded")
    
    
    
    def label_changed(self, context):
        """The label of an island was changed"""
        # accessing properties via [..] to avoid a recursive call after the update
        self["auto_label"] = not self.label or self.label.isspace()
        island_item_changed(self, context)
    
    
    def island_item_changed(self, context):
        """The labelling of an island was changed"""
    
        def increment(abbrev, collisions):
            letters = "ABCDEFGHIJKLMNPQRSTUVWXYZ123456789"
            while abbrev in collisions:
                abbrev = abbrev.rstrip(letters[-1])
                abbrev = abbrev[:2] + letters[letters.find(abbrev[-1]) + 1 if len(abbrev) == 3 else 0]
            return abbrev
    
    
        # accessing properties via [..] to avoid a recursive call after the update
        island_list = context.active_object.data.paper_island_list
        if self.auto_label:
            self["label"] = ""  # avoid self-conflict
            number = 1
            while any(item.label == "Island {}".format(number) for item in island_list):
                number += 1
            self["label"] = "Island {}".format(number)
        if self.auto_abbrev:
    
            self["abbreviation"] = ""  # avoid self-conflict
            abbrev = "".join(first_letters(self.label))[:3].upper()
            self["abbreviation"] = increment(abbrev, {item.abbreviation for item in island_list})
    
        elif len(self.abbreviation) > 3:
            self["abbreviation"] = self.abbreviation[:3]
    
        self.name = "[{}] {} ({} {})".format(
            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")
    
    
    
    class IslandList(bpy.types.PropertyGroup):
    
        faces: bpy.props.CollectionProperty(
    
            name="Faces", description="Faces belonging to this island", type=FaceList)
    
        label: bpy.props.StringProperty(
    
            name="Label", description="Label on this island",
    
            default="", update=label_changed)
    
        abbreviation: bpy.props.StringProperty(
    
            name="Abbreviation", description="Three-letter label to use when there is not enough space",
    
            default="", update=island_item_changed)
    
        auto_label: bpy.props.BoolProperty(
    
            name="Auto Label", description="Generate the label automatically",
    
            default=True, update=island_item_changed)
    
        auto_abbrev: bpy.props.BoolProperty(
    
            name="Auto Abbreviation", description="Generate the abbreviation automatically",
    
            default=True, update=island_item_changed)
    
    
    class PaperModelSettings(bpy.types.PropertyGroup):
    
        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)
    
        page_size_preset: bpy.props.EnumProperty(
    
            name="Page Size", description="Maximal size of an island",
            default='A4', update=page_size_preset_changed, items=global_paper_sizes)
    
        output_size_x: bpy.props.FloatProperty(
    
            name="Width", description="Maximal width of an island",
    
            default=0.2, soft_min=0.105, soft_max=0.841, subtype="UNSIGNED", unit="LENGTH")
    
        output_size_y: bpy.props.FloatProperty(
    
            name="Height", description="Maximal height of an island",
    
            default=0.29, soft_min=0.148, soft_max=1.189, subtype="UNSIGNED", unit="LENGTH")
    
        use_auto_scale: bpy.props.BoolProperty(
            name="Automatic Scale", description="Scale the net automatically to fit on paper",
            default=True)
    
        scale: bpy.props.FloatProperty(
    
            name="Scale", description="Divisor of all dimensions when exporting",
    
            default=1, soft_min=1.0, soft_max=100.0, subtype='FACTOR', precision=1,
            update=lambda settings, _: settings.__setattr__('use_auto_scale', False))
    
    
    def factory_update_addon_category(cls, prop):
        def func(self, context):
            if hasattr(bpy.types, cls.__name__):
                bpy.utils.unregister_class(cls)
            cls.bl_category = self[prop]
            bpy.utils.register_class(cls)
        return func
    
    
    class PaperAddonPreferences(bpy.types.AddonPreferences):
        bl_idname = __name__
        unfold_category: bpy.props.StringProperty(
            name="Unfold Panel Category", description="Category in 3D View Toolbox where the Unfold panel is displayed",
            default="Paper", update=factory_update_addon_category(VIEW3D_PT_paper_model_tools, 'unfold_category'))
        export_category: bpy.props.StringProperty(
            name="Export Panel Category", description="Category in 3D View Toolbox where the Export panel is displayed",
            default="Paper", update=factory_update_addon_category(VIEW3D_PT_paper_model_settings, 'export_category'))
    
        def draw(self, context):
            sub = self.layout.column(align=True)
            sub.use_property_split = True
            sub.label(text="3D View Panel Category:")
            sub.prop(self, "unfold_category", text="Unfold Panel:")
            sub.prop(self, "export_category", text="Export Panel:")
    
    module_classes = (
        Unfold,
        ExportPaperModel,
        ClearAllSeams,
        SelectIsland,
        FaceList,
        IslandList,
        PaperModelSettings,
        DATA_PT_paper_model_islands,
    
        VIEW3D_PT_paper_model_tools,
        VIEW3D_PT_paper_model_settings,
    
        PaperAddonPreferences,
    
    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'})
        bpy.types.Mesh.paper_island_list = bpy.props.CollectionProperty(
            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'}, update=island_index_changed)
        bpy.types.TOPBAR_MT_file_export.append(menu_func_export)
        bpy.types.VIEW3D_MT_edit_mesh.prepend(menu_func_unfold)
    
        # Force an update on the panel category properties
        prefs = bpy.context.preferences.addons[__name__].preferences
        prefs.unfold_category = prefs.unfold_category
        prefs.export_category = prefs.export_category
    
        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__":
        register()