diff --git a/io_export_paper_model.py b/io_export_paper_model.py index f44038f10400723ad5e533c3a9b666ed7dcfceb7..5f8e70f5906e8b8f19130bf90d66ac998186a3b1 100644 --- a/io_export_paper_model.py +++ b/io_export_paper_model.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # This script is Free software. Please share and reuse. -# ♡2010-2019 Adam Dominec <adominec@gmail.com> +# ♡2010-2020 Adam Dominec <adominec@gmail.com> ## Code structure # This file consists of several components, in this order: @@ -12,8 +12,8 @@ bl_info = { "name": "Export Paper Model", "author": "Addam Dominec", - "version": (1, 1), - "blender": (2, 80, 0), + "version": (1, 2), + "blender": (2, 83, 0), "location": "File > Export > Paper Model", "warning": "", "description": "Export printable net of the active mesh", @@ -22,21 +22,16 @@ bl_info = { } # 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 +# * 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) +# * 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 @@ -127,7 +122,6 @@ def cage_fit(points, aspect): 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 @@ -143,9 +137,7 @@ def cage_fit(points, aspect): 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)) @@ -166,6 +158,16 @@ def create_blank_image(image_name, dimensions, alpha=1): return image +def store_rna_properties(*datablocks): + return [{prop.identifier: getattr(data, prop.identifier) for prop in data.rna_type.properties if not prop.is_readonly} for data in datablocks] + + +def apply_rna_properties(memory, *datablocks): + for recall, data in zip(memory, datablocks): + for key, value in recall.items(): + setattr(data, key, value) + + class UnfoldError(ValueError): def mesh_select(self): if len(self.args) > 1: @@ -217,7 +219,7 @@ class Unfolder: # 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 + Exporter = Svg if properties.file_format == 'SVG' else Pdf filepath = properties.filepath extension = properties.file_format.lower() filepath = bpy.path.ensure_ext(filepath, "." + extension) @@ -249,11 +251,9 @@ class Unfolder: 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 + recall = store_rna_properties(rd, bk, sce.cycles) rd.engine = 'CYCLES' - recall_pass = {p: getattr(bk, f"use_pass_{p}") for p in ('ambient_occlusion', 'color', 'diffuse', 'direct', 'emit', 'glossy', 'indirect', 'transmission')} - for p in recall_pass: + for p in ('ambient_occlusion', 'color', 'diffuse', 'direct', 'emit', 'glossy', 'indirect', 'transmission'): 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] @@ -276,13 +276,9 @@ class Unfolder: 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) + apply_rna_properties(recall, rd, bk, sce.cycles) - 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 = Exporter(properties) exporter.write(self.mesh, filepath) @@ -336,8 +332,9 @@ class Mesh: if not (null_edges or null_faces or twisted_faces or inverted_scale): return True if inverted_scale: - raise UnfoldError("The object is flipped inside-out.\n" - "You can use Object -> Apply -> Scale to fix it. Export failed.") + raise UnfoldError( + "The object is flipped inside-out.\n" + "You can use Object -> Apply -> Scale to fix it. Export failed.") disease = [("Remove Doubles", null_edges or null_faces), ("Triangulate", twisted_faces)] cure = " and ".join(s for s, k in disease if k) raise UnfoldError( @@ -514,14 +511,13 @@ class Mesh: 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 - point[:] = rot @ point + point.rotate(rot) 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)}") + # 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))) @@ -666,7 +662,8 @@ class Mesh: class Edge: """Wrapper for BPy Edge""" - __slots__ = ('data', 'va', 'vb', 'main_faces', 'uvedges', + __slots__ = ( + 'data', 'va', 'vb', 'main_faces', 'uvedges', 'vector', 'angle', 'is_main_cut', 'force_cut', 'priority', 'freestyle') @@ -689,10 +686,11 @@ class Edge: 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)) + + loops = self.data.link_loops if len(loops) == 2: self.main_faces = list(loops) elif len(loops) > 2: @@ -709,7 +707,7 @@ class Edge: self.angle = -3 # just a very sharp angle else: s = normal_a.cross(normal_b).dot(self.vector.normalized()) - s = max(min(s, 1.0), -1.0) # deal with rounding errors + 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) @@ -741,7 +739,8 @@ class Edge: class Island: """Part of the net to be exported""" - __slots__ = ('mesh', 'faces', 'edges', 'vertices', 'fake_vertices', 'boundary', 'markers', + __slots__ = ( + 'mesh', 'faces', 'edges', 'vertices', 'fake_vertices', 'boundary', 'markers', 'pos', 'bounding_box', 'image_path', 'embedded_image', 'number', 'label', 'abbreviation', 'title', @@ -803,6 +802,7 @@ class Island: 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 @@ -1081,7 +1081,7 @@ class Page: def __init__(self, num=1): self.islands = list() - self.name = "page{}".format(num) # TODO delete me + self.name = "page{}".format(num) # note: this is only used in svg files naming self.image_path = None @@ -1098,7 +1098,8 @@ 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', 'uvface', 'loop', + __slots__ = ( + 'va', 'vb', 'uvface', 'loop', 'min', 'max', 'bottom', 'top', 'neighbor_left', 'neighbor_right', 'sticker') @@ -1172,7 +1173,7 @@ class Arrow: class Sticker: """Mark in the document: sticker tab""" - __slots__ = ('bounds', 'center', 'rot', 'text', 'width', 'vertices') + __slots__ = ('bounds', 'center', 'points', 'rot', 'text', 'width') def __init__(self, uvedge, default_width, index, other: UVEdge): """Sticker is directly attached to the given UVEdge""" @@ -1217,12 +1218,12 @@ 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) - if v3.co != v4.co: - self.vertices = [second_vertex, v3, v4, first_vertex] + v3 = second_vertex.co + M.Matrix(((cos_b, -sin_b), (sin_b, cos_b))) @ edge * len_b / edge.length + v4 = first_vertex.co + M.Matrix(((-cos_a, -sin_a), (sin_a, -cos_a))) @ edge * len_a / edge.length + if v3 != v4: + self.points = [second_vertex.co, v3, v4, first_vertex.co] else: - self.vertices = [second_vertex, v3, first_vertex] + self.points = [second_vertex.co, v3, first_vertex.co] sin, cos = edge.y / edge.length, edge.x / edge.length self.rot = M.Matrix(((cos, -sin), (sin, cos))) @@ -1232,7 +1233,7 @@ class Sticker: else: self.text = index 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] + self.bounds = [v3, v4, self.center] if v3 != v4 else [v3, self.center] class NumberAlone: @@ -1251,19 +1252,22 @@ class NumberAlone: self.bounds = [self.center] -class SVG: +def init_exporter(self, properties): + self.page_size = M.Vector((properties.output_size_x, properties.output_size_y)) + self.style = properties.style + margin = properties.output_margin + self.margin = M.Vector((margin, margin)) + self.pure_net = (properties.output_type == 'NONE') + self.do_create_stickers = properties.do_create_stickers + self.text_size = properties.sticker_width + self.angle_epsilon = properties.angle_epsilon + + +class Svg: """Simple SVG exporter""" - def __init__(self, page_size: M.Vector, style, margin, pure_net=True, angle_epsilon=0.01): - """Initialize document settings. - page_size: document dimensions in meters - pure_net: if True, do not use image""" - self.page_size = page_size - self.pure_net = pure_net - self.style = style - self.margin = margin - self.text_size = 12 - self.angle_epsilon = angle_epsilon + def __init__(self, properties): + init_exporter(self, properties) @classmethod def encode_image(cls, bpy_image): @@ -1275,10 +1279,9 @@ class SVG: bpy_image.save() return base64.encodebytes(open(filename, "rb").read()).decode('ascii') - def format_vertex(self, vector, pos=M.Vector((0, 0))): + def format_vertex(self, vector): """Return a string with both coordinates of the given vertex.""" - x, y = vector + pos - return "{:.6f} {:.6f}".format((x + self.margin) * 1000, (self.page_size.y - y - self.margin) * 1000) + return "{:.6f} {:.6f}".format((vector.x + self.margin.x) * 1000, (self.page_size.y - vector.y - self.margin.y) * 1000) def write(self, mesh, filename): """Write data to a file given by its name.""" @@ -1306,7 +1309,7 @@ class SVG: styleargs = { name: format_color(getattr(self.style, name)) for name in ( "outer_color", "outbg_color", "convex_color", "concave_color", "freestyle_color", - "inbg_color", "sticker_fill", "text_color")} + "inbg_color", "sticker_color", "text_color")} styleargs.update({ name: format_style[getattr(self.style, name)] for name in ("outer_style", "convex_style", "concave_style", "freestyle_style")}) @@ -1315,7 +1318,7 @@ class SVG: ("outer_alpha", "outer_color"), ("outbg_alpha", "outbg_color"), ("convex_alpha", "convex_color"), ("concave_alpha", "concave_color"), ("freestyle_alpha", "freestyle_color"), - ("inbg_alpha", "inbg_color"), ("sticker_alpha", "sticker_fill"), + ("inbg_alpha", "inbg_color"), ("sticker_alpha", "sticker_color"), ("text_alpha", "text_color"))}) styleargs.update({ name: getattr(self.style, name) * self.style.line_width * 1000 for name in @@ -1328,9 +1331,9 @@ class SVG: if page.image_path: print( self.image_linked_tag.format( - pos="{0:.6f} {0:.6f}".format(self.margin*1000), - width=(self.page_size.x - 2 * self.margin)*1000, - height=(self.page_size.y - 2 * self.margin)*1000, + pos="{0:.6f} {0:.6f}".format(self.margin.x*1000), + width=(self.page_size.x - 2 * self.margin.x)*1000, + height=(self.page_size.y - 2 * self.margin.y)*1000, path=path_convert(page.image_path)), file=f) if len(page.islands) > 1: @@ -1359,20 +1362,20 @@ class SVG: print( self.text_tag.format( size=1000 * self.text_size, - x=1000 * (island.bounding_box.x*0.5 + island.pos.x + self.margin), - y=1000 * (self.page_size.y - island.pos.y - self.margin - 0.2 * self.text_size), + x=1000 * (island.bounding_box.x*0.5 + island.pos.x + self.margin.x), + y=1000 * (self.page_size.y - island.pos.y - self.margin.y - 0.2 * self.text_size), label=island.title), file=f) - data_markers, data_stickerfill, data_outer, data_convex, data_concave, data_freestyle = (list() for i in range(6)) + data_markers, data_stickerfill = list(), list() for marker in island.markers: if isinstance(marker, Sticker): data_stickerfill.append("M {} Z".format( - line_through(self.format_vertex(vertex.co, island.pos) for vertex in marker.vertices))) + line_through(self.format_vertex(co + island.pos) for co in marker.points))) if marker.text: data_markers.append(self.text_transformed_tag.format( label=marker.text, - pos=self.format_vertex(marker.center, island.pos), + pos=self.format_vertex(marker.center + island.pos), mat=format_matrix(marker.rot), size=marker.width * 1000)) elif isinstance(marker, Arrow): @@ -1380,29 +1383,30 @@ class SVG: 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), + arrow_pos=self.format_vertex(marker.center + island.pos), scale=size, - pos=self.format_vertex(position, island.pos - marker.size*M.Vector((0, 0.4))), + pos=self.format_vertex(position + island.pos - marker.size*M.Vector((0, 0.4))), mat=format_matrix(size * marker.rot))) elif isinstance(marker, NumberAlone): data_markers.append(self.text_transformed_tag.format( label=marker.text, - pos=self.format_vertex(marker.center, island.pos), + pos=self.format_vertex(marker.center + island.pos), mat=format_matrix(marker.rot), size=marker.size * 1000)) - if data_stickerfill and self.style.sticker_fill[3] > 0: + if data_stickerfill and self.style.sticker_color[3] > 0: print("<path class='sticker' d='", rows(data_stickerfill), "'/>", file=f) + data_outer, data_convex, data_concave, data_freestyle = (list() for i in range(4)) outer_edges = set(island.boundary) while outer_edges: data_loop = list() uvedge = outer_edges.pop() while 1: if uvedge.sticker: - data_loop.extend(self.format_vertex(vertex.co, island.pos) for vertex in uvedge.sticker.vertices[1:]) + data_loop.extend(self.format_vertex(co + island.pos) for co in uvedge.sticker.points[1:]) else: vertex = uvedge.vb if uvedge.uvface.flipped else uvedge.va - data_loop.append(self.format_vertex(vertex.co, island.pos)) + data_loop.append(self.format_vertex(vertex.co + island.pos)) uvedge = uvedge.neighbor_right try: outer_edges.remove(uvedge) @@ -1416,7 +1420,7 @@ class SVG: if edge.is_cut(uvedge.uvface.face) and not uvedge.sticker: continue data_uvedge = "M {}".format( - line_through(self.format_vertex(vertex.co, island.pos) for vertex in (uvedge.va, uvedge.vb))) + line_through(self.format_vertex(v.co + island.pos) for v in (uvedge.va, uvedge.vb))) if edge.freestyle: data_freestyle.append(data_uvedge) # each uvedge is in two opposite-oriented variants; we want to add each only once @@ -1507,7 +1511,7 @@ class SVG: stroke-width: {inbg_width:.2} }} path.sticker {{ - fill: {sticker_fill}; + fill: {sticker_color}; stroke: none; fill-opacity: {sticker_alpha:.2}; }} @@ -1526,7 +1530,7 @@ class SVG: </style>""" -class PDF: +class Pdf: """Simple PDF exporter""" mm_to_pt = 72 / 25.4 @@ -1536,16 +1540,34 @@ class PDF: 667: '&ABEKPSVXY\x8a\x9fĂ€ĂĂ‚ĂĂ„Ă…ĂÉÊËÝÞ', 722: 'CDHNRUwÇĂÑÙÚÛÜ', 737: '©®', 778: 'GOQÒÓÔÕÖĂ', 833: 'Mm¼½¾', 889: '%æ', 944: 'W\x9c', 1000: '\x85\x89\x8c\x97\x99Æ', 1015: '@', } character_width = {c: value for (value, chars) in character_width_packed.items() for c in chars} - def __init__(self, page_size: M.Vector, style, margin, pure_net=True, angle_epsilon=0.01): - self.page_size = page_size - self.style = style - self.margin = M.Vector((margin, margin)) - self.pure_net = pure_net - self.angle_epsilon = angle_epsilon + def __init__(self, properties): + init_exporter(self, properties) + self.styles = dict() def text_width(self, text, scale=None): return (scale or self.text_size) * sum(self.character_width.get(c, 556) for c in text) / 1000 + def styling(self, name, do_stroke=True): + s, m, l = (length * self.style.line_width * 1000 for length in (1, 4, 9)) + format_style = {'SOLID': [], 'DOT': [s, m], 'DASH': [m, l], 'LONGDASH': [l, m], 'DASHDOT': [l, m, s, m]} + style, color, width = (getattr(self.style, f"{name}_{arg}", None) for arg in ("style", "color", "width")) + style = style or 'SOLID' + result = ["q"] + if do_stroke: + result += [ + "[ " + " ".join("{:.3f}".format(num) for num in format_style[style]) + " ] 0 d", + "{0:.3f} {1:.3f} {2:.3f} RG".format(*color), + "{:.3f} w".format(self.style.line_width * 1000 * width), + ] + else: + result.append("{0:.3f} {1:.3f} {2:.3f} rg".format(*color)) + if color[3] < 1: + style_name = "R{:03}".format(round(1000 * color[3])) + result.append("/{} gs".format(style_name)) + if style_name not in self.styles: + self.styles[style_name] = {"CA": color[3], "ca": color[3]} + return result + @classmethod def encode_image(cls, bpy_image): data = bytes(int(255 * px) for (i, px) in enumerate(bpy_image.pixels) if i % 4 != 3) @@ -1557,10 +1579,12 @@ class PDF: def write(self, mesh, filename): def format_dict(obj, refs=tuple()): - return "<< " + "".join("/{} {}\n".format(key, format_value(value, refs)) for (key, value) in obj.items()) + ">>" + content = "".join("/{} {}\n".format(key, format_value(value, refs)) for (key, value) in obj.items()) + return f"<< {content} >>" def line_through(seq): - return "".join("{0.x:.6f} {0.y:.6f} {1} ".format(1000*v.co, c) for (v, c) in zip(seq, chain("m", repeat("l")))) + fmt = "{0.x:.6f} {0.y:.6f} {1} ".format + return "".join(fmt(1000*co, cmd) for (co, cmd) in zip(seq, chain("m", repeat("l")))) def format_value(value, refs=tuple()): if value in refs: @@ -1579,82 +1603,64 @@ class PDF: return "/{}".format(value) # this script can output only PDF names, no strings def write_object(index, obj, refs, f, stream=None): - byte_count = f.write("{} 0 obj\n".format(index)) + byte_count = f.write("{} 0 obj\n".format(index).encode()) if type(obj) is not dict: stream, obj = obj, dict() elif "stream" in obj: stream = obj.pop("stream") if stream: - if True or type(stream) is bytes: - obj["Filter"] = ["ASCII85Decode", "FlateDecode"] - stream = encode(stream) + obj["Filter"] = "FlateDecode" + stream = encode(stream) obj["Length"] = len(stream) - byte_count += f.write(format_dict(obj, refs)) + byte_count += f.write(format_dict(obj, refs).encode()) if stream: - byte_count += f.write("\nstream\n") + byte_count += f.write(b"\nstream\n") byte_count += f.write(stream) - byte_count += f.write("\nendstream") - return byte_count + f.write("\nendobj\n") + byte_count += f.write(b"\nendstream") + return byte_count + f.write(b"\nendobj\n") def encode(data): - from base64 import a85encode from zlib import compress if hasattr(data, "encode"): data = data.encode() - return a85encode(compress(data), adobe=True, wrapcol=250)[2:].decode() + return compress(data) page_size_pt = 1000 * self.mm_to_pt * self.page_size + reset_style = ["Q"] # graphic command for later use root = {"Type": "Pages", "MediaBox": [0, 0, page_size_pt.x, page_size_pt.y], "Kids": list()} catalog = {"Type": "Catalog", "Pages": root} font = { "Type": "Font", "Subtype": "Type1", "Name": "F1", "BaseFont": "Helvetica", "Encoding": "MacRomanEncoding"} - - dl = [length * self.style.line_width * 1000 for length in (1, 4, 9)] - format_style = { - 'SOLID': list(), 'DOT': [dl[0], dl[1]], 'DASH': [dl[1], dl[2]], - 'LONGDASH': [dl[2], dl[1]], 'DASHDOT': [dl[2], dl[1], dl[0], dl[1]]} - styles = { - "Gtext": {"ca": self.style.text_color[3], "Font": [font, 1000 * self.text_size]}, - "Gsticker": {"ca": self.style.sticker_fill[3]}} - for name in ("outer", "convex", "concave", "freestyle"): - gs = { - "LW": self.style.line_width * 1000 * getattr(self.style, name + "_width"), - "CA": getattr(self.style, name + "_color")[3], - "D": [format_style[getattr(self.style, name + "_style")], 0]} - styles["G" + name] = gs - for name in ("outbg", "inbg"): - gs = { - "LW": self.style.line_width * 1000 * getattr(self.style, name + "_width"), - "CA": getattr(self.style, name + "_color")[3], - "D": [format_style['SOLID'], 0]} - styles["G" + name] = gs - objects = [root, catalog, font] - objects.extend(styles.values()) for page in mesh.pages: commands = ["{0:.6f} 0 0 {0:.6f} 0 0 cm".format(self.mm_to_pt)] - resources = {"Font": {"F1": font}, "ExtGState": styles, "XObject": dict()} + resources = {"Font": {"F1": font}, "ExtGState": self.styles, "ProcSet": ["PDF"]} + if any(island.embedded_image for island in page.islands): + resources["XObject"] = dict() + resources["ProcSet"].append("ImageC") for island in page.islands: commands.append("q 1 0 0 1 {0.x:.6f} {0.y:.6f} cm".format(1000*(self.margin + island.pos))) if island.embedded_image: - identifier = "Im{}".format(len(resources["XObject"]) + 1) + identifier = "I{}".format(len(resources["XObject"]) + 1) commands.append(self.command_image.format(1000 * island.bounding_box, identifier)) objects.append(island.embedded_image) resources["XObject"][identifier] = island.embedded_image if island.title: + commands += self.styling("text", do_stroke=False) commands.append(self.command_label.format( size=1000*self.text_size, x=500 * (island.bounding_box.x - self.text_width(island.title)), y=1000 * 0.2 * self.text_size, label=island.title)) + commands += reset_style - data_markers, data_stickerfill, data_outer, data_convex, data_concave, data_freestyle = (list() for i in range(6)) + data_markers, data_stickerfill = list(), list() for marker in island.markers: if isinstance(marker, Sticker): - data_stickerfill.append(line_through(marker.vertices) + "f") + data_stickerfill.append(line_through(marker.points) + "f") if marker.text: data_markers.append(self.command_sticker.format( label=marker.text, @@ -1678,16 +1684,17 @@ class PDF: mat=marker.rot, size=1000*marker.size)) + data_outer, data_convex, data_concave, data_freestyle = (list() for i in range(4)) outer_edges = set(island.boundary) while outer_edges: data_loop = list() uvedge = outer_edges.pop() while 1: if uvedge.sticker: - data_loop.extend(uvedge.sticker.vertices[1:]) + data_loop.extend(uvedge.sticker.points[1:]) else: vertex = uvedge.vb if uvedge.uvface.flipped else uvedge.va - data_loop.append(vertex) + data_loop.append(vertex.co) uvedge = uvedge.neighbor_right try: outer_edges.remove(uvedge) @@ -1699,7 +1706,7 @@ class PDF: 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" + data_uvedge = line_through((uvedge.va.co, uvedge.vb.co)) + "S" if edge.freestyle: data_freestyle.append(data_uvedge) # each uvedge exists in two opposite-oriented variants; we want to add each only once @@ -1711,56 +1718,52 @@ class PDF: if island.is_inside_out: data_convex, data_concave = data_concave, data_convex - if data_stickerfill and self.style.sticker_fill[3] > 0: - commands.append("/Gsticker gs {0[0]:.3f} {0[1]:.3f} {0[2]:.3f} rg".format(self.style.sticker_fill)) - commands.extend(data_stickerfill) + if data_stickerfill and self.style.sticker_color[3] > 0: + commands += chain(self.styling("sticker", do_stroke=False), data_stickerfill, reset_style) if data_freestyle: - commands.append("/Gfreestyle gs {0[0]:.3f} {0[1]:.3f} {0[2]:.3f} RG".format(self.style.freestyle_color)) - commands.extend(data_freestyle) + commands += chain(self.styling("freestyle"), data_freestyle, reset_style) if (data_convex or data_concave) and not self.pure_net and self.style.use_inbg: - commands.append("/Ginbg gs {0[0]:.3f} {0[1]:.3f} {0[2]:.3f} RG".format(self.style.inbg_color)) - commands.extend(chain(data_convex, data_concave)) + commands += chain(self.styling("inbg"), data_convex, data_concave, reset_style) if data_convex: - commands.append("/Gconvex gs {0[0]:.3f} {0[1]:.3f} {0[2]:.3f} RG".format(self.style.convex_color)) - commands.extend(data_convex) + commands += chain(self.styling("convex"), data_convex, reset_style) if data_concave: - commands.append("/Gconcave gs {0[0]:.3f} {0[1]:.3f} {0[2]:.3f} RG".format(self.style.concave_color)) - commands.extend(data_concave) + commands += chain(self.styling("concave"), data_concave, reset_style) if data_outer: if not self.pure_net and self.style.use_outbg: - commands.append("/Goutbg gs {0[0]:.3f} {0[1]:.3f} {0[2]:.3f} RG".format(self.style.outbg_color)) - commands.extend(data_outer) - commands.append("/Gouter gs {0[0]:.3f} {0[1]:.3f} {0[2]:.3f} RG".format(self.style.outer_color)) - commands.extend(data_outer) - commands.append("/Gtext gs {0[0]:.3f} {0[1]:.3f} {0[2]:.3f} rg".format(self.style.text_color)) - commands.extend(data_markers) - commands.append("Q") + commands += chain(self.styling("outbg"), data_outer, reset_style) + commands += chain(self.styling("outer"), data_outer, reset_style) + if data_markers: + commands += chain(self.styling("text", do_stroke=False), data_markers, reset_style) + commands += reset_style # return from island to page coordinates content = "\n".join(commands) page = {"Type": "Page", "Parent": root, "Contents": content, "Resources": resources} root["Kids"].append(page) - objects.extend((page, content)) + objects += page, content + objects.extend(self.styles.values()) root["Count"] = len(root["Kids"]) - with open(filename, "w+") as f: + with open(filename, "wb+") as f: xref_table = list() - position = f.write("%PDF-1.4\n") + position = 0 + position += f.write(b"%PDF-1.4\n") + position += f.write(b"%\xde\xad\xbe\xef\n") for index, obj in enumerate(objects, 1): xref_table.append(position) position += write_object(index, obj, objects, f) xref_pos = position - f.write("xref_table\n0 {}\n".format(len(xref_table) + 1)) - f.write("{:010} {:05} f\n".format(0, 65536)) + f.write("xref\n0 {}\n".format(len(xref_table) + 1).encode()) + f.write("{:010} {:05} f\r\n".format(0, 65535).encode()) for position in xref_table: - f.write("{:010} {:05} n\n".format(position, 0)) - f.write("trailer\n") - f.write(format_dict({"Size": len(xref_table), "Root": catalog}, objects)) - f.write("\nstartxref\n{}\n%%EOF\n".format(xref_pos)) + f.write("{:010} {:05} n\r\n".format(position, 0).encode()) + f.write(b"trailer\n") + f.write(format_dict({"Size": len(xref_table) + 1, "Root": catalog}, objects).encode()) + f.write("\nstartxref\n{}\n%%EOF\n".format(xref_pos).encode()) - command_label = "/Gtext gs BT {x:.6f} {y:.6f} Td ({label}) Tj ET" + command_label = "q /F1 {size:.6f} Tf BT {x:.6f} {y:.6f} Td ({label}) Tj ET Q" command_image = "q {0.x:.6f} 0 0 {0.y:.6f} 0 0 cm 1 0 0 -1 0 1 cm /{1} Do Q" - command_sticker = "q {mat[0][0]:.6f} {mat[1][0]:.6f} {mat[0][1]:.6f} {mat[1][1]:.6f} {pos.x:.6f} {pos.y:.6f} cm BT {align:.6f} 0 Td /F1 {size:.6f} Tf ({label}) Tj ET Q" - command_arrow = "q BT {pos.x:.6f} {pos.y:.6f} Td /F1 {size:.6f} Tf ({index}) Tj ET {mat[0][0]:.6f} {mat[1][0]:.6f} {mat[0][1]:.6f} {mat[1][1]:.6f} {arrow_pos.x:.6f} {arrow_pos.y:.6f} cm 0 0 m 1 -1 l 0 -0.25 l -1 -1 l f Q" - command_number = "q {mat[0][0]:.6f} {mat[1][0]:.6f} {mat[0][1]:.6f} {mat[1][1]:.6f} {pos.x:.6f} {pos.y:.6f} cm BT /F1 {size:.6f} Tf ({label}) Tj ET Q" + command_sticker = "q /F1 {size:.6f} Tf {mat[0][0]:.6f} {mat[1][0]:.6f} {mat[0][1]:.6f} {mat[1][1]:.6f} {pos.x:.6f} {pos.y:.6f} cm BT {align:.6f} 0 Td ({label}) Tj ET Q" + command_arrow = "q /F1 {size:.6f} Tf BT {pos.x:.6f} {pos.y:.6f} Td ({index}) Tj ET {mat[0][0]:.6f} {mat[1][0]:.6f} {mat[0][1]:.6f} {mat[1][1]:.6f} {arrow_pos.x:.6f} {arrow_pos.y:.6f} cm 0 0 m 1 -1 l 0 -0.25 l -1 -1 l f Q" + command_number = "q /F1 {size:.6f} Tf {mat[0][0]:.6f} {mat[1][0]:.6f} {mat[0][1]:.6f} {mat[1][1]:.6f} {pos.x:.6f} {pos.y:.6f} cm BT ({label}) Tj ET Q" class Unfold(bpy.types.Operator): @@ -1957,7 +1960,7 @@ class PaperModelStyle(bpy.types.PropertyGroup): name="Inner Highlight Thickness", description="Relative thickness of the highlighting lines", default=2, min=0, soft_max=10, precision=1, step=10, subtype='FACTOR') - sticker_fill: bpy.props.FloatVectorProperty( + sticker_color: bpy.props.FloatVectorProperty( name="Tabs Fill", description="Fill color of sticking tabs", default=(0.9, 0.9, 0.9, 1.0), min=0, max=1, subtype='COLOR', size=4) text_color: bpy.props.FloatVectorProperty( @@ -1972,6 +1975,8 @@ class ExportPaperModel(bpy.types.Operator): bl_idname = "export_mesh.paper_model" bl_label = "Export Paper Model" bl_description = "Export the selected object's net and optionally bake its texture" + bl_options = {'PRESET'} + filepath: bpy.props.StringProperty( name="File Path", description="Target file to save the SVG", options={'SKIP_SAVE'}) filename: bpy.props.StringProperty( @@ -2034,13 +2039,16 @@ class ExportPaperModel(bpy.types.Operator): 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", + 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", + 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", + 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) @@ -2058,8 +2066,9 @@ class ExportPaperModel(bpy.types.Operator): 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: + 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): @@ -2106,14 +2115,7 @@ class ExportPaperModel(bpy.types.Operator): def draw(self, context): layout = self.layout - layout.prop(self.properties, "do_create_uvmap") - - row = layout.row(align=True) - row.menu("VIEW3D_MT_paper_model_presets", text=bpy.types.VIEW3D_MT_paper_model_presets.bl_label) - row.operator("export_mesh.paper_model_preset_add", text="", icon='ADD') - row.operator("export_mesh.paper_model_preset_add", text="", icon='REMOVE').remove_active = True - layout.prop(self.properties, "scale", text="Scale: 1/") scale_ratio = self.get_scale_ratio(context.scene) if scale_ratio > 1: @@ -2153,7 +2155,7 @@ 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_layers) == 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 != 'CYCLES' and self.output_type != 'NONE': col.label(text="Cycles will be used for texture baking.", icon='ERROR') @@ -2206,7 +2208,7 @@ class ExportPaperModel(bpy.types.Operator): sub.prop(self.style, "inbg_width", text="Relative width") col = box.column() col.active = self.do_create_stickers - col.prop(self.style, "sticker_fill") + col.prop(self.style, "sticker_color") box.prop(self.style, "text_color") @@ -2271,31 +2273,6 @@ class SelectIsland(bpy.types.Operator): return {'FINISHED'} -class VIEW3D_MT_paper_model_presets(bpy.types.Menu): - bl_label = "Paper Model Presets" - preset_subdir = "export_mesh" - preset_operator = "script.execute_preset" - draw = bpy.types.Menu.draw_preset - - -class AddPresetPaperModel(bl_operators.presets.AddPresetBase, bpy.types.Operator): - """Add or remove a Paper Model Preset""" - bl_idname = "export_mesh.paper_model_preset_add" - bl_label = "Add Paper Model Preset" - preset_menu = "VIEW3D_MT_paper_model_presets" - preset_subdir = "export_mesh" - preset_defines = ["op = bpy.context.active_operator"] - - @property - def preset_values(self): - op = bpy.ops.export_mesh.paper_model - 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 - if not (prop.is_hidden or prop.is_skip_save or prop_id in blacklist)] - - class VIEW3D_PT_paper_model_tools(bpy.types.Panel): bl_space_type = 'VIEW_3D' bl_region_type = 'UI' @@ -2332,7 +2309,10 @@ class VIEW3D_PT_paper_model_settings(bpy.types.Panel): layout.operator("export_mesh.paper_model") props = sce.paper_model - layout.prop(props, "scale", text="Model Scale: 1/") + 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() @@ -2459,9 +2439,39 @@ class PaperModelSettings(bpy.types.PropertyGroup): 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) + 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 = ( @@ -2469,14 +2479,13 @@ module_classes = ( ExportPaperModel, ClearAllSeams, SelectIsland, - AddPresetPaperModel, FaceList, IslandList, PaperModelSettings, - VIEW3D_MT_paper_model_presets, DATA_PT_paper_model_islands, VIEW3D_PT_paper_model_tools, VIEW3D_PT_paper_model_settings, + PaperAddonPreferences, ) @@ -2493,6 +2502,10 @@ def register(): 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 def unregister():