sun_position: fix warning from deleted prop in User Preferences
[blender-addons.git] / io_export_paper_model.py
blob083c13d4b93734a3bc4efb167c74902424c819af
1 # -*- coding: utf-8 -*-
2 # This script is Free software. Please share and reuse.
3 # ♡2010-2019 Adam Dominec <adominec@gmail.com>
5 ## Code structure
6 # This file consists of several components, in this order:
7 # * Unfolding and baking
8 # * Export (SVG or PDF)
9 # * User interface
10 # During the unfold process, the mesh is mirrored into a 2D structure: UVFace, UVEdge, UVVertex.
12 bl_info = {
13 "name": "Export Paper Model",
14 "author": "Addam Dominec",
15 "version": (1, 1),
16 "blender": (2, 80, 0),
17 "location": "File > Export > Paper Model",
18 "warning": "",
19 "description": "Export printable net of the active mesh",
20 "category": "Import-Export",
21 "wiki_url": "https://docs.blender.org/manual/en/dev/addons/"
22 "import_export/paper_model.html",
25 # Task: split into four files (SVG and PDF separately)
26 # does any portion of baking belong into the export module?
27 # sketch out the code for GCODE and two-sided export
29 # TODO:
30 # sanitize the constructors Edge, Face, UVFace so that they don't edit their parent object
31 # The Exporter classes should take parameters as a whole pack, and parse it themselves
32 # remember objects selected before baking (except selected to active)
33 # add 'estimated number of pages' to the export UI
34 # QuickSweepline is very much broken -- it throws GeometryError for all nets > ~15 faces
35 # rotate islands to minimize area -- and change that only if necessary to fill the page size
36 # Sticker.vertices should be of type Vector
38 # check conflicts in island naming and either:
39 # * append a number to the conflicting names or
40 # * enumerate faces uniquely within all islands of the same name (requires a check that both label and abbr. equals)
42 import bpy
43 import bl_operators
44 import bmesh
45 import mathutils as M
46 from re import compile as re_compile
47 from itertools import chain, repeat, product, combinations
48 from math import pi, ceil, asin, atan2
49 import os.path as os_path
51 default_priority_effect = {
52 'CONVEX': 0.5,
53 'CONCAVE': 1,
54 'LENGTH': -0.05
57 global_paper_sizes = [
58 ('USER', "User defined", "User defined paper size"),
59 ('A4', "A4", "International standard paper size"),
60 ('A3', "A3", "International standard paper size"),
61 ('US_LETTER', "Letter", "North American paper size"),
62 ('US_LEGAL', "Legal", "North American paper size")
66 def first_letters(text):
67 """Iterator over the first letter of each word"""
68 for match in first_letters.pattern.finditer(text):
69 yield text[match.start()]
70 first_letters.pattern = re_compile("((?<!\w)\w)|\d")
73 def is_upsidedown_wrong(name):
74 """Tell if the string would get a different meaning if written upside down"""
75 chars = set(name)
76 mistakable = set("69NZMWpbqd")
77 rotatable = set("80oOxXIl").union(mistakable)
78 return chars.issubset(rotatable) and not chars.isdisjoint(mistakable)
81 def pairs(sequence):
82 """Generate consecutive pairs throughout the given sequence; at last, it gives elements last, first."""
83 i = iter(sequence)
84 previous = first = next(i)
85 for this in i:
86 yield previous, this
87 previous = this
88 yield this, first
91 def fitting_matrix(v1, v2):
92 """Get a matrix that rotates v1 to the same direction as v2"""
93 return (1 / v1.length_squared) * M.Matrix((
94 (v1.x*v2.x + v1.y*v2.y, v1.y*v2.x - v1.x*v2.y),
95 (v1.x*v2.y - v1.y*v2.x, v1.x*v2.x + v1.y*v2.y)))
98 def z_up_matrix(n):
99 """Get a rotation matrix that aligns given vector upwards."""
100 b = n.xy.length
101 s = n.length
102 if b > 0:
103 return M.Matrix((
104 (n.x*n.z/(b*s), n.y*n.z/(b*s), -b/s),
105 (-n.y/b, n.x/b, 0),
106 (0, 0, 0)
108 else:
109 # no need for rotation
110 return M.Matrix((
111 (1, 0, 0),
112 (0, (-1 if n.z < 0 else 1), 0),
113 (0, 0, 0)
117 def cage_fit(points, aspect):
118 """Find rotation for a minimum bounding box with a given aspect ratio
119 returns a tuple: rotation angle, box height"""
120 def guesses(polygon):
121 """Yield all tentative extrema of the bounding box height wrt. polygon rotation"""
122 for a, b in pairs(polygon):
123 if a == b:
124 continue
125 direction = (b - a).normalized()
126 sinx, cosx = -direction.y, direction.x
127 rot = M.Matrix(((cosx, -sinx), (sinx, cosx)))
128 rot_polygon = [rot @ p for p in polygon]
129 left, right = [fn(rot_polygon, key=lambda p: p.to_tuple()) for fn in (min, max)]
130 bottom, top = [fn(rot_polygon, key=lambda p: p.yx.to_tuple()) for fn in (min, max)]
131 #print(f"{rot_polygon.index(left)}-{rot_polygon.index(right)}, {rot_polygon.index(bottom)}-{rot_polygon.index(top)}")
132 horz, vert = right - left, top - bottom
133 # solve (rot * a).y == (rot * b).y
134 yield max(aspect * horz.x, vert.y), sinx, cosx
135 # solve (rot * a).x == (rot * b).x
136 yield max(horz.x, aspect * vert.y), -cosx, sinx
137 # solve aspect * (rot * (right - left)).x == (rot * (top - bottom)).y
138 # using substitution t = tan(rot / 2)
139 q = aspect * horz.x - vert.y
140 r = vert.x + aspect * horz.y
141 t = ((r**2 + q**2)**0.5 - r) / q if q != 0 else 0
142 t = -1 / t if abs(t) > 1 else t # pick the positive solution
143 siny, cosy = 2 * t / (1 + t**2), (1 - t**2) / (1 + t**2)
144 rot = M.Matrix(((cosy, -siny), (siny, cosy)))
145 for p in rot_polygon:
146 p[:] = rot @ p # note: this also modifies left, right, bottom, top
147 #print(f"solve {aspect * (right - left).x} == {(top - bottom).y} with aspect = {aspect}")
148 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):
149 #print(f"yield {max(aspect * (right - left).x, (top - bottom).y)}")
150 yield max(aspect * (right - left).x, (top - bottom).y), sinx*cosy + cosx*siny, cosx*cosy - sinx*siny
151 polygon = [points[i] for i in M.geometry.convex_hull_2d(points)]
152 height, sinx, cosx = min(guesses(polygon))
153 return atan2(sinx, cosx), height
156 def create_blank_image(image_name, dimensions, alpha=1):
157 """Create a new image and assign white color to all its pixels"""
158 image_name = image_name[:64]
159 width, height = int(dimensions.x), int(dimensions.y)
160 image = bpy.data.images.new(image_name, width, height, alpha=True)
161 if image.users > 0:
162 raise UnfoldError(
163 "There is something wrong with the material of the model. "
164 "Please report this on the BlenderArtists forum. Export failed.")
165 image.pixels = [1, 1, 1, alpha] * (width * height)
166 image.file_format = 'PNG'
167 return image
170 class UnfoldError(ValueError):
171 def mesh_select(self):
172 if len(self.args) > 1:
173 elems, bm = self.args[1:3]
174 bpy.context.tool_settings.mesh_select_mode = [bool(elems[key]) for key in ("verts", "edges", "faces")]
175 for elem in chain(bm.verts, bm.edges, bm.faces):
176 elem.select = False
177 for elem in chain(*elems.values()):
178 elem.select_set(True)
179 bmesh.update_edit_mesh(bpy.context.object.data, False, False)
182 class Unfolder:
183 def __init__(self, ob):
184 self.do_create_uvmap = False
185 bm = bmesh.from_edit_mesh(ob.data)
186 self.mesh = Mesh(bm, ob.matrix_world)
187 self.mesh.check_correct()
189 def __del__(self):
190 if not self.do_create_uvmap:
191 self.mesh.delete_uvmap()
193 def prepare(self, cage_size=None, priority_effect=default_priority_effect, scale=1, limit_by_page=False):
194 """Create the islands of the net"""
195 self.mesh.generate_cuts(cage_size / scale if limit_by_page and cage_size else None, priority_effect)
196 self.mesh.finalize_islands(cage_size or M.Vector((1, 1)))
197 self.mesh.enumerate_islands()
198 self.mesh.save_uv()
200 def copy_island_names(self, island_list):
201 """Copy island label and abbreviation from the best matching island in the list"""
202 orig_islands = [{face.id for face in item.faces} for item in island_list]
203 matching = list()
204 for i, island in enumerate(self.mesh.islands):
205 islfaces = {face.index for face in island.faces}
206 matching.extend((len(islfaces.intersection(item)), i, j) for j, item in enumerate(orig_islands))
207 matching.sort(reverse=True)
208 available_new = [True for island in self.mesh.islands]
209 available_orig = [True for item in island_list]
210 for face_count, i, j in matching:
211 if available_new[i] and available_orig[j]:
212 available_new[i] = available_orig[j] = False
213 self.mesh.islands[i].label = island_list[j].label
214 self.mesh.islands[i].abbreviation = island_list[j].abbreviation
216 def save(self, properties):
217 """Export the document"""
218 # Note about scale: input is directly in blender length
219 # Mesh.scale_islands multiplies everything by a user-defined ratio
220 # exporters (SVG or PDF) multiply everything by 1000 (output in millimeters)
221 Exporter = SVG if properties.file_format == 'SVG' else PDF
222 filepath = properties.filepath
223 extension = properties.file_format.lower()
224 filepath = bpy.path.ensure_ext(filepath, "." + extension)
225 # page size in meters
226 page_size = M.Vector((properties.output_size_x, properties.output_size_y))
227 # printable area size in meters
228 printable_size = page_size - 2 * properties.output_margin * M.Vector((1, 1))
229 unit_scale = bpy.context.scene.unit_settings.scale_length
230 ppm = properties.output_dpi * 100 / 2.54 # pixels per meter
232 # after this call, all dimensions will be in meters
233 self.mesh.scale_islands(unit_scale/properties.scale)
234 if properties.do_create_stickers:
235 self.mesh.generate_stickers(properties.sticker_width, properties.do_create_numbers)
236 elif properties.do_create_numbers:
237 self.mesh.generate_numbers_alone(properties.sticker_width)
239 text_height = properties.sticker_width if (properties.do_create_numbers and len(self.mesh.islands) > 1) else 0
240 # title height must be somewhat larger that text size, glyphs go below the baseline
241 self.mesh.finalize_islands(printable_size, title_height=text_height * 1.2)
242 self.mesh.fit_islands(printable_size)
244 if properties.output_type != 'NONE':
245 # bake an image and save it as a PNG to disk or into memory
246 image_packing = properties.image_packing if properties.file_format == 'SVG' else 'ISLAND_EMBED'
247 use_separate_images = image_packing in ('ISLAND_LINK', 'ISLAND_EMBED')
248 self.mesh.save_uv(cage_size=printable_size, separate_image=use_separate_images)
250 sce = bpy.context.scene
251 rd = sce.render
252 bk = rd.bake
253 # TODO: do we really need all this recollection?
254 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
255 rd.engine = 'CYCLES'
256 recall_pass = {p: getattr(bk, f"use_pass_{p}") for p in ('ambient_occlusion', 'color', 'diffuse', 'direct', 'emit', 'glossy', 'indirect', 'subsurface', 'transmission')}
257 for p in recall_pass:
258 setattr(bk, f"use_pass_{p}", (properties.output_type != 'TEXTURE'))
259 lookup = {'TEXTURE': 'DIFFUSE', 'AMBIENT_OCCLUSION': 'AO', 'RENDER': 'COMBINED', 'SELECTED_TO_ACTIVE': 'COMBINED'}
260 sce.cycles.bake_type = lookup[properties.output_type]
261 bk.use_selected_to_active = (properties.output_type == 'SELECTED_TO_ACTIVE')
262 bk.margin, bk.cage_extrusion, bk.use_cage, bk.use_clear = 1, 10, False, False
263 if properties.output_type == 'TEXTURE':
264 bk.use_pass_direct, bk.use_pass_indirect, bk.use_pass_color = False, False, True
265 sce.cycles.samples = 1
266 else:
267 sce.cycles.samples = properties.bake_samples
268 if sce.cycles.bake_type == 'COMBINED':
269 bk.use_pass_direct, bk.use_pass_indirect = True, True
270 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
272 if image_packing == 'PAGE_LINK':
273 self.mesh.save_image(printable_size * ppm, filepath)
274 elif image_packing == 'ISLAND_LINK':
275 image_dir = filepath[:filepath.rfind(".")]
276 self.mesh.save_separate_images(ppm, image_dir)
277 elif image_packing == 'ISLAND_EMBED':
278 self.mesh.save_separate_images(ppm, filepath, embed=Exporter.encode_image)
280 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
281 for p, v in recall_pass.items():
282 setattr(bk, f"use_pass_{p}", v)
284 exporter = Exporter(page_size, properties.style, properties.output_margin, (properties.output_type == 'NONE'), properties.angle_epsilon)
285 exporter.do_create_stickers = properties.do_create_stickers
286 exporter.text_size = properties.sticker_width
287 exporter.write(self.mesh, filepath)
290 class Mesh:
291 """Wrapper for Bpy Mesh"""
293 def __init__(self, bmesh, matrix):
294 self.data = bmesh
295 self.matrix = matrix.to_3x3()
296 self.looptex = bmesh.loops.layers.uv.new("Unfolded")
297 self.edges = {bmedge: Edge(bmedge) for bmedge in bmesh.edges}
298 self.islands = list()
299 self.pages = list()
300 for edge in self.edges.values():
301 edge.choose_main_faces()
302 if edge.main_faces:
303 edge.calculate_angle()
304 self.copy_freestyle_marks()
306 def delete_uvmap(self):
307 self.data.loops.layers.uv.remove(self.looptex) if self.looptex else None
309 def copy_freestyle_marks(self):
310 # NOTE: this is a workaround for NotImplementedError on bmesh.edges.layers.freestyle
311 mesh = bpy.data.meshes.new("unfolder_temp")
312 self.data.to_mesh(mesh)
313 for bmedge, edge in self.edges.items():
314 edge.freestyle = mesh.edges[bmedge.index].use_freestyle_mark
315 bpy.data.meshes.remove(mesh)
317 def mark_cuts(self):
318 for bmedge, edge in self.edges.items():
319 if edge.is_main_cut and not bmedge.is_boundary:
320 bmedge.seam = True
322 def check_correct(self, epsilon=1e-6):
323 """Check for invalid geometry"""
324 def is_twisted(face):
325 if len(face.verts) <= 3:
326 return False
327 center = face.calc_center_median()
328 plane_d = center.dot(face.normal)
329 diameter = max((center - vertex.co).length for vertex in face.verts)
330 threshold = 0.01 * diameter
331 return any(abs(v.co.dot(face.normal) - plane_d) > threshold for v in face.verts)
333 null_edges = {e for e in self.edges.keys() if e.calc_length() < epsilon and e.link_faces}
334 null_faces = {f for f in self.data.faces if f.calc_area() < epsilon}
335 twisted_faces = {f for f in self.data.faces if is_twisted(f)}
336 inverted_scale = self.matrix.determinant() <= 0
337 if not (null_edges or null_faces or twisted_faces or inverted_scale):
338 return True
339 if inverted_scale:
340 raise UnfoldError("The object is flipped inside-out.\n"
341 "You can use Object -> Apply -> Scale to fix it. Export failed.")
342 disease = [("Remove Doubles", null_edges or null_faces), ("Triangulate", twisted_faces)]
343 cure = " and ".join(s for s, k in disease if k)
344 raise UnfoldError(
345 "The model contains:\n" +
346 (" {} zero-length edge(s)\n".format(len(null_edges)) if null_edges else "") +
347 (" {} zero-area face(s)\n".format(len(null_faces)) if null_faces else "") +
348 (" {} twisted polygon(s)\n".format(len(twisted_faces)) if twisted_faces else "") +
349 "The offenders are selected and you can use {} to fix them. Export failed.".format(cure),
350 {"verts": set(), "edges": null_edges, "faces": null_faces | twisted_faces}, self.data)
352 def generate_cuts(self, page_size, priority_effect):
353 """Cut the mesh so that it can be unfolded to a flat net."""
354 normal_matrix = self.matrix.inverted().transposed()
355 islands = {Island(self, face, self.matrix, normal_matrix) for face in self.data.faces}
356 uvfaces = {face: uvface for island in islands for face, uvface in island.faces.items()}
357 uvedges = {loop: uvedge for island in islands for loop, uvedge in island.edges.items()}
358 for loop, uvedge in uvedges.items():
359 self.edges[loop.edge].uvedges.append(uvedge)
360 # check for edges that are cut permanently
361 edges = [edge for edge in self.edges.values() if not edge.force_cut and edge.main_faces]
363 if edges:
364 average_length = sum(edge.vector.length for edge in edges) / len(edges)
365 for edge in edges:
366 edge.generate_priority(priority_effect, average_length)
367 edges.sort(reverse=False, key=lambda edge: edge.priority)
368 for edge in edges:
369 if not edge.vector:
370 continue
371 edge_a, edge_b = (uvedges[l] for l in edge.main_faces)
372 old_island = join(edge_a, edge_b, size_limit=page_size)
373 if old_island:
374 islands.remove(old_island)
376 self.islands = sorted(islands, reverse=True, key=lambda island: len(island.faces))
378 for edge in self.edges.values():
379 # some edges did not know until now whether their angle is convex or concave
380 if edge.main_faces and (uvfaces[edge.main_faces[0].face].flipped or uvfaces[edge.main_faces[1].face].flipped):
381 edge.calculate_angle()
382 # ensure that the order of faces corresponds to the order of uvedges
383 if edge.main_faces:
384 reordered = [None, None]
385 for uvedge in edge.uvedges:
386 try:
387 index = edge.main_faces.index(uvedge.loop)
388 reordered[index] = uvedge
389 except ValueError:
390 reordered.append(uvedge)
391 edge.uvedges = reordered
393 for island in self.islands:
394 # if the normals are ambiguous, flip them so that there are more convex edges than concave ones
395 if any(uvface.flipped for uvface in island.faces.values()):
396 island_edges = {self.edges[uvedge.edge] for uvedge in island.edges}
397 balance = sum((+1 if edge.angle > 0 else -1) for edge in island_edges if not edge.is_cut(uvedge.uvface.face))
398 if balance < 0:
399 island.is_inside_out = True
401 # construct a linked list from each island's boundary
402 # uvedge.neighbor_right is clockwise = forward = via uvedge.vb if not uvface.flipped
403 neighbor_lookup, conflicts = dict(), dict()
404 for uvedge in island.boundary:
405 uvvertex = uvedge.va if uvedge.uvface.flipped else uvedge.vb
406 if uvvertex not in neighbor_lookup:
407 neighbor_lookup[uvvertex] = uvedge
408 else:
409 if uvvertex not in conflicts:
410 conflicts[uvvertex] = [neighbor_lookup[uvvertex], uvedge]
411 else:
412 conflicts[uvvertex].append(uvedge)
414 for uvedge in island.boundary:
415 uvvertex = uvedge.vb if uvedge.uvface.flipped else uvedge.va
416 if uvvertex not in conflicts:
417 # using the 'get' method so as to handle single-connected vertices properly
418 uvedge.neighbor_right = neighbor_lookup.get(uvvertex, uvedge)
419 uvedge.neighbor_right.neighbor_left = uvedge
420 else:
421 conflicts[uvvertex].append(uvedge)
423 # resolve merged vertices with more boundaries crossing
424 def direction_to_float(vector):
425 return (1 - vector.x/vector.length) if vector.y > 0 else (vector.x/vector.length - 1)
426 for uvvertex, uvedges in conflicts.items():
427 def is_inwards(uvedge):
428 return uvedge.uvface.flipped == (uvedge.va is uvvertex)
430 def uvedge_sortkey(uvedge):
431 if is_inwards(uvedge):
432 return direction_to_float(uvedge.va.co - uvedge.vb.co)
433 else:
434 return direction_to_float(uvedge.vb.co - uvedge.va.co)
436 uvedges.sort(key=uvedge_sortkey)
437 for right, left in (
438 zip(uvedges[:-1:2], uvedges[1::2]) if is_inwards(uvedges[0])
439 else zip([uvedges[-1]] + uvedges[1::2], uvedges[:-1:2])):
440 left.neighbor_right = right
441 right.neighbor_left = left
442 return True
444 def generate_stickers(self, default_width, do_create_numbers=True):
445 """Add sticker faces where they are needed."""
446 def uvedge_priority(uvedge):
447 """Returns whether it is a good idea to stick something on this edge's face"""
448 # TODO: it should take into account overlaps with faces and with other stickers
449 face = uvedge.uvface.face
450 return face.calc_area() / face.calc_perimeter()
452 def add_sticker(uvedge, index, target_uvedge):
453 uvedge.sticker = Sticker(uvedge, default_width, index, target_uvedge)
454 uvedge.uvface.island.add_marker(uvedge.sticker)
456 def is_index_obvious(uvedge, target):
457 if uvedge in (target.neighbor_left, target.neighbor_right):
458 return True
459 if uvedge.neighbor_left.loop.edge is target.neighbor_right.loop.edge and uvedge.neighbor_right.loop.edge is target.neighbor_left.loop.edge:
460 return True
461 return False
463 for edge in self.edges.values():
464 index = None
465 if edge.is_main_cut and len(edge.uvedges) >= 2 and edge.vector.length_squared > 0:
466 target, source = edge.uvedges[:2]
467 if uvedge_priority(target) < uvedge_priority(source):
468 target, source = source, target
469 target_island = target.uvface.island
470 if do_create_numbers:
471 for uvedge in [source] + edge.uvedges[2:]:
472 if not is_index_obvious(uvedge, target):
473 # it will not be clear to see that these uvedges should be sticked together
474 # So, create an arrow and put the index on all stickers
475 target_island.sticker_numbering += 1
476 index = str(target_island.sticker_numbering)
477 if is_upsidedown_wrong(index):
478 index += "."
479 target_island.add_marker(Arrow(target, default_width, index))
480 break
481 add_sticker(source, index, target)
482 elif len(edge.uvedges) > 2:
483 target = edge.uvedges[0]
484 if len(edge.uvedges) > 2:
485 for source in edge.uvedges[2:]:
486 add_sticker(source, index, target)
488 def generate_numbers_alone(self, size):
489 global_numbering = 0
490 for edge in self.edges.values():
491 if edge.is_main_cut and len(edge.uvedges) >= 2:
492 global_numbering += 1
493 index = str(global_numbering)
494 if is_upsidedown_wrong(index):
495 index += "."
496 for uvedge in edge.uvedges:
497 uvedge.uvface.island.add_marker(NumberAlone(uvedge, index, size))
499 def enumerate_islands(self):
500 for num, island in enumerate(self.islands, 1):
501 island.number = num
502 island.generate_label()
504 def scale_islands(self, scale):
505 for island in self.islands:
506 vertices = set(island.vertices.values())
507 for point in chain((vertex.co for vertex in vertices), island.fake_vertices):
508 point *= scale
510 def finalize_islands(self, cage_size, title_height=0):
511 for island in self.islands:
512 if title_height:
513 island.title = "[{}] {}".format(island.abbreviation, island.label)
514 points = [vertex.co for vertex in set(island.vertices.values())] + island.fake_vertices
515 angle, _ = cage_fit(points, (cage_size.y - title_height) / cage_size.x)
516 rot = M.Matrix.Rotation(angle, 2)
517 for point in points:
518 # note: we need an in-place operation, and Vector.rotate() seems to work for 3d vectors only
519 point[:] = rot @ point
520 for marker in island.markers:
521 marker.rot = rot @ marker.rot
522 bottom_left = M.Vector((min(v.x for v in points), min(v.y for v in points) - title_height))
523 #DEBUG
524 top_right = M.Vector((max(v.x for v in points), max(v.y for v in points) - title_height))
525 #print(f"fitted aspect: {(top_right.y - bottom_left.y) / (top_right.x - bottom_left.x)}")
526 for point in points:
527 point -= bottom_left
528 island.bounding_box = M.Vector((max(v.x for v in points), max(v.y for v in points)))
530 def largest_island_ratio(self, cage_size):
531 return max(i / p for island in self.islands for (i, p) in zip(island.bounding_box, cage_size))
533 def fit_islands(self, cage_size):
534 """Move islands so that they fit onto pages, based on their bounding boxes"""
536 def try_emplace(island, page_islands, stops_x, stops_y, occupied_cache):
537 """Tries to put island to each pair from stops_x, stops_y
538 and checks if it overlaps with any islands present on the page.
539 Returns True and positions the given island on success."""
540 bbox_x, bbox_y = island.bounding_box.xy
541 for x in stops_x:
542 if x + bbox_x > cage_size.x:
543 continue
544 for y in stops_y:
545 if y + bbox_y > cage_size.y or (x, y) in occupied_cache:
546 continue
547 for i, obstacle in enumerate(page_islands):
548 # if this obstacle overlaps with the island, try another stop
549 if (x + bbox_x > obstacle.pos.x and
550 obstacle.pos.x + obstacle.bounding_box.x > x and
551 y + bbox_y > obstacle.pos.y and
552 obstacle.pos.y + obstacle.bounding_box.y > y):
553 if x >= obstacle.pos.x and y >= obstacle.pos.y:
554 occupied_cache.add((x, y))
555 # just a stupid heuristic to make subsequent searches faster
556 if i > 0:
557 page_islands[1:i+1] = page_islands[:i]
558 page_islands[0] = obstacle
559 break
560 else:
561 # if no obstacle called break, this position is okay
562 island.pos.xy = x, y
563 page_islands.append(island)
564 stops_x.append(x + bbox_x)
565 stops_y.append(y + bbox_y)
566 return True
567 return False
569 def drop_portion(stops, border, divisor):
570 stops.sort()
571 # distance from left neighbor to the right one, excluding the first stop
572 distances = [right - left for left, right in zip(stops, chain(stops[2:], [border]))]
573 quantile = sorted(distances)[len(distances) // divisor]
574 return [stop for stop, distance in zip(stops, chain([quantile], distances)) if distance >= quantile]
576 if any(island.bounding_box.x > cage_size.x or island.bounding_box.y > cage_size.y for island in self.islands):
577 raise UnfoldError(
578 "An island is too big to fit onto page of the given size. "
579 "Either downscale the model or find and split that island manually.\n"
580 "Export failed, sorry.")
581 # sort islands by their diagonal... just a guess
582 remaining_islands = sorted(self.islands, reverse=True, key=lambda island: island.bounding_box.length_squared)
583 page_num = 1 # TODO delete me
585 while remaining_islands:
586 # create a new page and try to fit as many islands onto it as possible
587 page = Page(page_num)
588 page_num += 1
589 occupied_cache = set()
590 stops_x, stops_y = [0], [0]
591 for island in remaining_islands:
592 try_emplace(island, page.islands, stops_x, stops_y, occupied_cache)
593 # if overwhelmed with stops, drop a quarter of them
594 if len(stops_x)**2 > 4 * len(self.islands) + 100:
595 stops_x = drop_portion(stops_x, cage_size.x, 4)
596 stops_y = drop_portion(stops_y, cage_size.y, 4)
597 remaining_islands = [island for island in remaining_islands if island not in page.islands]
598 self.pages.append(page)
600 def save_uv(self, cage_size=M.Vector((1, 1)), separate_image=False):
601 if separate_image:
602 for island in self.islands:
603 island.save_uv_separate(self.looptex)
604 else:
605 for island in self.islands:
606 island.save_uv(self.looptex, cage_size)
608 def save_image(self, page_size_pixels: M.Vector, filename):
609 for page in self.pages:
610 image = create_blank_image("Page {}".format(page.name), page_size_pixels, alpha=1)
611 image.filepath_raw = page.image_path = "{}_{}.png".format(filename, page.name)
612 faces = [face for island in page.islands for face in island.faces]
613 self.bake(faces, image)
614 image.save()
615 image.user_clear()
616 bpy.data.images.remove(image)
618 def save_separate_images(self, scale, filepath, embed=None):
619 for i, island in enumerate(self.islands):
620 image_name = "Island {}".format(i)
621 image = create_blank_image(image_name, island.bounding_box * scale, alpha=0)
622 self.bake(island.faces.keys(), image)
623 if embed:
624 island.embedded_image = embed(image)
625 else:
626 from os import makedirs
627 image_dir = filepath
628 makedirs(image_dir, exist_ok=True)
629 image_path = os_path.join(image_dir, "island{}.png".format(i))
630 image.filepath_raw = image_path
631 image.save()
632 island.image_path = image_path
633 image.user_clear()
634 bpy.data.images.remove(image)
636 def bake(self, faces, image):
637 if not self.looptex:
638 raise UnfoldError("The mesh has no UV Map slots left. Either delete a UV Map or export the net without textures.")
639 ob = bpy.context.active_object
640 me = ob.data
641 # in Cycles, the image for baking is defined by the active Image Node
642 temp_nodes = dict()
643 for mat in me.materials:
644 mat.use_nodes = True
645 img = mat.node_tree.nodes.new('ShaderNodeTexImage')
646 img.image = image
647 temp_nodes[mat] = img
648 mat.node_tree.nodes.active = img
649 # move all excess faces to negative numbers (that is the only way to disable them)
650 ignored_uvs = [loop[self.looptex].uv for f in self.data.faces if f not in faces for loop in f.loops]
651 for uv in ignored_uvs:
652 uv *= -1
653 bake_type = bpy.context.scene.cycles.bake_type
654 sta = bpy.context.scene.render.bake.use_selected_to_active
655 try:
656 ob.update_from_editmode()
657 me.uv_layers.active = me.uv_layers[self.looptex.name]
658 bpy.ops.object.bake(type=bake_type, margin=1, use_selected_to_active=sta, cage_extrusion=100, use_clear=False)
659 except RuntimeError as e:
660 raise UnfoldError(*e.args)
661 finally:
662 for mat, node in temp_nodes.items():
663 mat.node_tree.nodes.remove(node)
664 for uv in ignored_uvs:
665 uv *= -1
668 class Edge:
669 """Wrapper for BPy Edge"""
670 __slots__ = ('data', 'va', 'vb', 'main_faces', 'uvedges',
671 'vector', 'angle',
672 'is_main_cut', 'force_cut', 'priority', 'freestyle')
674 def __init__(self, edge):
675 self.data = edge
676 self.va, self.vb = edge.verts
677 self.vector = self.vb.co - self.va.co
678 # if self.main_faces is set, then self.uvedges[:2] must correspond to self.main_faces, in their order
679 # this constraint is assured at the time of finishing mesh.generate_cuts
680 self.uvedges = list()
682 self.force_cut = edge.seam # such edges will always be cut
683 self.main_faces = None # two faces that may be connected in the island
684 # is_main_cut defines whether the two main faces are connected
685 # all the others will be assumed to be cut
686 self.is_main_cut = True
687 self.priority = None
688 self.angle = None
689 self.freestyle = False
691 def choose_main_faces(self):
692 """Choose two main faces that might get connected in an island"""
693 from itertools import combinations
694 loops = self.data.link_loops
695 def score(pair):
696 return abs(pair[0].face.normal.dot(pair[1].face.normal))
697 if len(loops) == 2:
698 self.main_faces = list(loops)
699 elif len(loops) > 2:
700 # find (with brute force) the pair of indices whose loops have the most similar normals
701 self.main_faces = max(combinations(loops, 2), key=score)
702 if self.main_faces and self.main_faces[1].vert == self.va:
703 self.main_faces = self.main_faces[::-1]
705 def calculate_angle(self):
706 """Calculate the angle between the main faces"""
707 loop_a, loop_b = self.main_faces
708 normal_a, normal_b = (l.face.normal for l in self.main_faces)
709 if not normal_a or not normal_b:
710 self.angle = -3 # just a very sharp angle
711 else:
712 s = normal_a.cross(normal_b).dot(self.vector.normalized())
713 s = max(min(s, 1.0), -1.0) # deal with rounding errors
714 self.angle = asin(s)
715 if loop_a.link_loop_next.vert != loop_b.vert or loop_b.link_loop_next.vert != loop_a.vert:
716 self.angle = abs(self.angle)
718 def generate_priority(self, priority_effect, average_length):
719 """Calculate the priority value for cutting"""
720 angle = self.angle
721 if angle > 0:
722 self.priority = priority_effect['CONVEX'] * angle / pi
723 else:
724 self.priority = priority_effect['CONCAVE'] * (-angle) / pi
725 self.priority += (self.vector.length / average_length) * priority_effect['LENGTH']
727 def is_cut(self, face):
728 """Return False if this edge will the given face to another one in the resulting net
729 (useful for edges with more than two faces connected)"""
730 # Return whether there is a cut between the two main faces
731 if self.main_faces and face in {loop.face for loop in self.main_faces}:
732 return self.is_main_cut
733 # All other faces (third and more) are automatically treated as cut
734 else:
735 return True
737 def other_uvedge(self, this):
738 """Get an uvedge of this edge that is not the given one
739 causes an IndexError if case of less than two adjacent edges"""
740 return self.uvedges[1] if this is self.uvedges[0] else self.uvedges[0]
743 class Island:
744 """Part of the net to be exported"""
745 __slots__ = ('mesh', 'faces', 'edges', 'vertices', 'fake_vertices', 'boundary', 'markers',
746 'pos', 'bounding_box',
747 'image_path', 'embedded_image',
748 'number', 'label', 'abbreviation', 'title',
749 'has_safe_geometry', 'is_inside_out',
750 'sticker_numbering')
752 def __init__(self, mesh, face, matrix, normal_matrix):
753 """Create an Island from a single Face"""
754 self.mesh = mesh
755 self.faces = dict() # face -> uvface
756 self.edges = dict() # loop -> uvedge
757 self.vertices = dict() # loop -> uvvertex
758 self.fake_vertices = list()
759 self.markers = list()
760 self.label = None
761 self.abbreviation = None
762 self.title = None
763 self.pos = M.Vector((0, 0))
764 self.image_path = None
765 self.embedded_image = None
766 self.is_inside_out = False # swaps concave <-> convex edges
767 self.has_safe_geometry = True
768 self.sticker_numbering = 0
770 uvface = UVFace(face, self, matrix, normal_matrix)
771 self.vertices.update(uvface.vertices)
772 self.edges.update(uvface.edges)
773 self.faces[face] = uvface
774 # UVEdges on the boundary
775 self.boundary = list(self.edges.values())
777 def add_marker(self, marker):
778 self.fake_vertices.extend(marker.bounds)
779 self.markers.append(marker)
781 def generate_label(self, label=None, abbreviation=None):
782 """Assign a name to this island automatically"""
783 abbr = abbreviation or self.abbreviation or str(self.number)
784 # TODO: dots should be added in the last instant when outputting any text
785 if is_upsidedown_wrong(abbr):
786 abbr += "."
787 self.label = label or self.label or "Island {}".format(self.number)
788 self.abbreviation = abbr
790 def save_uv(self, tex, cage_size):
791 """Save UV Coordinates of all UVFaces to a given UV texture
792 tex: UV Texture layer to use (BMLayerItem)
793 page_size: size of the page in pixels (vector)"""
794 scale_x, scale_y = 1 / cage_size.x, 1 / cage_size.y
795 for loop, uvvertex in self.vertices.items():
796 uv = uvvertex.co + self.pos
797 loop[tex].uv = uv.x * scale_x, uv.y * scale_y
799 def save_uv_separate(self, tex):
800 """Save UV Coordinates of all UVFaces to a given UV texture, spanning from 0 to 1
801 tex: UV Texture layer to use (BMLayerItem)
802 page_size: size of the page in pixels (vector)"""
803 scale_x, scale_y = 1 / self.bounding_box.x, 1 / self.bounding_box.y
804 for loop, uvvertex in self.vertices.items():
805 loop[tex].uv = uvvertex.co.x * scale_x, uvvertex.co.y * scale_y
807 def join(uvedge_a, uvedge_b, size_limit=None, epsilon=1e-6):
809 Try to join other island on given edge
810 Returns False if they would overlap
813 class Intersection(Exception):
814 pass
816 class GeometryError(Exception):
817 pass
819 def is_below(self, other, correct_geometry=True):
820 if self is other:
821 return False
822 if self.top < other.bottom:
823 return True
824 if other.top < self.bottom:
825 return False
826 if self.max.tup <= other.min.tup:
827 return True
828 if other.max.tup <= self.min.tup:
829 return False
830 self_vector = self.max.co - self.min.co
831 min_to_min = other.min.co - self.min.co
832 cross_b1 = self_vector.cross(min_to_min)
833 cross_b2 = self_vector.cross(other.max.co - self.min.co)
834 if cross_b2 < cross_b1:
835 cross_b1, cross_b2 = cross_b2, cross_b1
836 if cross_b2 > 0 and (cross_b1 > 0 or (cross_b1 == 0 and not self.is_uvface_upwards())):
837 return True
838 if cross_b1 < 0 and (cross_b2 < 0 or (cross_b2 == 0 and self.is_uvface_upwards())):
839 return False
840 other_vector = other.max.co - other.min.co
841 cross_a1 = other_vector.cross(-min_to_min)
842 cross_a2 = other_vector.cross(self.max.co - other.min.co)
843 if cross_a2 < cross_a1:
844 cross_a1, cross_a2 = cross_a2, cross_a1
845 if cross_a2 > 0 and (cross_a1 > 0 or (cross_a1 == 0 and not other.is_uvface_upwards())):
846 return False
847 if cross_a1 < 0 and (cross_a2 < 0 or (cross_a2 == 0 and other.is_uvface_upwards())):
848 return True
849 if cross_a1 == cross_b1 == cross_a2 == cross_b2 == 0:
850 if correct_geometry:
851 raise GeometryError
852 elif self.is_uvface_upwards() == other.is_uvface_upwards():
853 raise Intersection
854 return False
855 if self.min.tup == other.min.tup or self.max.tup == other.max.tup:
856 return cross_a2 > cross_b2
857 raise Intersection
859 class QuickSweepline:
860 """Efficient sweepline based on binary search, checking neighbors only"""
861 def __init__(self):
862 self.children = list()
864 def add(self, item, cmp=is_below):
865 low, high = 0, len(self.children)
866 while low < high:
867 mid = (low + high) // 2
868 if cmp(self.children[mid], item):
869 low = mid + 1
870 else:
871 high = mid
872 self.children.insert(low, item)
874 def remove(self, item, cmp=is_below):
875 index = self.children.index(item)
876 self.children.pop(index)
877 if index > 0 and index < len(self.children):
878 # check for intersection
879 if cmp(self.children[index], self.children[index-1]):
880 raise GeometryError
882 class BruteSweepline:
883 """Safe sweepline which checks all its members pairwise"""
884 def __init__(self):
885 self.children = set()
887 def add(self, item, cmp=is_below):
888 for child in self.children:
889 if child.min is not item.min and child.max is not item.max:
890 cmp(item, child, False)
891 self.children.add(item)
893 def remove(self, item):
894 self.children.remove(item)
896 def sweep(sweepline, segments):
897 """Sweep across the segments and raise an exception if necessary"""
898 # careful, 'segments' may be a use-once iterator
899 events_add = sorted(segments, reverse=True, key=lambda uvedge: uvedge.min.tup)
900 events_remove = sorted(events_add, reverse=True, key=lambda uvedge: uvedge.max.tup)
901 while events_remove:
902 while events_add and events_add[-1].min.tup <= events_remove[-1].max.tup:
903 sweepline.add(events_add.pop())
904 sweepline.remove(events_remove.pop())
906 def root_find(value, tree):
907 """Find the root of a given value in a forest-like dictionary
908 also updates the dictionary using path compression"""
909 parent, relink = tree.get(value), list()
910 while parent is not None:
911 relink.append(value)
912 value, parent = parent, tree.get(parent)
913 tree.update(dict.fromkeys(relink, value))
914 return value
916 def slope_from(position):
917 def slope(uvedge):
918 vec = (uvedge.vb.co - uvedge.va.co) if uvedge.va.tup == position else (uvedge.va.co - uvedge.vb.co)
919 return (vec.y / vec.length + 1) if ((vec.x, vec.y) > (0, 0)) else (-1 - vec.y / vec.length)
920 return slope
922 island_a, island_b = (e.uvface.island for e in (uvedge_a, uvedge_b))
923 if island_a is island_b:
924 return False
925 elif len(island_b.faces) > len(island_a.faces):
926 uvedge_a, uvedge_b = uvedge_b, uvedge_a
927 island_a, island_b = island_b, island_a
928 # check if vertices and normals are aligned correctly
929 verts_flipped = uvedge_b.loop.vert is uvedge_a.loop.vert
930 flipped = verts_flipped ^ uvedge_a.uvface.flipped ^ uvedge_b.uvface.flipped
931 # determine rotation
932 # NOTE: if the edges differ in length, the matrix will involve uniform scaling.
933 # Such situation may occur in the case of twisted n-gons
934 first_b, second_b = (uvedge_b.va, uvedge_b.vb) if not verts_flipped else (uvedge_b.vb, uvedge_b.va)
935 if not flipped:
936 rot = fitting_matrix(first_b.co - second_b.co, uvedge_a.vb.co - uvedge_a.va.co)
937 else:
938 flip = M.Matrix(((-1, 0), (0, 1)))
939 rot = fitting_matrix(flip @ (first_b.co - second_b.co), uvedge_a.vb.co - uvedge_a.va.co) @ flip
940 trans = uvedge_a.vb.co - rot @ first_b.co
941 # preview of island_b's vertices after the join operation
942 phantoms = {uvvertex: UVVertex(rot @ uvvertex.co + trans) for uvvertex in island_b.vertices.values()}
944 # check the size of the resulting island
945 if size_limit:
946 points = [vert.co for vert in chain(island_a.vertices.values(), phantoms.values())]
947 left, right, bottom, top = (fn(co[i] for co in points) for i in (0, 1) for fn in (min, max))
948 bbox_width = right - left
949 bbox_height = top - bottom
950 if min(bbox_width, bbox_height)**2 > size_limit.x**2 + size_limit.y**2:
951 return False
952 if (bbox_width > size_limit.x or bbox_height > size_limit.y) and (bbox_height > size_limit.x or bbox_width > size_limit.y):
953 _, height = cage_fit(points, size_limit.y / size_limit.x)
954 if height > size_limit.y:
955 return False
957 distance_limit = uvedge_a.loop.edge.calc_length() * epsilon
958 # try and merge UVVertices closer than sqrt(distance_limit)
959 merged_uvedges = set()
960 merged_uvedge_pairs = list()
962 # merge all uvvertices that are close enough using a union-find structure
963 # uvvertices will be merged only in cases island_b->island_a and island_a->island_a
964 # all resulting groups are merged together to a uvvertex of island_a
965 is_merged_mine = False
966 shared_vertices = {loop.vert for loop in chain(island_a.vertices, island_b.vertices)}
967 for vertex in shared_vertices:
968 uvs_a = {island_a.vertices.get(loop) for loop in vertex.link_loops} - {None}
969 uvs_b = {island_b.vertices.get(loop) for loop in vertex.link_loops} - {None}
970 for a, b in product(uvs_a, uvs_b):
971 if (a.co - phantoms[b].co).length_squared < distance_limit:
972 phantoms[b] = root_find(a, phantoms)
973 for a1, a2 in combinations(uvs_a, 2):
974 if (a1.co - a2.co).length_squared < distance_limit:
975 a1, a2 = (root_find(a, phantoms) for a in (a1, a2))
976 if a1 is not a2:
977 phantoms[a2] = a1
978 is_merged_mine = True
979 for source, target in phantoms.items():
980 target = root_find(target, phantoms)
981 phantoms[source] = target
983 for uvedge in (chain(island_a.boundary, island_b.boundary) if is_merged_mine else island_b.boundary):
984 for loop in uvedge.loop.link_loops:
985 partner = island_b.edges.get(loop) or island_a.edges.get(loop)
986 if partner is not None and partner is not uvedge:
987 paired_a, paired_b = phantoms.get(partner.vb, partner.vb), phantoms.get(partner.va, partner.va)
988 if (partner.uvface.flipped ^ flipped) != uvedge.uvface.flipped:
989 paired_a, paired_b = paired_b, paired_a
990 if phantoms.get(uvedge.va, uvedge.va) is paired_a and phantoms.get(uvedge.vb, uvedge.vb) is paired_b:
991 # if these two edges will get merged, add them both to the set
992 merged_uvedges.update((uvedge, partner))
993 merged_uvedge_pairs.append((uvedge, partner))
994 break
996 if uvedge_b not in merged_uvedges:
997 raise UnfoldError("Export failed. Please report this error, including the model if you can.")
999 boundary_other = [
1000 PhantomUVEdge(phantoms[uvedge.va], phantoms[uvedge.vb], flipped ^ uvedge.uvface.flipped)
1001 for uvedge in island_b.boundary if uvedge not in merged_uvedges]
1002 # TODO: if is_merged_mine, it might make sense to create a similar list from island_a.boundary as well
1004 incidence = {vertex.tup for vertex in phantoms.values()}.intersection(vertex.tup for vertex in island_a.vertices.values())
1005 incidence = {position: list() for position in incidence} # from now on, 'incidence' is a dict
1006 for uvedge in chain(boundary_other, island_a.boundary):
1007 if uvedge.va.co == uvedge.vb.co:
1008 continue
1009 for vertex in (uvedge.va, uvedge.vb):
1010 site = incidence.get(vertex.tup)
1011 if site is not None:
1012 site.append(uvedge)
1013 for position, segments in incidence.items():
1014 if len(segments) <= 2:
1015 continue
1016 segments.sort(key=slope_from(position))
1017 for right, left in pairs(segments):
1018 is_left_ccw = left.is_uvface_upwards() ^ (left.max.tup == position)
1019 is_right_ccw = right.is_uvface_upwards() ^ (right.max.tup == position)
1020 if is_right_ccw and not is_left_ccw and type(right) is not type(left) and right not in merged_uvedges and left not in merged_uvedges:
1021 return False
1022 if (not is_right_ccw and right not in merged_uvedges) ^ (is_left_ccw and left not in merged_uvedges):
1023 return False
1025 # check for self-intersections
1026 try:
1027 try:
1028 sweepline = QuickSweepline() if island_a.has_safe_geometry and island_b.has_safe_geometry else BruteSweepline()
1029 sweep(sweepline, (uvedge for uvedge in chain(boundary_other, island_a.boundary)))
1030 island_a.has_safe_geometry &= island_b.has_safe_geometry
1031 except GeometryError:
1032 sweep(BruteSweepline(), (uvedge for uvedge in chain(boundary_other, island_a.boundary)))
1033 island_a.has_safe_geometry = False
1034 except Intersection:
1035 return False
1037 # mark all edges that connect the islands as not cut
1038 for uvedge in merged_uvedges:
1039 island_a.mesh.edges[uvedge.loop.edge].is_main_cut = False
1041 # include all trasformed vertices as mine
1042 island_a.vertices.update({loop: phantoms[uvvertex] for loop, uvvertex in island_b.vertices.items()})
1044 # re-link uvedges and uvfaces to their transformed locations
1045 for uvedge in island_b.edges.values():
1046 uvedge.va = phantoms[uvedge.va]
1047 uvedge.vb = phantoms[uvedge.vb]
1048 uvedge.update()
1049 if is_merged_mine:
1050 for uvedge in island_a.edges.values():
1051 uvedge.va = phantoms.get(uvedge.va, uvedge.va)
1052 uvedge.vb = phantoms.get(uvedge.vb, uvedge.vb)
1053 island_a.edges.update(island_b.edges)
1055 for uvface in island_b.faces.values():
1056 uvface.island = island_a
1057 uvface.vertices = {loop: phantoms[uvvertex] for loop, uvvertex in uvface.vertices.items()}
1058 uvface.flipped ^= flipped
1059 if is_merged_mine:
1060 # there may be own uvvertices that need to be replaced by phantoms
1061 for uvface in island_a.faces.values():
1062 if any(uvvertex in phantoms for uvvertex in uvface.vertices):
1063 uvface.vertices = {loop: phantoms.get(uvvertex, uvvertex) for loop, uvvertex in uvface.vertices.items()}
1064 island_a.faces.update(island_b.faces)
1066 island_a.boundary = [
1067 uvedge for uvedge in chain(island_a.boundary, island_b.boundary)
1068 if uvedge not in merged_uvedges]
1070 for uvedge, partner in merged_uvedge_pairs:
1071 # make sure that main faces are the ones actually merged (this changes nothing in most cases)
1072 edge = island_a.mesh.edges[uvedge.loop.edge]
1073 edge.main_faces = uvedge.loop, partner.loop
1075 # everything seems to be OK
1076 return island_b
1079 class Page:
1080 """Container for several Islands"""
1081 __slots__ = ('islands', 'name', 'image_path')
1083 def __init__(self, num=1):
1084 self.islands = list()
1085 self.name = "page{}".format(num) # TODO delete me
1086 self.image_path = None
1089 class UVVertex:
1090 """Vertex in 2D"""
1091 __slots__ = ('co', 'tup')
1093 def __init__(self, vector):
1094 self.co = vector.xy
1095 self.tup = tuple(self.co)
1098 class UVEdge:
1099 """Edge in 2D"""
1100 # Every UVEdge is attached to only one UVFace
1101 # UVEdges are doubled as needed because they both have to point clockwise around their faces
1102 __slots__ = ('va', 'vb', 'uvface', 'loop',
1103 'min', 'max', 'bottom', 'top',
1104 'neighbor_left', 'neighbor_right', 'sticker')
1106 def __init__(self, vertex1: UVVertex, vertex2: UVVertex, uvface, loop):
1107 self.va = vertex1
1108 self.vb = vertex2
1109 self.update()
1110 self.uvface = uvface
1111 self.sticker = None
1112 self.loop = loop
1114 def update(self):
1115 """Update data if UVVertices have moved"""
1116 self.min, self.max = (self.va, self.vb) if (self.va.tup < self.vb.tup) else (self.vb, self.va)
1117 y1, y2 = self.va.co.y, self.vb.co.y
1118 self.bottom, self.top = (y1, y2) if y1 < y2 else (y2, y1)
1120 def is_uvface_upwards(self):
1121 return (self.va.tup < self.vb.tup) ^ self.uvface.flipped
1123 def __repr__(self):
1124 return "({0.va} - {0.vb})".format(self)
1127 class PhantomUVEdge:
1128 """Temporary 2D Segment for calculations"""
1129 __slots__ = ('va', 'vb', 'min', 'max', 'bottom', 'top')
1131 def __init__(self, vertex1: UVVertex, vertex2: UVVertex, flip):
1132 self.va, self.vb = (vertex2, vertex1) if flip else (vertex1, vertex2)
1133 self.min, self.max = (self.va, self.vb) if (self.va.tup < self.vb.tup) else (self.vb, self.va)
1134 y1, y2 = self.va.co.y, self.vb.co.y
1135 self.bottom, self.top = (y1, y2) if y1 < y2 else (y2, y1)
1137 def is_uvface_upwards(self):
1138 return self.va.tup < self.vb.tup
1140 def __repr__(self):
1141 return "[{0.va} - {0.vb}]".format(self)
1144 class UVFace:
1145 """Face in 2D"""
1146 __slots__ = ('vertices', 'edges', 'face', 'island', 'flipped')
1148 def __init__(self, face: bmesh.types.BMFace, island: Island, matrix=1, normal_matrix=1):
1149 self.face = face
1150 self.island = island
1151 self.flipped = False # a flipped UVFace has edges clockwise
1153 flatten = z_up_matrix(normal_matrix @ face.normal) @ matrix
1154 self.vertices = {loop: UVVertex(flatten @ loop.vert.co) for loop in face.loops}
1155 self.edges = {loop: UVEdge(self.vertices[loop], self.vertices[loop.link_loop_next], self, loop) for loop in face.loops}
1158 class Arrow:
1159 """Mark in the document: an arrow denoting the number of the edge it points to"""
1160 __slots__ = ('bounds', 'center', 'rot', 'text', 'size')
1162 def __init__(self, uvedge, size, index):
1163 self.text = str(index)
1164 edge = (uvedge.vb.co - uvedge.va.co) if not uvedge.uvface.flipped else (uvedge.va.co - uvedge.vb.co)
1165 self.center = (uvedge.va.co + uvedge.vb.co) / 2
1166 self.size = size
1167 tangent = edge.normalized()
1168 cos, sin = tangent
1169 self.rot = M.Matrix(((cos, -sin), (sin, cos)))
1170 normal = M.Vector((sin, -cos))
1171 self.bounds = [self.center, self.center + (1.2 * normal + tangent) * size, self.center + (1.2 * normal - tangent) * size]
1174 class Sticker:
1175 """Mark in the document: sticker tab"""
1176 __slots__ = ('bounds', 'center', 'rot', 'text', 'width', 'vertices')
1178 def __init__(self, uvedge, default_width, index, other: UVEdge):
1179 """Sticker is directly attached to the given UVEdge"""
1180 first_vertex, second_vertex = (uvedge.va, uvedge.vb) if not uvedge.uvface.flipped else (uvedge.vb, uvedge.va)
1181 edge = first_vertex.co - second_vertex.co
1182 sticker_width = min(default_width, edge.length / 2)
1183 other_first, other_second = (other.va, other.vb) if not other.uvface.flipped else (other.vb, other.va)
1184 other_edge = other_second.co - other_first.co
1186 # angle a is at vertex uvedge.va, b is at uvedge.vb
1187 cos_a = cos_b = 0.5
1188 sin_a = sin_b = 0.75**0.5
1189 # len_a is length of the side adjacent to vertex a, len_b likewise
1190 len_a = len_b = sticker_width / sin_a
1192 # fix overlaps with the most often neighbour - its sticking target
1193 if first_vertex == other_second:
1194 cos_a = max(cos_a, edge.dot(other_edge) / (edge.length_squared)) # angles between pi/3 and 0
1195 elif second_vertex == other_first:
1196 cos_b = max(cos_b, edge.dot(other_edge) / (edge.length_squared)) # angles between pi/3 and 0
1198 # Fix tabs for sticking targets with small angles
1199 try:
1200 other_face_neighbor_left = other.neighbor_left
1201 other_face_neighbor_right = other.neighbor_right
1202 other_edge_neighbor_a = other_face_neighbor_left.vb.co - other.vb.co
1203 other_edge_neighbor_b = other_face_neighbor_right.va.co - other.va.co
1204 # Adjacent angles in the face
1205 cos_a = max(cos_a, -other_edge.dot(other_edge_neighbor_a) / (other_edge.length*other_edge_neighbor_a.length))
1206 cos_b = max(cos_b, other_edge.dot(other_edge_neighbor_b) / (other_edge.length*other_edge_neighbor_b.length))
1207 except AttributeError: # neighbor data may be missing for edges with 3+ faces
1208 pass
1209 except ZeroDivisionError:
1210 pass
1212 # Calculate the lengths of the glue tab edges using the possibly smaller angles
1213 sin_a = abs(1 - cos_a**2)**0.5
1214 len_b = min(len_a, (edge.length * sin_a) / (sin_a * cos_b + sin_b * cos_a))
1215 len_a = 0 if sin_a == 0 else min(sticker_width / sin_a, (edge.length - len_b*cos_b) / cos_a)
1217 sin_b = abs(1 - cos_b**2)**0.5
1218 len_a = min(len_a, (edge.length * sin_b) / (sin_a * cos_b + sin_b * cos_a))
1219 len_b = 0 if sin_b == 0 else min(sticker_width / sin_b, (edge.length - len_a * cos_a) / cos_b)
1221 v3 = UVVertex(second_vertex.co + M.Matrix(((cos_b, -sin_b), (sin_b, cos_b))) @ edge * len_b / edge.length)
1222 v4 = UVVertex(first_vertex.co + M.Matrix(((-cos_a, -sin_a), (sin_a, -cos_a))) @ edge * len_a / edge.length)
1223 if v3.co != v4.co:
1224 self.vertices = [second_vertex, v3, v4, first_vertex]
1225 else:
1226 self.vertices = [second_vertex, v3, first_vertex]
1228 sin, cos = edge.y / edge.length, edge.x / edge.length
1229 self.rot = M.Matrix(((cos, -sin), (sin, cos)))
1230 self.width = sticker_width * 0.9
1231 if index and uvedge.uvface.island is not other.uvface.island:
1232 self.text = "{}:{}".format(other.uvface.island.abbreviation, index)
1233 else:
1234 self.text = index
1235 self.center = (uvedge.va.co + uvedge.vb.co) / 2 + self.rot @ M.Vector((0, self.width * 0.2))
1236 self.bounds = [v3.co, v4.co, self.center] if v3.co != v4.co else [v3.co, self.center]
1239 class NumberAlone:
1240 """Mark in the document: numbering inside the island denoting edges to be sticked"""
1241 __slots__ = ('bounds', 'center', 'rot', 'text', 'size')
1243 def __init__(self, uvedge, index, default_size=0.005):
1244 """Sticker is directly attached to the given UVEdge"""
1245 edge = (uvedge.va.co - uvedge.vb.co) if not uvedge.uvface.flipped else (uvedge.vb.co - uvedge.va.co)
1247 self.size = default_size
1248 sin, cos = edge.y / edge.length, edge.x / edge.length
1249 self.rot = M.Matrix(((cos, -sin), (sin, cos)))
1250 self.text = index
1251 self.center = (uvedge.va.co + uvedge.vb.co) / 2 - self.rot @ M.Vector((0, self.size * 1.2))
1252 self.bounds = [self.center]
1255 class SVG:
1256 """Simple SVG exporter"""
1258 def __init__(self, page_size: M.Vector, style, margin, pure_net=True, angle_epsilon=0.01):
1259 """Initialize document settings.
1260 page_size: document dimensions in meters
1261 pure_net: if True, do not use image"""
1262 self.page_size = page_size
1263 self.pure_net = pure_net
1264 self.style = style
1265 self.margin = margin
1266 self.text_size = 12
1267 self.angle_epsilon = angle_epsilon
1269 @classmethod
1270 def encode_image(cls, bpy_image):
1271 import tempfile
1272 import base64
1273 with tempfile.TemporaryDirectory() as directory:
1274 filename = directory + "/i.png"
1275 bpy_image.filepath_raw = filename
1276 bpy_image.save()
1277 return base64.encodebytes(open(filename, "rb").read()).decode('ascii')
1279 def format_vertex(self, vector, pos=M.Vector((0, 0))):
1280 """Return a string with both coordinates of the given vertex."""
1281 x, y = vector + pos
1282 return "{:.6f} {:.6f}".format((x + self.margin) * 1000, (self.page_size.y - y - self.margin) * 1000)
1284 def write(self, mesh, filename):
1285 """Write data to a file given by its name."""
1286 line_through = " L ".join # used for formatting of SVG path data
1287 rows = "\n".join
1289 dl = ["{:.2f}".format(length * self.style.line_width * 1000) for length in (2, 5, 10)]
1290 format_style = {
1291 'SOLID': "none", 'DOT': "{0},{1}".format(*dl), 'DASH': "{1},{2}".format(*dl),
1292 'LONGDASH': "{2},{1}".format(*dl), 'DASHDOT': "{2},{1},{0},{1}".format(*dl)}
1294 def format_color(vec):
1295 return "#{:02x}{:02x}{:02x}".format(round(vec[0] * 255), round(vec[1] * 255), round(vec[2] * 255))
1297 def format_matrix(matrix):
1298 return " ".join("{:.6f}".format(cell) for column in matrix for cell in column)
1300 def path_convert(string, relto=os_path.dirname(filename)):
1301 assert(os_path) # check the module was imported
1302 string = os_path.relpath(string, relto)
1303 if os_path.sep != '/':
1304 string = string.replace(os_path.sep, '/')
1305 return string
1307 styleargs = {
1308 name: format_color(getattr(self.style, name)) for name in (
1309 "outer_color", "outbg_color", "convex_color", "concave_color", "freestyle_color",
1310 "inbg_color", "sticker_fill", "text_color")}
1311 styleargs.update({
1312 name: format_style[getattr(self.style, name)] for name in
1313 ("outer_style", "convex_style", "concave_style", "freestyle_style")})
1314 styleargs.update({
1315 name: getattr(self.style, attr)[3] for name, attr in (
1316 ("outer_alpha", "outer_color"), ("outbg_alpha", "outbg_color"),
1317 ("convex_alpha", "convex_color"), ("concave_alpha", "concave_color"),
1318 ("freestyle_alpha", "freestyle_color"),
1319 ("inbg_alpha", "inbg_color"), ("sticker_alpha", "sticker_fill"),
1320 ("text_alpha", "text_color"))})
1321 styleargs.update({
1322 name: getattr(self.style, name) * self.style.line_width * 1000 for name in
1323 ("outer_width", "convex_width", "concave_width", "freestyle_width", "outbg_width", "inbg_width")})
1324 for num, page in enumerate(mesh.pages):
1325 page_filename = "{}_{}.svg".format(filename[:filename.rfind(".svg")], page.name) if len(mesh.pages) > 1 else filename
1326 with open(page_filename, 'w') as f:
1327 print(self.svg_base.format(width=self.page_size.x*1000, height=self.page_size.y*1000), file=f)
1328 print(self.css_base.format(**styleargs), file=f)
1329 if page.image_path:
1330 print(
1331 self.image_linked_tag.format(
1332 pos="{0:.6f} {0:.6f}".format(self.margin*1000),
1333 width=(self.page_size.x - 2 * self.margin)*1000,
1334 height=(self.page_size.y - 2 * self.margin)*1000,
1335 path=path_convert(page.image_path)),
1336 file=f)
1337 if len(page.islands) > 1:
1338 print("<g>", file=f)
1340 for island in page.islands:
1341 print("<g>", file=f)
1342 if island.image_path:
1343 print(
1344 self.image_linked_tag.format(
1345 pos=self.format_vertex(island.pos + M.Vector((0, island.bounding_box.y))),
1346 width=island.bounding_box.x*1000,
1347 height=island.bounding_box.y*1000,
1348 path=path_convert(island.image_path)),
1349 file=f)
1350 elif island.embedded_image:
1351 print(
1352 self.image_embedded_tag.format(
1353 pos=self.format_vertex(island.pos + M.Vector((0, island.bounding_box.y))),
1354 width=island.bounding_box.x*1000,
1355 height=island.bounding_box.y*1000,
1356 path=island.image_path),
1357 island.embedded_image, "'/>",
1358 file=f, sep="")
1359 if island.title:
1360 print(
1361 self.text_tag.format(
1362 size=1000 * self.text_size,
1363 x=1000 * (island.bounding_box.x*0.5 + island.pos.x + self.margin),
1364 y=1000 * (self.page_size.y - island.pos.y - self.margin - 0.2 * self.text_size),
1365 label=island.title),
1366 file=f)
1368 data_markers, data_stickerfill, data_outer, data_convex, data_concave, data_freestyle = (list() for i in range(6))
1369 for marker in island.markers:
1370 if isinstance(marker, Sticker):
1371 data_stickerfill.append("M {} Z".format(
1372 line_through(self.format_vertex(vertex.co, island.pos) for vertex in marker.vertices)))
1373 if marker.text:
1374 data_markers.append(self.text_transformed_tag.format(
1375 label=marker.text,
1376 pos=self.format_vertex(marker.center, island.pos),
1377 mat=format_matrix(marker.rot),
1378 size=marker.width * 1000))
1379 elif isinstance(marker, Arrow):
1380 size = marker.size * 1000
1381 position = marker.center + marker.size * marker.rot @ M.Vector((0, -0.9))
1382 data_markers.append(self.arrow_marker_tag.format(
1383 index=marker.text,
1384 arrow_pos=self.format_vertex(marker.center, island.pos),
1385 scale=size,
1386 pos=self.format_vertex(position, island.pos - marker.size*M.Vector((0, 0.4))),
1387 mat=format_matrix(size * marker.rot)))
1388 elif isinstance(marker, NumberAlone):
1389 data_markers.append(self.text_transformed_tag.format(
1390 label=marker.text,
1391 pos=self.format_vertex(marker.center, island.pos),
1392 mat=format_matrix(marker.rot),
1393 size=marker.size * 1000))
1394 if data_stickerfill and self.style.sticker_fill[3] > 0:
1395 print("<path class='sticker' d='", rows(data_stickerfill), "'/>", file=f)
1397 outer_edges = set(island.boundary)
1398 while outer_edges:
1399 data_loop = list()
1400 uvedge = outer_edges.pop()
1401 while 1:
1402 if uvedge.sticker:
1403 data_loop.extend(self.format_vertex(vertex.co, island.pos) for vertex in uvedge.sticker.vertices[1:])
1404 else:
1405 vertex = uvedge.vb if uvedge.uvface.flipped else uvedge.va
1406 data_loop.append(self.format_vertex(vertex.co, island.pos))
1407 uvedge = uvedge.neighbor_right
1408 try:
1409 outer_edges.remove(uvedge)
1410 except KeyError:
1411 break
1412 data_outer.append("M {} Z".format(line_through(data_loop)))
1414 visited_edges = set()
1415 for loop, uvedge in island.edges.items():
1416 edge = mesh.edges[loop.edge]
1417 if edge.is_cut(uvedge.uvface.face) and not uvedge.sticker:
1418 continue
1419 data_uvedge = "M {}".format(
1420 line_through(self.format_vertex(vertex.co, island.pos) for vertex in (uvedge.va, uvedge.vb)))
1421 if edge.freestyle:
1422 data_freestyle.append(data_uvedge)
1423 # each uvedge is in two opposite-oriented variants; we want to add each only once
1424 vertex_pair = frozenset((uvedge.va, uvedge.vb))
1425 if vertex_pair not in visited_edges:
1426 visited_edges.add(vertex_pair)
1427 if edge.angle > self.angle_epsilon:
1428 data_convex.append(data_uvedge)
1429 elif edge.angle < -self.angle_epsilon:
1430 data_concave.append(data_uvedge)
1431 if island.is_inside_out:
1432 data_convex, data_concave = data_concave, data_convex
1434 if data_freestyle:
1435 print("<path class='freestyle' d='", rows(data_freestyle), "'/>", file=f)
1436 if (data_convex or data_concave) and not self.pure_net and self.style.use_inbg:
1437 print("<path class='inner_background' d='", rows(data_convex + data_concave), "'/>", file=f)
1438 if data_convex:
1439 print("<path class='convex' d='", rows(data_convex), "'/>", file=f)
1440 if data_concave:
1441 print("<path class='concave' d='", rows(data_concave), "'/>", file=f)
1442 if data_outer:
1443 if not self.pure_net and self.style.use_outbg:
1444 print("<path class='outer_background' d='", rows(data_outer), "'/>", file=f)
1445 print("<path class='outer' d='", rows(data_outer), "'/>", file=f)
1446 if data_markers:
1447 print(rows(data_markers), file=f)
1448 print("</g>", file=f)
1450 if len(page.islands) > 1:
1451 print("</g>", file=f)
1452 print("</svg>", file=f)
1454 image_linked_tag = "<image transform='translate({pos})' width='{width:.6f}' height='{height:.6f}' xlink:href='{path}'/>"
1455 image_embedded_tag = "<image transform='translate({pos})' width='{width:.6f}' height='{height:.6f}' xlink:href='data:image/png;base64,"
1456 text_tag = "<text transform='translate({x} {y})' style='font-size:{size:.2f}'><tspan>{label}</tspan></text>"
1457 text_transformed_tag = "<text transform='matrix({mat} {pos})' style='font-size:{size:.2f}'><tspan>{label}</tspan></text>"
1458 arrow_marker_tag = "<g><path transform='matrix({mat} {arrow_pos})' class='arrow' d='M 0 0 L 1 1 L 0 0.25 L -1 1 Z'/>" \
1459 "<text transform='translate({pos})' style='font-size:{scale:.2f}'><tspan>{index}</tspan></text></g>"
1461 svg_base = """<?xml version='1.0' encoding='UTF-8' standalone='no'?>
1462 <svg xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' version='1.1'
1463 width='{width:.2f}mm' height='{height:.2f}mm' viewBox='0 0 {width:.2f} {height:.2f}'>"""
1465 css_base = """<style type="text/css">
1466 path {{
1467 fill: none;
1468 stroke-linecap: butt;
1469 stroke-linejoin: bevel;
1470 stroke-dasharray: none;
1472 path.outer {{
1473 stroke: {outer_color};
1474 stroke-dasharray: {outer_style};
1475 stroke-dashoffset: 0;
1476 stroke-width: {outer_width:.2};
1477 stroke-opacity: {outer_alpha:.2};
1479 path.convex {{
1480 stroke: {convex_color};
1481 stroke-dasharray: {convex_style};
1482 stroke-dashoffset:0;
1483 stroke-width:{convex_width:.2};
1484 stroke-opacity: {convex_alpha:.2}
1486 path.concave {{
1487 stroke: {concave_color};
1488 stroke-dasharray: {concave_style};
1489 stroke-dashoffset: 0;
1490 stroke-width: {concave_width:.2};
1491 stroke-opacity: {concave_alpha:.2}
1493 path.freestyle {{
1494 stroke: {freestyle_color};
1495 stroke-dasharray: {freestyle_style};
1496 stroke-dashoffset: 0;
1497 stroke-width: {freestyle_width:.2};
1498 stroke-opacity: {freestyle_alpha:.2}
1500 path.outer_background {{
1501 stroke: {outbg_color};
1502 stroke-opacity: {outbg_alpha};
1503 stroke-width: {outbg_width:.2}
1505 path.inner_background {{
1506 stroke: {inbg_color};
1507 stroke-opacity: {inbg_alpha};
1508 stroke-width: {inbg_width:.2}
1510 path.sticker {{
1511 fill: {sticker_fill};
1512 stroke: none;
1513 fill-opacity: {sticker_alpha:.2};
1515 path.arrow {{
1516 fill: {text_color};
1518 text {{
1519 font-style: normal;
1520 fill: {text_color};
1521 fill-opacity: {text_alpha:.2};
1522 stroke: none;
1524 text, tspan {{
1525 text-anchor:middle;
1527 </style>"""
1530 class PDF:
1531 """Simple PDF exporter"""
1533 mm_to_pt = 72 / 25.4
1534 character_width_packed = {
1535 191: "'", 222: 'ijl\x82\x91\x92', 278: '|¦\x00\x01\x02\x03\x04\x05\x06\x07\x08\t\n\x0b\x0c\r\x0e\x0f\x10\x11\x12\x13\x14\x15\x16\x17\x18\x19\x1a\x1b\x1c\x1d\x1e\x1f !,./:;I[\\]ft\xa0·ÌÍÎÏìíîï',
1536 333: '()-`r\x84\x88\x8b\x93\x94\x98\x9b¡¨\xad¯²³´¸¹{}', 350: '\x7f\x81\x8d\x8f\x90\x95\x9d', 365: '"ºª*°', 469: '^', 500: 'Jcksvxyz\x9a\x9eçýÿ', 584: '¶+<=>~¬±×÷', 611: 'FTZ\x8e¿ßø',
1537 667: '&ABEKPSVXY\x8a\x9fÀÁÂÃÄÅÈÉÊËÝÞ', 722: 'CDHNRUwÇÐÑÙÚÛÜ', 737: '©®', 778: 'GOQÒÓÔÕÖØ', 833: 'Mm¼½¾', 889: '%æ', 944: 'W\x9c', 1000: '\x85\x89\x8c\x97\x99Æ', 1015: '@', }
1538 character_width = {c: value for (value, chars) in character_width_packed.items() for c in chars}
1540 def __init__(self, page_size: M.Vector, style, margin, pure_net=True, angle_epsilon=0.01):
1541 self.page_size = page_size
1542 self.style = style
1543 self.margin = M.Vector((margin, margin))
1544 self.pure_net = pure_net
1545 self.angle_epsilon = angle_epsilon
1547 def text_width(self, text, scale=None):
1548 return (scale or self.text_size) * sum(self.character_width.get(c, 556) for c in text) / 1000
1550 @classmethod
1551 def encode_image(cls, bpy_image):
1552 data = bytes(int(255 * px) for (i, px) in enumerate(bpy_image.pixels) if i % 4 != 3)
1553 image = {
1554 "Type": "XObject", "Subtype": "Image", "Width": bpy_image.size[0], "Height": bpy_image.size[1],
1555 "ColorSpace": "DeviceRGB", "BitsPerComponent": 8, "Interpolate": True,
1556 "Filter": ["ASCII85Decode", "FlateDecode"], "stream": data}
1557 return image
1559 def write(self, mesh, filename):
1560 def format_dict(obj, refs=tuple()):
1561 return "<< " + "".join("/{} {}\n".format(key, format_value(value, refs)) for (key, value) in obj.items()) + ">>"
1563 def line_through(seq):
1564 return "".join("{0.x:.6f} {0.y:.6f} {1} ".format(1000*v.co, c) for (v, c) in zip(seq, chain("m", repeat("l"))))
1566 def format_value(value, refs=tuple()):
1567 if value in refs:
1568 return "{} 0 R".format(refs.index(value) + 1)
1569 elif type(value) is dict:
1570 return format_dict(value, refs)
1571 elif type(value) in (list, tuple):
1572 return "[ " + " ".join(format_value(item, refs) for item in value) + " ]"
1573 elif type(value) is int:
1574 return str(value)
1575 elif type(value) is float:
1576 return "{:.6f}".format(value)
1577 elif type(value) is bool:
1578 return "true" if value else "false"
1579 else:
1580 return "/{}".format(value) # this script can output only PDF names, no strings
1582 def write_object(index, obj, refs, f, stream=None):
1583 byte_count = f.write("{} 0 obj\n".format(index))
1584 if type(obj) is not dict:
1585 stream, obj = obj, dict()
1586 elif "stream" in obj:
1587 stream = obj.pop("stream")
1588 if stream:
1589 if True or type(stream) is bytes:
1590 obj["Filter"] = ["ASCII85Decode", "FlateDecode"]
1591 stream = encode(stream)
1592 obj["Length"] = len(stream)
1593 byte_count += f.write(format_dict(obj, refs))
1594 if stream:
1595 byte_count += f.write("\nstream\n")
1596 byte_count += f.write(stream)
1597 byte_count += f.write("\nendstream")
1598 return byte_count + f.write("\nendobj\n")
1600 def encode(data):
1601 from base64 import a85encode
1602 from zlib import compress
1603 if hasattr(data, "encode"):
1604 data = data.encode()
1605 return a85encode(compress(data), adobe=True, wrapcol=250)[2:].decode()
1607 page_size_pt = 1000 * self.mm_to_pt * self.page_size
1608 root = {"Type": "Pages", "MediaBox": [0, 0, page_size_pt.x, page_size_pt.y], "Kids": list()}
1609 catalog = {"Type": "Catalog", "Pages": root}
1610 font = {
1611 "Type": "Font", "Subtype": "Type1", "Name": "F1",
1612 "BaseFont": "Helvetica", "Encoding": "MacRomanEncoding"}
1614 dl = [length * self.style.line_width * 1000 for length in (1, 4, 9)]
1615 format_style = {
1616 'SOLID': list(), 'DOT': [dl[0], dl[1]], 'DASH': [dl[1], dl[2]],
1617 'LONGDASH': [dl[2], dl[1]], 'DASHDOT': [dl[2], dl[1], dl[0], dl[1]]}
1618 styles = {
1619 "Gtext": {"ca": self.style.text_color[3], "Font": [font, 1000 * self.text_size]},
1620 "Gsticker": {"ca": self.style.sticker_fill[3]}}
1621 for name in ("outer", "convex", "concave", "freestyle"):
1622 gs = {
1623 "LW": self.style.line_width * 1000 * getattr(self.style, name + "_width"),
1624 "CA": getattr(self.style, name + "_color")[3],
1625 "D": [format_style[getattr(self.style, name + "_style")], 0]}
1626 styles["G" + name] = gs
1627 for name in ("outbg", "inbg"):
1628 gs = {
1629 "LW": self.style.line_width * 1000 * getattr(self.style, name + "_width"),
1630 "CA": getattr(self.style, name + "_color")[3],
1631 "D": [format_style['SOLID'], 0]}
1632 styles["G" + name] = gs
1634 objects = [root, catalog, font]
1635 objects.extend(styles.values())
1637 for page in mesh.pages:
1638 commands = ["{0:.6f} 0 0 {0:.6f} 0 0 cm".format(self.mm_to_pt)]
1639 resources = {"Font": {"F1": font}, "ExtGState": styles, "XObject": dict()}
1640 for island in page.islands:
1641 commands.append("q 1 0 0 1 {0.x:.6f} {0.y:.6f} cm".format(1000*(self.margin + island.pos)))
1642 if island.embedded_image:
1643 identifier = "Im{}".format(len(resources["XObject"]) + 1)
1644 commands.append(self.command_image.format(1000 * island.bounding_box, identifier))
1645 objects.append(island.embedded_image)
1646 resources["XObject"][identifier] = island.embedded_image
1648 if island.title:
1649 commands.append(self.command_label.format(
1650 size=1000*self.text_size,
1651 x=500 * (island.bounding_box.x - self.text_width(island.title)),
1652 y=1000 * 0.2 * self.text_size,
1653 label=island.title))
1655 data_markers, data_stickerfill, data_outer, data_convex, data_concave, data_freestyle = (list() for i in range(6))
1656 for marker in island.markers:
1657 if isinstance(marker, Sticker):
1658 data_stickerfill.append(line_through(marker.vertices) + "f")
1659 if marker.text:
1660 data_markers.append(self.command_sticker.format(
1661 label=marker.text,
1662 pos=1000*marker.center,
1663 mat=marker.rot,
1664 align=-500 * self.text_width(marker.text, marker.width),
1665 size=1000*marker.width))
1666 elif isinstance(marker, Arrow):
1667 size = 1000 * marker.size
1668 position = 1000 * (marker.center + marker.size * marker.rot @ M.Vector((0, -0.9)))
1669 data_markers.append(self.command_arrow.format(
1670 index=marker.text,
1671 arrow_pos=1000 * marker.center,
1672 pos=position - 1000 * M.Vector((0.5 * self.text_width(marker.text), 0.4 * self.text_size)),
1673 mat=size * marker.rot,
1674 size=size))
1675 elif isinstance(marker, NumberAlone):
1676 data_markers.append(self.command_number.format(
1677 label=marker.text,
1678 pos=1000*marker.center,
1679 mat=marker.rot,
1680 size=1000*marker.size))
1682 outer_edges = set(island.boundary)
1683 while outer_edges:
1684 data_loop = list()
1685 uvedge = outer_edges.pop()
1686 while 1:
1687 if uvedge.sticker:
1688 data_loop.extend(uvedge.sticker.vertices[1:])
1689 else:
1690 vertex = uvedge.vb if uvedge.uvface.flipped else uvedge.va
1691 data_loop.append(vertex)
1692 uvedge = uvedge.neighbor_right
1693 try:
1694 outer_edges.remove(uvedge)
1695 except KeyError:
1696 break
1697 data_outer.append(line_through(data_loop) + "s")
1699 for loop, uvedge in island.edges.items():
1700 edge = mesh.edges[loop.edge]
1701 if edge.is_cut(uvedge.uvface.face) and not uvedge.sticker:
1702 continue
1703 data_uvedge = line_through((uvedge.va, uvedge.vb)) + "S"
1704 if edge.freestyle:
1705 data_freestyle.append(data_uvedge)
1706 # each uvedge exists in two opposite-oriented variants; we want to add each only once
1707 if uvedge.sticker or uvedge.uvface.flipped != (id(uvedge.va) > id(uvedge.vb)):
1708 if edge.angle > self.angle_epsilon:
1709 data_convex.append(data_uvedge)
1710 elif edge.angle < -self.angle_epsilon:
1711 data_concave.append(data_uvedge)
1712 if island.is_inside_out:
1713 data_convex, data_concave = data_concave, data_convex
1715 if data_stickerfill and self.style.sticker_fill[3] > 0:
1716 commands.append("/Gsticker gs {0[0]:.3f} {0[1]:.3f} {0[2]:.3f} rg".format(self.style.sticker_fill))
1717 commands.extend(data_stickerfill)
1718 if data_freestyle:
1719 commands.append("/Gfreestyle gs {0[0]:.3f} {0[1]:.3f} {0[2]:.3f} RG".format(self.style.freestyle_color))
1720 commands.extend(data_freestyle)
1721 if (data_convex or data_concave) and not self.pure_net and self.style.use_inbg:
1722 commands.append("/Ginbg gs {0[0]:.3f} {0[1]:.3f} {0[2]:.3f} RG".format(self.style.inbg_color))
1723 commands.extend(chain(data_convex, data_concave))
1724 if data_convex:
1725 commands.append("/Gconvex gs {0[0]:.3f} {0[1]:.3f} {0[2]:.3f} RG".format(self.style.convex_color))
1726 commands.extend(data_convex)
1727 if data_concave:
1728 commands.append("/Gconcave gs {0[0]:.3f} {0[1]:.3f} {0[2]:.3f} RG".format(self.style.concave_color))
1729 commands.extend(data_concave)
1730 if data_outer:
1731 if not self.pure_net and self.style.use_outbg:
1732 commands.append("/Goutbg gs {0[0]:.3f} {0[1]:.3f} {0[2]:.3f} RG".format(self.style.outbg_color))
1733 commands.extend(data_outer)
1734 commands.append("/Gouter gs {0[0]:.3f} {0[1]:.3f} {0[2]:.3f} RG".format(self.style.outer_color))
1735 commands.extend(data_outer)
1736 commands.append("/Gtext gs {0[0]:.3f} {0[1]:.3f} {0[2]:.3f} rg".format(self.style.text_color))
1737 commands.extend(data_markers)
1738 commands.append("Q")
1739 content = "\n".join(commands)
1740 page = {"Type": "Page", "Parent": root, "Contents": content, "Resources": resources}
1741 root["Kids"].append(page)
1742 objects.extend((page, content))
1744 root["Count"] = len(root["Kids"])
1745 with open(filename, "w+") as f:
1746 xref_table = list()
1747 position = f.write("%PDF-1.4\n")
1748 for index, obj in enumerate(objects, 1):
1749 xref_table.append(position)
1750 position += write_object(index, obj, objects, f)
1751 xref_pos = position
1752 f.write("xref_table\n0 {}\n".format(len(xref_table) + 1))
1753 f.write("{:010} {:05} f\n".format(0, 65536))
1754 for position in xref_table:
1755 f.write("{:010} {:05} n\n".format(position, 0))
1756 f.write("trailer\n")
1757 f.write(format_dict({"Size": len(xref_table), "Root": catalog}, objects))
1758 f.write("\nstartxref\n{}\n%%EOF\n".format(xref_pos))
1760 command_label = "/Gtext gs BT {x:.6f} {y:.6f} Td ({label}) Tj ET"
1761 command_image = "q {0.x:.6f} 0 0 {0.y:.6f} 0 0 cm 1 0 0 -1 0 1 cm /{1} Do Q"
1762 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"
1763 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"
1764 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"
1767 class Unfold(bpy.types.Operator):
1768 """Blender Operator: unfold the selected object."""
1770 bl_idname = "mesh.unfold"
1771 bl_label = "Unfold"
1772 bl_description = "Mark seams so that the mesh can be exported as a paper model"
1773 bl_options = {'REGISTER', 'UNDO'}
1774 edit: bpy.props.BoolProperty(default=False, options={'HIDDEN'})
1775 priority_effect_convex: bpy.props.FloatProperty(
1776 name="Priority Convex", description="Priority effect for edges in convex angles",
1777 default=default_priority_effect['CONVEX'], soft_min=-1, soft_max=10, subtype='FACTOR')
1778 priority_effect_concave: bpy.props.FloatProperty(
1779 name="Priority Concave", description="Priority effect for edges in concave angles",
1780 default=default_priority_effect['CONCAVE'], soft_min=-1, soft_max=10, subtype='FACTOR')
1781 priority_effect_length: bpy.props.FloatProperty(
1782 name="Priority Length", description="Priority effect of edge length",
1783 default=default_priority_effect['LENGTH'], soft_min=-10, soft_max=1, subtype='FACTOR')
1784 do_create_uvmap: bpy.props.BoolProperty(
1785 name="Create UVMap", description="Create a new UV Map showing the islands and page layout", default=False)
1786 object = None
1788 @classmethod
1789 def poll(cls, context):
1790 return context.active_object and context.active_object.type == "MESH"
1792 def draw(self, context):
1793 layout = self.layout
1794 col = layout.column()
1795 col.active = not self.object or len(self.object.data.uv_layers) < 8
1796 col.prop(self.properties, "do_create_uvmap")
1797 layout.label(text="Edge Cutting Factors:")
1798 col = layout.column(align=True)
1799 col.label(text="Face Angle:")
1800 col.prop(self.properties, "priority_effect_convex", text="Convex")
1801 col.prop(self.properties, "priority_effect_concave", text="Concave")
1802 layout.prop(self.properties, "priority_effect_length", text="Edge Length")
1804 def execute(self, context):
1805 sce = bpy.context.scene
1806 settings = sce.paper_model
1807 recall_mode = context.object.mode
1808 bpy.ops.object.mode_set(mode='EDIT')
1810 self.object = context.object
1812 cage_size = M.Vector((settings.output_size_x, settings.output_size_y))
1813 priority_effect = {
1814 'CONVEX': self.priority_effect_convex,
1815 'CONCAVE': self.priority_effect_concave,
1816 'LENGTH': self.priority_effect_length}
1817 try:
1818 unfolder = Unfolder(self.object)
1819 unfolder.do_create_uvmap = self.do_create_uvmap
1820 scale = sce.unit_settings.scale_length / settings.scale
1821 unfolder.prepare(cage_size, priority_effect, scale, settings.limit_by_page)
1822 unfolder.mesh.mark_cuts()
1823 except UnfoldError as error:
1824 self.report(type={'ERROR_INVALID_INPUT'}, message=error.args[0])
1825 error.mesh_select()
1826 bpy.ops.object.mode_set(mode=recall_mode)
1827 return {'CANCELLED'}
1828 mesh = self.object.data
1829 mesh.update()
1830 if mesh.paper_island_list:
1831 unfolder.copy_island_names(mesh.paper_island_list)
1832 island_list = mesh.paper_island_list
1833 attributes = {item.label: (item.abbreviation, item.auto_label, item.auto_abbrev) for item in island_list}
1834 island_list.clear() # remove previously defined islands
1835 for island in unfolder.mesh.islands:
1836 # add islands to UI list and set default descriptions
1837 list_item = island_list.add()
1838 # add faces' IDs to the island
1839 for face in island.faces:
1840 lface = list_item.faces.add()
1841 lface.id = face.index
1842 list_item["label"] = island.label
1843 list_item["abbreviation"], list_item["auto_label"], list_item["auto_abbrev"] = attributes.get(
1844 island.label,
1845 (island.abbreviation, True, True))
1846 island_item_changed(list_item, context)
1847 mesh.paper_island_index = -1
1849 del unfolder
1850 bpy.ops.object.mode_set(mode=recall_mode)
1851 return {'FINISHED'}
1854 class ClearAllSeams(bpy.types.Operator):
1855 """Blender Operator: clear all seams of the active Mesh and all its unfold data"""
1857 bl_idname = "mesh.clear_all_seams"
1858 bl_label = "Clear All Seams"
1859 bl_description = "Clear all the seams and unfolded islands of the active object"
1861 @classmethod
1862 def poll(cls, context):
1863 return context.active_object and context.active_object.type == 'MESH'
1865 def execute(self, context):
1866 ob = context.active_object
1867 mesh = ob.data
1869 for edge in mesh.edges:
1870 edge.use_seam = False
1871 mesh.paper_island_list.clear()
1873 return {'FINISHED'}
1876 def page_size_preset_changed(self, context):
1877 """Update the actual document size to correct values"""
1878 if hasattr(self, "limit_by_page") and not self.limit_by_page:
1879 return
1880 if self.page_size_preset == 'A4':
1881 self.output_size_x = 0.210
1882 self.output_size_y = 0.297
1883 elif self.page_size_preset == 'A3':
1884 self.output_size_x = 0.297
1885 self.output_size_y = 0.420
1886 elif self.page_size_preset == 'US_LETTER':
1887 self.output_size_x = 0.216
1888 self.output_size_y = 0.279
1889 elif self.page_size_preset == 'US_LEGAL':
1890 self.output_size_x = 0.216
1891 self.output_size_y = 0.356
1894 class PaperModelStyle(bpy.types.PropertyGroup):
1895 line_styles = [
1896 ('SOLID', "Solid (----)", "Solid line"),
1897 ('DOT', "Dots (. . .)", "Dotted line"),
1898 ('DASH', "Short Dashes (- - -)", "Solid line"),
1899 ('LONGDASH', "Long Dashes (-- --)", "Solid line"),
1900 ('DASHDOT', "Dash-dotted (-- .)", "Solid line")
1902 outer_color: bpy.props.FloatVectorProperty(
1903 name="Outer Lines", description="Color of net outline",
1904 default=(0.0, 0.0, 0.0, 1.0), min=0, max=1, subtype='COLOR', size=4)
1905 outer_style: bpy.props.EnumProperty(
1906 name="Outer Lines Drawing Style", description="Drawing style of net outline",
1907 default='SOLID', items=line_styles)
1908 line_width: bpy.props.FloatProperty(
1909 name="Base Lines Thickness", description="Base thickness of net lines, each actual value is a multiple of this length",
1910 default=1e-4, min=0, soft_max=5e-3, precision=5, step=1e-2, subtype="UNSIGNED", unit="LENGTH")
1911 outer_width: bpy.props.FloatProperty(
1912 name="Outer Lines Thickness", description="Relative thickness of net outline",
1913 default=3, min=0, soft_max=10, precision=1, step=10, subtype='FACTOR')
1914 use_outbg: bpy.props.BoolProperty(
1915 name="Highlight Outer Lines", description="Add another line below every line to improve contrast",
1916 default=True)
1917 outbg_color: bpy.props.FloatVectorProperty(
1918 name="Outer Highlight", description="Color of the highlight for outer lines",
1919 default=(1.0, 1.0, 1.0, 1.0), min=0, max=1, subtype='COLOR', size=4)
1920 outbg_width: bpy.props.FloatProperty(
1921 name="Outer Highlight Thickness", description="Relative thickness of the highlighting lines",
1922 default=5, min=0, soft_max=10, precision=1, step=10, subtype='FACTOR')
1924 convex_color: bpy.props.FloatVectorProperty(
1925 name="Inner Convex Lines", description="Color of lines to be folded to a convex angle",
1926 default=(0.0, 0.0, 0.0, 1.0), min=0, max=1, subtype='COLOR', size=4)
1927 convex_style: bpy.props.EnumProperty(
1928 name="Convex Lines Drawing Style", description="Drawing style of lines to be folded to a convex angle",
1929 default='DASH', items=line_styles)
1930 convex_width: bpy.props.FloatProperty(
1931 name="Convex Lines Thickness", description="Relative thickness of concave lines",
1932 default=2, min=0, soft_max=10, precision=1, step=10, subtype='FACTOR')
1933 concave_color: bpy.props.FloatVectorProperty(
1934 name="Inner Concave Lines", description="Color of lines to be folded to a concave angle",
1935 default=(0.0, 0.0, 0.0, 1.0), min=0, max=1, subtype='COLOR', size=4)
1936 concave_style: bpy.props.EnumProperty(
1937 name="Concave Lines Drawing Style", description="Drawing style of lines to be folded to a concave angle",
1938 default='DASHDOT', items=line_styles)
1939 concave_width: bpy.props.FloatProperty(
1940 name="Concave Lines Thickness", description="Relative thickness of concave lines",
1941 default=2, min=0, soft_max=10, precision=1, step=10, subtype='FACTOR')
1942 freestyle_color: bpy.props.FloatVectorProperty(
1943 name="Freestyle Edges", description="Color of lines marked as Freestyle Edge",
1944 default=(0.0, 0.0, 0.0, 1.0), min=0, max=1, subtype='COLOR', size=4)
1945 freestyle_style: bpy.props.EnumProperty(
1946 name="Freestyle Edges Drawing Style", description="Drawing style of Freestyle Edges",
1947 default='SOLID', items=line_styles)
1948 freestyle_width: bpy.props.FloatProperty(
1949 name="Freestyle Edges Thickness", description="Relative thickness of Freestyle edges",
1950 default=2, min=0, soft_max=10, precision=1, step=10, subtype='FACTOR')
1951 use_inbg: bpy.props.BoolProperty(
1952 name="Highlight Inner Lines", description="Add another line below every line to improve contrast",
1953 default=True)
1954 inbg_color: bpy.props.FloatVectorProperty(
1955 name="Inner Highlight", description="Color of the highlight for inner lines",
1956 default=(1.0, 1.0, 1.0, 1.0), min=0, max=1, subtype='COLOR', size=4)
1957 inbg_width: bpy.props.FloatProperty(
1958 name="Inner Highlight Thickness", description="Relative thickness of the highlighting lines",
1959 default=2, min=0, soft_max=10, precision=1, step=10, subtype='FACTOR')
1961 sticker_fill: bpy.props.FloatVectorProperty(
1962 name="Tabs Fill", description="Fill color of sticking tabs",
1963 default=(0.9, 0.9, 0.9, 1.0), min=0, max=1, subtype='COLOR', size=4)
1964 text_color: bpy.props.FloatVectorProperty(
1965 name="Text Color", description="Color of all text used in the document",
1966 default=(0.0, 0.0, 0.0, 1.0), min=0, max=1, subtype='COLOR', size=4)
1967 bpy.utils.register_class(PaperModelStyle)
1970 class ExportPaperModel(bpy.types.Operator):
1971 """Blender Operator: save the selected object's net and optionally bake its texture"""
1973 bl_idname = "export_mesh.paper_model"
1974 bl_label = "Export Paper Model"
1975 bl_description = "Export the selected object's net and optionally bake its texture"
1976 filepath: bpy.props.StringProperty(
1977 name="File Path", description="Target file to save the SVG", options={'SKIP_SAVE'})
1978 filename: bpy.props.StringProperty(
1979 name="File Name", description="Name of the file", options={'SKIP_SAVE'})
1980 directory: bpy.props.StringProperty(
1981 name="Directory", description="Directory of the file", options={'SKIP_SAVE'})
1982 page_size_preset: bpy.props.EnumProperty(
1983 name="Page Size", description="Size of the exported document",
1984 default='A4', update=page_size_preset_changed, items=global_paper_sizes)
1985 output_size_x: bpy.props.FloatProperty(
1986 name="Page Width", description="Width of the exported document",
1987 default=0.210, soft_min=0.105, soft_max=0.841, subtype="UNSIGNED", unit="LENGTH")
1988 output_size_y: bpy.props.FloatProperty(
1989 name="Page Height", description="Height of the exported document",
1990 default=0.297, soft_min=0.148, soft_max=1.189, subtype="UNSIGNED", unit="LENGTH")
1991 output_margin: bpy.props.FloatProperty(
1992 name="Page Margin", description="Distance from page borders to the printable area",
1993 default=0.005, min=0, soft_max=0.1, step=0.1, subtype="UNSIGNED", unit="LENGTH")
1994 output_type: bpy.props.EnumProperty(
1995 name="Textures", description="Source of a texture for the model",
1996 default='NONE', items=[
1997 ('NONE', "No Texture", "Export the net only"),
1998 ('TEXTURE', "From Materials", "Render the diffuse color and all painted textures"),
1999 ('AMBIENT_OCCLUSION', "Ambient Occlusion", "Render the Ambient Occlusion pass"),
2000 ('RENDER', "Full Render", "Render the material in actual scene illumination"),
2001 ('SELECTED_TO_ACTIVE', "Selected to Active", "Render all selected surrounding objects as a texture")
2003 do_create_stickers: bpy.props.BoolProperty(
2004 name="Create Tabs", description="Create gluing tabs around the net (useful for paper)",
2005 default=True)
2006 do_create_numbers: bpy.props.BoolProperty(
2007 name="Create Numbers", description="Enumerate edges to make it clear which edges should be sticked together",
2008 default=True)
2009 sticker_width: bpy.props.FloatProperty(
2010 name="Tabs and Text Size", description="Width of gluing tabs and their numbers",
2011 default=0.005, soft_min=0, soft_max=0.05, step=0.1, subtype="UNSIGNED", unit="LENGTH")
2012 angle_epsilon: bpy.props.FloatProperty(
2013 name="Hidden Edge Angle", description="Folds with angle below this limit will not be drawn",
2014 default=pi/360, min=0, soft_max=pi/4, step=0.01, subtype="ANGLE", unit="ROTATION")
2015 output_dpi: bpy.props.FloatProperty(
2016 name="Resolution (DPI)", description="Resolution of images in pixels per inch",
2017 default=90, min=1, soft_min=30, soft_max=600, subtype="UNSIGNED")
2018 bake_samples: bpy.props.IntProperty(
2019 name="Samples", description="Number of samples to render for each pixel",
2020 default=64, min=1, subtype="UNSIGNED")
2021 file_format: bpy.props.EnumProperty(
2022 name="Document Format", description="File format of the exported net",
2023 default='PDF', items=[
2024 ('PDF', "PDF", "Adobe Portable Document Format 1.4"),
2025 ('SVG', "SVG", "W3C Scalable Vector Graphics"),
2027 image_packing: bpy.props.EnumProperty(
2028 name="Image Packing Method", description="Method of attaching baked image(s) to the SVG",
2029 default='ISLAND_EMBED', items=[
2030 ('PAGE_LINK', "Single Linked", "Bake one image per page of output and save it separately"),
2031 ('ISLAND_LINK', "Linked", "Bake images separately for each island and save them in a directory"),
2032 ('ISLAND_EMBED', "Embedded", "Bake images separately for each island and embed them into the SVG")
2034 scale: bpy.props.FloatProperty(
2035 name="Scale", description="Divisor of all dimensions when exporting",
2036 default=1, soft_min=1.0, soft_max=100.0, subtype='FACTOR', precision=1)
2037 do_create_uvmap: bpy.props.BoolProperty(
2038 name="Create UVMap", description="Create a new UV Map showing the islands and page layout",
2039 default=False, options={'SKIP_SAVE'})
2040 ui_expanded_document: bpy.props.BoolProperty(
2041 name="Show Document Settings Expanded", description="Shows the box 'Document Settings' expanded in user interface",
2042 default=True, options={'SKIP_SAVE'})
2043 ui_expanded_style: bpy.props.BoolProperty(
2044 name="Show Style Settings Expanded", description="Shows the box 'Colors and Style' expanded in user interface",
2045 default=False, options={'SKIP_SAVE'})
2046 style: bpy.props.PointerProperty(type=PaperModelStyle)
2048 unfolder = None
2050 @classmethod
2051 def poll(cls, context):
2052 return context.active_object and context.active_object.type == 'MESH'
2054 def prepare(self, context):
2055 sce = context.scene
2056 self.recall_mode = context.object.mode
2057 bpy.ops.object.mode_set(mode='EDIT')
2059 self.object = context.active_object
2060 self.unfolder = Unfolder(self.object)
2061 cage_size = M.Vector((sce.paper_model.output_size_x, sce.paper_model.output_size_y))
2062 self.unfolder.prepare(cage_size, scale=sce.unit_settings.scale_length/self.scale, limit_by_page=sce.paper_model.limit_by_page)
2063 if self.scale == 1:
2064 self.scale = ceil(self.get_scale_ratio(sce))
2066 def recall(self):
2067 if self.unfolder:
2068 del self.unfolder
2069 bpy.ops.object.mode_set(mode=self.recall_mode)
2071 def invoke(self, context, event):
2072 self.scale = context.scene.paper_model.scale
2073 try:
2074 self.prepare(context)
2075 except UnfoldError as error:
2076 self.report(type={'ERROR_INVALID_INPUT'}, message=error.args[0])
2077 error.mesh_select()
2078 self.recall()
2079 return {'CANCELLED'}
2080 wm = context.window_manager
2081 wm.fileselect_add(self)
2082 return {'RUNNING_MODAL'}
2084 def execute(self, context):
2085 if not self.unfolder:
2086 self.prepare(context)
2087 self.unfolder.do_create_uvmap = self.do_create_uvmap
2088 try:
2089 if self.object.data.paper_island_list:
2090 self.unfolder.copy_island_names(self.object.data.paper_island_list)
2091 self.unfolder.save(self.properties)
2092 self.report({'INFO'}, "Saved a {}-page document".format(len(self.unfolder.mesh.pages)))
2093 return {'FINISHED'}
2094 except UnfoldError as error:
2095 self.report(type={'ERROR_INVALID_INPUT'}, message=error.args[0])
2096 return {'CANCELLED'}
2097 finally:
2098 self.recall()
2100 def get_scale_ratio(self, sce):
2101 margin = self.output_margin + self.sticker_width
2102 if min(self.output_size_x, self.output_size_y) <= 2 * margin:
2103 return False
2104 output_inner_size = M.Vector((self.output_size_x - 2*margin, self.output_size_y - 2*margin))
2105 ratio = self.unfolder.mesh.largest_island_ratio(output_inner_size)
2106 return ratio * sce.unit_settings.scale_length / self.scale
2108 def draw(self, context):
2109 layout = self.layout
2111 layout.prop(self.properties, "do_create_uvmap")
2113 row = layout.row(align=True)
2114 row.menu("VIEW3D_MT_paper_model_presets", text=bpy.types.VIEW3D_MT_paper_model_presets.bl_label)
2115 row.operator("export_mesh.paper_model_preset_add", text="", icon='ADD')
2116 row.operator("export_mesh.paper_model_preset_add", text="", icon='REMOVE').remove_active = True
2118 layout.prop(self.properties, "scale", text="Scale: 1/")
2119 scale_ratio = self.get_scale_ratio(context.scene)
2120 if scale_ratio > 1:
2121 layout.label(
2122 text="An island is roughly {:.1f}x bigger than page".format(scale_ratio),
2123 icon="ERROR")
2124 elif scale_ratio > 0:
2125 layout.label(text="Largest island is roughly 1/{:.1f} of page".format(1 / scale_ratio))
2127 if context.scene.unit_settings.scale_length != 1:
2128 layout.label(
2129 text="Unit scale {:.1f} makes page size etc. not display correctly".format(
2130 context.scene.unit_settings.scale_length), icon="ERROR")
2131 box = layout.box()
2132 row = box.row(align=True)
2133 row.prop(
2134 self.properties, "ui_expanded_document", text="",
2135 icon=('TRIA_DOWN' if self.ui_expanded_document else 'TRIA_RIGHT'), emboss=False)
2136 row.label(text="Document Settings")
2138 if self.ui_expanded_document:
2139 box.prop(self.properties, "file_format", text="Format")
2140 box.prop(self.properties, "page_size_preset")
2141 col = box.column(align=True)
2142 col.active = self.page_size_preset == 'USER'
2143 col.prop(self.properties, "output_size_x")
2144 col.prop(self.properties, "output_size_y")
2145 box.prop(self.properties, "output_margin")
2146 col = box.column()
2147 col.prop(self.properties, "do_create_stickers")
2148 col.prop(self.properties, "do_create_numbers")
2149 col = box.column()
2150 col.active = self.do_create_stickers or self.do_create_numbers
2151 col.prop(self.properties, "sticker_width")
2152 box.prop(self.properties, "angle_epsilon")
2154 box.prop(self.properties, "output_type")
2155 col = box.column()
2156 col.active = (self.output_type != 'NONE')
2157 if len(self.object.data.uv_layers) == 8:
2158 col.label(text="No UV slots left, No Texture is the only option.", icon='ERROR')
2159 elif context.scene.render.engine != 'CYCLES' and self.output_type != 'NONE':
2160 col.label(text="Cycles will be used for texture baking.", icon='ERROR')
2161 row = col.row()
2162 row.active = self.output_type in ('AMBIENT_OCCLUSION', 'RENDER', 'SELECTED_TO_ACTIVE')
2163 row.prop(self.properties, "bake_samples")
2164 col.prop(self.properties, "output_dpi")
2165 row = col.row()
2166 row.active = self.file_format == 'SVG'
2167 row.prop(self.properties, "image_packing", text="Images")
2169 box = layout.box()
2170 row = box.row(align=True)
2171 row.prop(
2172 self.properties, "ui_expanded_style", text="",
2173 icon=('TRIA_DOWN' if self.ui_expanded_style else 'TRIA_RIGHT'), emboss=False)
2174 row.label(text="Colors and Style")
2176 if self.ui_expanded_style:
2177 box.prop(self.style, "line_width", text="Default line width")
2178 col = box.column()
2179 col.prop(self.style, "outer_color")
2180 col.prop(self.style, "outer_width", text="Relative width")
2181 col.prop(self.style, "outer_style", text="Style")
2182 col = box.column()
2183 col.active = self.output_type != 'NONE'
2184 col.prop(self.style, "use_outbg", text="Outer Lines Highlight:")
2185 sub = col.column()
2186 sub.active = self.output_type != 'NONE' and self.style.use_outbg
2187 sub.prop(self.style, "outbg_color", text="")
2188 sub.prop(self.style, "outbg_width", text="Relative width")
2189 col = box.column()
2190 col.prop(self.style, "convex_color")
2191 col.prop(self.style, "convex_width", text="Relative width")
2192 col.prop(self.style, "convex_style", text="Style")
2193 col = box.column()
2194 col.prop(self.style, "concave_color")
2195 col.prop(self.style, "concave_width", text="Relative width")
2196 col.prop(self.style, "concave_style", text="Style")
2197 col = box.column()
2198 col.prop(self.style, "freestyle_color")
2199 col.prop(self.style, "freestyle_width", text="Relative width")
2200 col.prop(self.style, "freestyle_style", text="Style")
2201 col = box.column()
2202 col.active = self.output_type != 'NONE'
2203 col.prop(self.style, "use_inbg", text="Inner Lines Highlight:")
2204 sub = col.column()
2205 sub.active = self.output_type != 'NONE' and self.style.use_inbg
2206 sub.prop(self.style, "inbg_color", text="")
2207 sub.prop(self.style, "inbg_width", text="Relative width")
2208 col = box.column()
2209 col.active = self.do_create_stickers
2210 col.prop(self.style, "sticker_fill")
2211 box.prop(self.style, "text_color")
2214 def menu_func_export(self, context):
2215 self.layout.operator("export_mesh.paper_model", text="Paper Model (.pdf/.svg)")
2218 def menu_func_unfold(self, context):
2219 self.layout.operator("mesh.unfold", text="Unfold")
2222 class SelectIsland(bpy.types.Operator):
2223 """Blender Operator: select all faces of the active island"""
2225 bl_idname = "mesh.select_paper_island"
2226 bl_label = "Select Island"
2227 bl_description = "Select an island of the paper model net"
2229 operation: bpy.props.EnumProperty(
2230 name="Operation", description="Operation with the current selection",
2231 default='ADD', items=[
2232 ('ADD', "Add", "Add to current selection"),
2233 ('REMOVE', "Remove", "Remove from selection"),
2234 ('REPLACE', "Replace", "Select only the ")
2237 @classmethod
2238 def poll(cls, context):
2239 return context.active_object and context.active_object.type == 'MESH' and context.mode == 'EDIT_MESH'
2241 def execute(self, context):
2242 ob = context.active_object
2243 me = ob.data
2244 bm = bmesh.from_edit_mesh(me)
2245 island = me.paper_island_list[me.paper_island_index]
2246 faces = {face.id for face in island.faces}
2247 edges = set()
2248 verts = set()
2249 if self.operation == 'REPLACE':
2250 for face in bm.faces:
2251 selected = face.index in faces
2252 face.select = selected
2253 if selected:
2254 edges.update(face.edges)
2255 verts.update(face.verts)
2256 for edge in bm.edges:
2257 edge.select = edge in edges
2258 for vert in bm.verts:
2259 vert.select = vert in verts
2260 else:
2261 selected = (self.operation == 'ADD')
2262 for index in faces:
2263 face = bm.faces[index]
2264 face.select = selected
2265 edges.update(face.edges)
2266 verts.update(face.verts)
2267 for edge in edges:
2268 edge.select = any(face.select for face in edge.link_faces)
2269 for vert in verts:
2270 vert.select = any(edge.select for edge in vert.link_edges)
2271 bmesh.update_edit_mesh(me, False, False)
2272 return {'FINISHED'}
2275 class VIEW3D_MT_paper_model_presets(bpy.types.Menu):
2276 bl_label = "Paper Model Presets"
2277 preset_subdir = "export_mesh"
2278 preset_operator = "script.execute_preset"
2279 draw = bpy.types.Menu.draw_preset
2282 class AddPresetPaperModel(bl_operators.presets.AddPresetBase, bpy.types.Operator):
2283 """Add or remove a Paper Model Preset"""
2284 bl_idname = "export_mesh.paper_model_preset_add"
2285 bl_label = "Add Paper Model Preset"
2286 preset_menu = "VIEW3D_MT_paper_model_presets"
2287 preset_subdir = "export_mesh"
2288 preset_defines = ["op = bpy.context.active_operator"]
2290 @property
2291 def preset_values(self):
2292 op = bpy.ops.export_mesh.paper_model
2293 properties = op.get_rna().bl_rna.properties.items()
2294 blacklist = bpy.types.Operator.bl_rna.properties.keys()
2295 return [
2296 "op.{}".format(prop_id) for (prop_id, prop) in properties
2297 if not (prop.is_hidden or prop.is_skip_save or prop_id in blacklist)]
2300 class VIEW3D_PT_paper_model_tools(bpy.types.Panel):
2301 bl_space_type = 'VIEW_3D'
2302 bl_region_type = 'UI'
2303 bl_category = 'Paper'
2304 bl_label = "Unfold"
2306 def draw(self, context):
2307 layout = self.layout
2308 sce = context.scene
2309 obj = context.active_object
2310 mesh = obj.data if obj and obj.type == 'MESH' else None
2312 layout.operator("mesh.unfold")
2314 if context.mode == 'EDIT_MESH':
2315 row = layout.row(align=True)
2316 row.operator("mesh.mark_seam", text="Mark Seam").clear = False
2317 row.operator("mesh.mark_seam", text="Clear Seam").clear = True
2318 else:
2319 layout.operator("mesh.clear_all_seams")
2322 class VIEW3D_PT_paper_model_settings(bpy.types.Panel):
2323 bl_space_type = 'VIEW_3D'
2324 bl_region_type = 'UI'
2325 bl_category = 'Paper'
2326 bl_label = "Export"
2328 def draw(self, context):
2329 layout = self.layout
2330 sce = context.scene
2331 obj = context.active_object
2332 mesh = obj.data if obj and obj.type == 'MESH' else None
2334 layout.operator("export_mesh.paper_model")
2335 props = sce.paper_model
2336 layout.prop(props, "scale", text="Model Scale: 1/")
2338 layout.prop(props, "limit_by_page")
2339 col = layout.column()
2340 col.active = props.limit_by_page
2341 col.prop(props, "page_size_preset")
2342 sub = col.column(align=True)
2343 sub.active = props.page_size_preset == 'USER'
2344 sub.prop(props, "output_size_x")
2345 sub.prop(props, "output_size_y")
2348 class DATA_PT_paper_model_islands(bpy.types.Panel):
2349 bl_space_type = 'PROPERTIES'
2350 bl_region_type = 'WINDOW'
2351 bl_context = "data"
2352 bl_label = "Paper Model Islands"
2353 COMPAT_ENGINES = {'BLENDER_RENDER', 'BLENDER_EEVEE', 'BLENDER_WORKBENCH'}
2355 def draw(self, context):
2356 layout = self.layout
2357 sce = context.scene
2358 obj = context.active_object
2359 mesh = obj.data if obj and obj.type == 'MESH' else None
2361 layout.operator("mesh.unfold", icon='FILE_REFRESH')
2362 if mesh and mesh.paper_island_list:
2363 layout.label(
2364 text="1 island:" if len(mesh.paper_island_list) == 1 else
2365 "{} islands:".format(len(mesh.paper_island_list)))
2366 layout.template_list(
2367 'UI_UL_list', 'paper_model_island_list', mesh,
2368 'paper_island_list', mesh, 'paper_island_index', rows=1, maxrows=5)
2369 sub = layout.split(align=True)
2370 sub.operator("mesh.select_paper_island", text="Select").operation = 'ADD'
2371 sub.operator("mesh.select_paper_island", text="Deselect").operation = 'REMOVE'
2372 sub.prop(sce.paper_model, "sync_island", icon='UV_SYNC_SELECT', toggle=True)
2373 if mesh.paper_island_index >= 0:
2374 list_item = mesh.paper_island_list[mesh.paper_island_index]
2375 sub = layout.column(align=True)
2376 sub.prop(list_item, "auto_label")
2377 sub.prop(list_item, "label")
2378 sub.prop(list_item, "auto_abbrev")
2379 row = sub.row()
2380 row.active = not list_item.auto_abbrev
2381 row.prop(list_item, "abbreviation")
2382 else:
2383 layout.box().label(text="Not unfolded")
2386 def label_changed(self, context):
2387 """The label of an island was changed"""
2388 # accessing properties via [..] to avoid a recursive call after the update
2389 self["auto_label"] = not self.label or self.label.isspace()
2390 island_item_changed(self, context)
2393 def island_item_changed(self, context):
2394 """The labelling of an island was changed"""
2395 def increment(abbrev, collisions):
2396 letters = "ABCDEFGHIJKLMNPQRSTUVWXYZ123456789"
2397 while abbrev in collisions:
2398 abbrev = abbrev.rstrip(letters[-1])
2399 abbrev = abbrev[:2] + letters[letters.find(abbrev[-1]) + 1 if len(abbrev) == 3 else 0]
2400 return abbrev
2402 # accessing properties via [..] to avoid a recursive call after the update
2403 island_list = context.active_object.data.paper_island_list
2404 if self.auto_label:
2405 self["label"] = "" # avoid self-conflict
2406 number = 1
2407 while any(item.label == "Island {}".format(number) for item in island_list):
2408 number += 1
2409 self["label"] = "Island {}".format(number)
2410 if self.auto_abbrev:
2411 self["abbreviation"] = "" # avoid self-conflict
2412 abbrev = "".join(first_letters(self.label))[:3].upper()
2413 self["abbreviation"] = increment(abbrev, {item.abbreviation for item in island_list})
2414 elif len(self.abbreviation) > 3:
2415 self["abbreviation"] = self.abbreviation[:3]
2416 self.name = "[{}] {} ({} {})".format(
2417 self.abbreviation, self.label, len(self.faces), "faces" if len(self.faces) > 1 else "face")
2420 def island_index_changed(self, context):
2421 """The active island was changed"""
2422 if context.scene.paper_model.sync_island and SelectIsland.poll(context):
2423 bpy.ops.mesh.select_paper_island(operation='REPLACE')
2426 class FaceList(bpy.types.PropertyGroup):
2427 id: bpy.props.IntProperty(name="Face ID")
2430 class IslandList(bpy.types.PropertyGroup):
2431 faces: bpy.props.CollectionProperty(
2432 name="Faces", description="Faces belonging to this island", type=FaceList)
2433 label: bpy.props.StringProperty(
2434 name="Label", description="Label on this island",
2435 default="", update=label_changed)
2436 abbreviation: bpy.props.StringProperty(
2437 name="Abbreviation", description="Three-letter label to use when there is not enough space",
2438 default="", update=island_item_changed)
2439 auto_label: bpy.props.BoolProperty(
2440 name="Auto Label", description="Generate the label automatically",
2441 default=True, update=island_item_changed)
2442 auto_abbrev: bpy.props.BoolProperty(
2443 name="Auto Abbreviation", description="Generate the abbreviation automatically",
2444 default=True, update=island_item_changed)
2447 class PaperModelSettings(bpy.types.PropertyGroup):
2448 sync_island: bpy.props.BoolProperty(
2449 name="Sync", description="Keep faces of the active island selected",
2450 default=False, update=island_index_changed)
2451 limit_by_page: bpy.props.BoolProperty(
2452 name="Limit Island Size", description="Do not create islands larger than given dimensions",
2453 default=False, update=page_size_preset_changed)
2454 page_size_preset: bpy.props.EnumProperty(
2455 name="Page Size", description="Maximal size of an island",
2456 default='A4', update=page_size_preset_changed, items=global_paper_sizes)
2457 output_size_x: bpy.props.FloatProperty(
2458 name="Width", description="Maximal width of an island",
2459 default=0.2, soft_min=0.105, soft_max=0.841, subtype="UNSIGNED", unit="LENGTH")
2460 output_size_y: bpy.props.FloatProperty(
2461 name="Height", description="Maximal height of an island",
2462 default=0.29, soft_min=0.148, soft_max=1.189, subtype="UNSIGNED", unit="LENGTH")
2463 scale: bpy.props.FloatProperty(
2464 name="Scale", description="Divisor of all dimensions when exporting",
2465 default=1, soft_min=1.0, soft_max=100.0, subtype='FACTOR', precision=1)
2468 module_classes = (
2469 Unfold,
2470 ExportPaperModel,
2471 ClearAllSeams,
2472 SelectIsland,
2473 AddPresetPaperModel,
2474 FaceList,
2475 IslandList,
2476 PaperModelSettings,
2477 VIEW3D_MT_paper_model_presets,
2478 DATA_PT_paper_model_islands,
2479 VIEW3D_PT_paper_model_tools,
2480 VIEW3D_PT_paper_model_settings,
2484 def register():
2485 for cls in module_classes:
2486 bpy.utils.register_class(cls)
2487 bpy.types.Scene.paper_model = bpy.props.PointerProperty(
2488 name="Paper Model", description="Settings of the Export Paper Model script",
2489 type=PaperModelSettings, options={'SKIP_SAVE'})
2490 bpy.types.Mesh.paper_island_list = bpy.props.CollectionProperty(
2491 name="Island List", type=IslandList)
2492 bpy.types.Mesh.paper_island_index = bpy.props.IntProperty(
2493 name="Island List Index",
2494 default=-1, min=-1, max=100, options={'SKIP_SAVE'}, update=island_index_changed)
2495 bpy.types.TOPBAR_MT_file_export.append(menu_func_export)
2496 bpy.types.VIEW3D_MT_edit_mesh.prepend(menu_func_unfold)
2499 def unregister():
2500 bpy.types.TOPBAR_MT_file_export.remove(menu_func_export)
2501 bpy.types.VIEW3D_MT_edit_mesh.remove(menu_func_unfold)
2502 for cls in reversed(module_classes):
2503 bpy.utils.unregister_class(cls)
2506 if __name__ == "__main__":
2507 register()