BVH: count no longer needs to be a keyword arg
[blender-addons.git] / io_export_paper_model.py
blob3839c4f590c1675b963648bd89f85193f7ddb8f7
1 # -*- coding: utf-8 -*-
2 # ##### BEGIN GPL LICENSE BLOCK #####
4 # This program is free software: you can redistribute it and/or modify
5 # it under the terms of the GNU General Public License as published by
6 # the Free Software Foundation, either version 2 of the License, or
7 # (at your option) any later version.
9 # This program is distributed in the hope that it will be useful,
10 # but without any warranty; without even the implied warranty of
11 # merchantability or fitness for a particular purpose. See the
12 # GNU General Public License for more details.
14 # You should have received a copy of the GNU General Public License
15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
17 # ##### END GPL LICENSE BLOCK #####
19 bl_info = {
20 "name": "Export Paper Model",
21 "author": "Addam Dominec",
22 "version": (0, 9),
23 "blender": (2, 73, 0),
24 "location": "File > Export > Paper Model",
25 "warning": "",
26 "description": "Export printable net of the active mesh",
27 "category": "Import-Export",
28 "wiki_url": "http://wiki.blender.org/index.php/Extensions:2.6/Py/"
29 "Scripts/Import-Export/Paper_Model",
30 "tracker_url": "https://developer.blender.org/T38441"
33 # TODO:
34 # sanitize the constructors Edge, Face, UVFace so that they don't edit their parent object
35 # The Exporter classes should take parameters as a whole pack, and parse it themselves
36 # remember objects selected before baking (except selected to active)
37 # add 'estimated number of pages' to the export UI
38 # profile QuickSweepline vs. BruteSweepline with/without blist: for which nets is it faster?
39 # rotate islands to minimize area -- and change that only if necessary to fill the page size
40 # Sticker.vertices should be of type Vector
42 # check conflicts in island naming and either:
43 # * append a number to the conflicting names or
44 # * enumerate faces uniquely within all islands of the same name (requires a check that both label and abbr. equals)
47 """
49 Additional links:
50 e-mail: adominec {at} gmail {dot} com
52 """
53 import bpy
54 import bl_operators
55 import bgl
56 import mathutils as M
57 from re import compile as re_compile
58 from itertools import chain, repeat
59 from math import pi, ceil
61 try:
62 import os.path as os_path
63 except ImportError:
64 os_path = None
66 try:
67 from blist import blist
68 except ImportError:
69 blist = list
71 default_priority_effect = {
72 'CONVEX': 0.5,
73 'CONCAVE': 1,
74 'LENGTH': -0.05
77 global_paper_sizes = [
78 ('USER', "User defined", "User defined paper size"),
79 ('A4', "A4", "International standard paper size"),
80 ('A3', "A3", "International standard paper size"),
81 ('US_LETTER', "Letter", "North American paper size"),
82 ('US_LEGAL', "Legal", "North American paper size")
86 def first_letters(text):
87 """Iterator over the first letter of each word"""
88 for match in first_letters.pattern.finditer(text):
89 yield text[match.start()]
90 first_letters.pattern = re_compile("((?<!\w)\w)|\d")
93 def is_upsidedown_wrong(name):
94 """Tell if the string would get a different meaning if written upside down"""
95 chars = set(name)
96 mistakable = set("69NZMWpbqd")
97 rotatable = set("80oOxXIl").union(mistakable)
98 return chars.issubset(rotatable) and not chars.isdisjoint(mistakable)
101 def pairs(sequence):
102 """Generate consecutive pairs throughout the given sequence; at last, it gives elements last, first."""
103 i = iter(sequence)
104 previous = first = next(i)
105 for this in i:
106 yield previous, this
107 previous = this
108 yield this, first
111 def argmax_pair(array, key):
112 """Find an (unordered) pair of indices that maximize the given function"""
113 n = len(array)
114 mi, mj, m = None, None, None
115 for i in range(n):
116 for j in range(i+1, n):
117 k = key(array[i], array[j])
118 if not m or k > m:
119 mi, mj, m = i, j, k
120 return mi, mj
123 def fitting_matrix(v1, v2):
124 """Get a matrix that rotates v1 to the same direction as v2"""
125 return (1 / v1.length_squared) * M.Matrix((
126 (v1.x*v2.x + v1.y*v2.y, v1.y*v2.x - v1.x*v2.y),
127 (v1.x*v2.y - v1.y*v2.x, v1.x*v2.x + v1.y*v2.y)))
130 def z_up_matrix(n):
131 """Get a rotation matrix that aligns given vector upwards."""
132 b = n.xy.length
133 s = n.length
134 if b > 0:
135 return M.Matrix((
136 (n.x*n.z/(b*s), n.y*n.z/(b*s), -b/s),
137 (-n.y/b, n.x/b, 0),
138 (0, 0, 0)
140 else:
141 # no need for rotation
142 return M.Matrix((
143 (1, 0, 0),
144 (0, (-1 if n.z < 0 else 1), 0),
145 (0, 0, 0)
149 def create_blank_image(image_name, dimensions, alpha=1):
150 """Create a new image and assign white color to all its pixels"""
151 image_name = image_name[:64]
152 width, height = int(dimensions.x), int(dimensions.y)
153 image = bpy.data.images.new(image_name, width, height, alpha=True)
154 if image.users > 0:
155 raise UnfoldError(
156 "There is something wrong with the material of the model. "
157 "Please report this on the BlenderArtists forum. Export failed.")
158 image.pixels = [1, 1, 1, alpha] * (width * height)
159 image.file_format = 'PNG'
160 return image
163 def bake(face_indices, uvmap, image):
164 import bpy
165 is_cycles = (bpy.context.scene.render.engine == 'CYCLES')
166 if is_cycles:
167 # please excuse the following mess. Cycles baking API does not seem to allow better.
168 ob = bpy.context.active_object
169 me = ob.data
170 # add a disconnected image node that defines the bake target
171 temp_nodes = dict()
172 for mat in me.materials:
173 mat.use_nodes = True
174 img = mat.node_tree.nodes.new('ShaderNodeTexImage')
175 img.image = image
176 temp_nodes[mat] = img
177 mat.node_tree.nodes.active = img
178 uvmap.active = True
179 # move all excess faces to negative numbers (that is the only way to disable them)
180 loop = me.uv_layers[me.uv_layers.active_index].data
181 face_indices = set(face_indices)
182 ignored_uvs = [
183 face.loop_start + i
184 for face in me.polygons if face.index not in face_indices
185 for i, v in enumerate(face.vertices)]
186 for vid in ignored_uvs:
187 loop[vid].uv *= -1
188 bake_type = bpy.context.scene.cycles.bake_type
189 sta = bpy.context.scene.render.bake.use_selected_to_active
190 try:
191 bpy.ops.object.bake(type=bake_type, margin=0, use_selected_to_active=sta, cage_extrusion=100, use_clear=False)
192 except RuntimeError as e:
193 raise UnfoldError(*e.args)
194 finally:
195 for mat, node in temp_nodes.items():
196 mat.node_tree.nodes.remove(node)
197 for vid in ignored_uvs:
198 loop[vid].uv *= -1
199 else:
200 texfaces = uvmap.data
201 for fid in face_indices:
202 texfaces[fid].image = image
203 bpy.ops.object.bake_image()
204 for fid in face_indices:
205 texfaces[fid].image = None
208 class UnfoldError(ValueError):
209 pass
212 class Unfolder:
213 def __init__(self, ob):
214 self.ob = ob
215 self.mesh = Mesh(ob.data, ob.matrix_world)
216 self.mesh.check_correct()
217 self.tex = None
219 def prepare(self, cage_size=None, create_uvmap=False, mark_seams=False, priority_effect=default_priority_effect, scale=1):
220 """Create the islands of the net"""
221 self.mesh.generate_cuts(cage_size / scale if cage_size else None, priority_effect)
222 is_landscape = cage_size and cage_size.x > cage_size.y
223 self.mesh.finalize_islands(is_landscape)
224 self.mesh.enumerate_islands()
225 if create_uvmap:
226 self.tex = self.mesh.save_uv()
227 if mark_seams:
228 self.mesh.mark_cuts()
230 def copy_island_names(self, island_list):
231 """Copy island label and abbreviation from the best matching island in the list"""
232 orig_islands = [{face.id for face in item.faces} for item in island_list]
233 matching = list()
234 for i, island in enumerate(self.mesh.islands):
235 islfaces = {uvface.face.index for uvface in island.faces}
236 matching.extend((len(islfaces.intersection(item)), i, j) for j, item in enumerate(orig_islands))
237 matching.sort(reverse=True)
238 available_new = [True for island in self.mesh.islands]
239 available_orig = [True for item in island_list]
240 for face_count, i, j in matching:
241 if available_new[i] and available_orig[j]:
242 available_new[i] = available_orig[j] = False
243 self.mesh.islands[i].label = island_list[j].label
244 self.mesh.islands[i].abbreviation = island_list[j].abbreviation
246 def save(self, properties):
247 """Export the document"""
248 # Note about scale: input is direcly in blender length
249 # Mesh.scale_islands multiplies everything by a user-defined ratio
250 # exporters (SVG or PDF) multiply everything by 1000 (output in millimeters)
251 Exporter = SVG if properties.file_format == 'SVG' else PDF
252 filepath = properties.filepath
253 extension = properties.file_format.lower()
254 filepath = bpy.path.ensure_ext(filepath, "." + extension)
255 # page size in meters
256 page_size = M.Vector((properties.output_size_x, properties.output_size_y))
257 # printable area size in meters
258 printable_size = page_size - 2 * properties.output_margin * M.Vector((1, 1))
259 unit_scale = bpy.context.scene.unit_settings.scale_length
260 ppm = properties.output_dpi * 100 / 2.54 # pixels per meter
262 # after this call, all dimensions will be in meters
263 self.mesh.scale_islands(unit_scale/properties.scale)
264 if properties.do_create_stickers:
265 self.mesh.generate_stickers(properties.sticker_width, properties.do_create_numbers)
266 elif properties.do_create_numbers:
267 self.mesh.generate_numbers_alone(properties.sticker_width)
269 text_height = properties.sticker_width if (properties.do_create_numbers and len(self.mesh.islands) > 1) else 0
270 aspect_ratio = printable_size.x / printable_size.y
271 # title height must be somewhat larger that text size, glyphs go below the baseline
272 self.mesh.finalize_islands(is_landscape=(printable_size.x > printable_size.y), title_height=text_height * 1.2)
273 self.mesh.fit_islands(cage_size=printable_size)
275 if properties.output_type != 'NONE':
276 # bake an image and save it as a PNG to disk or into memory
277 image_packing = properties.image_packing if properties.file_format == 'SVG' else 'ISLAND_EMBED'
278 use_separate_images = image_packing in ('ISLAND_LINK', 'ISLAND_EMBED')
279 tex = self.mesh.save_uv(cage_size=printable_size, separate_image=use_separate_images, tex=self.tex)
280 if not tex:
281 raise UnfoldError("The mesh has no UV Map slots left. Either delete a UV Map or export the net without textures.")
283 sce = bpy.context.scene
284 rd = sce.render
285 bk = rd.bake
286 if rd.engine == 'CYCLES':
287 recall = sce.cycles.bake_type, bk.use_selected_to_active, bk.margin, bk.cage_extrusion, bk.use_cage, bk.use_clear, bk.use_pass_direct, bk.use_pass_indirect
288 # recall use_pass...
289 lookup = {'TEXTURE': 'DIFFUSE', 'AMBIENT_OCCLUSION': 'AO', 'RENDER': 'COMBINED', 'SELECTED_TO_ACTIVE': 'COMBINED'}
290 sce.cycles.bake_type = lookup[properties.output_type]
291 bk.use_pass_direct = bk.use_pass_indirect = (properties.output_type != 'TEXTURE')
292 bk.use_selected_to_active = (properties.output_type == 'SELECTED_TO_ACTIVE')
293 bk.margin, bk.cage_extrusion, bk.use_cage, bk.use_clear = 0, 10, False, False
294 else:
295 recall = rd.engine, rd.bake_type, rd.use_bake_to_vertex_color, rd.use_bake_selected_to_active, rd.bake_distance, rd.bake_bias, rd.bake_margin, rd.use_bake_clear
296 rd.engine = 'BLENDER_RENDER'
297 lookup = {'TEXTURE': 'TEXTURE', 'AMBIENT_OCCLUSION': 'AO', 'RENDER': 'FULL', 'SELECTED_TO_ACTIVE': 'FULL'}
298 rd.bake_type = lookup[properties.output_type]
299 rd.use_bake_selected_to_active = (properties.output_type == 'SELECTED_TO_ACTIVE')
300 rd.bake_margin, rd.bake_distance, rd.bake_bias, rd.use_bake_to_vertex_color, rd.use_bake_clear = 0, 0, 0.001, False, False
302 if image_packing == 'PAGE_LINK':
303 self.mesh.save_image(tex, printable_size * ppm, filepath)
304 elif image_packing == 'ISLAND_LINK':
305 image_dir = filepath[:filepath.rfind(".")]
306 self.mesh.save_separate_images(tex, ppm, image_dir)
307 elif image_packing == 'ISLAND_EMBED':
308 self.mesh.save_separate_images(tex, ppm, filepath, embed=Exporter.encode_image)
310 # revoke settings
311 if rd.engine == 'CYCLES':
312 sce.cycles.bake_type, bk.use_selected_to_active, bk.margin, bk.cage_extrusion, bk.use_cage, bk.use_clear, bk.use_pass_direct, bk.use_pass_indirect = recall
313 else:
314 rd.engine, rd.bake_type, rd.use_bake_to_vertex_color, rd.use_bake_selected_to_active, rd.bake_distance, rd.bake_bias, rd.bake_margin, rd.use_bake_clear = recall
315 if not properties.do_create_uvmap:
316 tex.active = True
317 bpy.ops.mesh.uv_texture_remove()
319 exporter = Exporter(page_size, properties.style, properties.output_margin, (properties.output_type == 'NONE'), properties.angle_epsilon)
320 exporter.do_create_stickers = properties.do_create_stickers
321 exporter.text_size = properties.sticker_width
322 exporter.write(self.mesh, filepath)
325 class Mesh:
326 """Wrapper for Bpy Mesh"""
328 def __init__(self, mesh, matrix):
329 self.vertices = dict()
330 self.edges = dict()
331 self.edges_by_verts_indices = dict()
332 self.faces = dict()
333 self.islands = list()
334 self.data = mesh
335 self.pages = list()
336 for bpy_vertex in mesh.vertices:
337 self.vertices[bpy_vertex.index] = Vertex(bpy_vertex, matrix)
338 for bpy_edge in mesh.edges:
339 edge = Edge(bpy_edge, self, matrix)
340 self.edges[bpy_edge.index] = edge
341 self.edges_by_verts_indices[(edge.va.index, edge.vb.index)] = edge
342 self.edges_by_verts_indices[(edge.vb.index, edge.va.index)] = edge
343 for bpy_face in mesh.polygons:
344 face = Face(bpy_face, self)
345 self.faces[bpy_face.index] = face
346 for edge in self.edges.values():
347 edge.choose_main_faces()
348 if edge.main_faces:
349 edge.calculate_angle()
351 def check_correct(self, epsilon=1e-6):
352 """Check for invalid geometry"""
353 null_edges = {i for i, e in self.edges.items() if e.vector.length < epsilon and e.faces}
354 null_faces = {i for i, f in self.faces.items() if f.normal.length_squared < epsilon}
355 twisted_faces = {i for i, f in self.faces.items() if f.is_twisted()}
356 if not (null_edges or null_faces or twisted_faces):
357 return
358 bpy.context.tool_settings.mesh_select_mode = False, bool(null_edges), bool(null_faces or twisted_faces)
359 for vertex in self.data.vertices:
360 vertex.select = False
361 for edge in self.data.edges:
362 edge.select = (edge.index in null_edges)
363 for face in self.data.polygons:
364 face.select = (face.index in null_faces or face.index in twisted_faces)
365 cure = ("Remove Doubles and Triangulate" if (null_edges or null_faces) and twisted_faces
366 else "Triangulate" if twisted_faces
367 else "Remove Doubles")
368 raise UnfoldError(
369 "The model contains:\n" +
370 (" {} zero-length edge(s)\n".format(len(null_edges)) if null_edges else "") +
371 (" {} zero-area face(s)\n".format(len(null_faces)) if null_faces else "") +
372 (" {} twisted polygon(s)\n".format(len(twisted_faces)) if twisted_faces else "") +
373 "The offenders are selected and you can use {} to fix them. Export failed.".format(cure))
375 def generate_cuts(self, page_size, priority_effect):
376 """Cut the mesh so that it can be unfolded to a flat net."""
377 # warning: this constructor modifies its parameter (face)
378 islands = {Island(face) for face in self.faces.values()}
379 # check for edges that are cut permanently
380 edges = [edge for edge in self.edges.values() if not edge.force_cut and len(edge.faces) > 1]
382 if edges:
383 average_length = sum(edge.vector.length for edge in edges) / len(edges)
384 for edge in edges:
385 edge.generate_priority(priority_effect, average_length)
386 edges.sort(reverse=False, key=lambda edge: edge.priority)
387 for edge in edges:
388 if edge.vector.length_squared == 0:
389 continue
390 face_a, face_b = edge.main_faces
391 island_a, island_b = face_a.uvface.island, face_b.uvface.island
392 if island_a is not island_b:
393 if len(island_b.faces) > len(island_a.faces):
394 island_a, island_b = island_b, island_a
395 if island_a.join(island_b, edge, size_limit=page_size):
396 islands.remove(island_b)
398 self.islands = sorted(islands, reverse=True, key=lambda island: len(island.faces))
400 for edge in self.edges.values():
401 # some edges did not know until now whether their angle is convex or concave
402 if edge.main_faces and (edge.main_faces[0].uvface.flipped or edge.main_faces[1].uvface.flipped):
403 edge.calculate_angle()
404 # ensure that the order of faces corresponds to the order of uvedges
405 if edge.main_faces:
406 reordered = [None, None]
407 for uvedge in edge.uvedges:
408 try:
409 index = edge.main_faces.index(uvedge.uvface.face)
410 reordered[index] = uvedge
411 except ValueError:
412 reordered.append(uvedge)
413 edge.uvedges = reordered
415 for island in self.islands:
416 # if the normals are ambiguous, flip them so that there are more convex edges than concave ones
417 if any(uvface.flipped for uvface in island.faces):
418 island_edges = {uvedge.edge for uvedge in island.edges if not uvedge.edge.is_cut(uvedge.uvface.face)}
419 balance = sum((+1 if edge.angle > 0 else -1) for edge in island_edges)
420 if balance < 0:
421 island.is_inside_out = True
423 # construct a linked list from each island's boundary
424 # uvedge.neighbor_right is clockwise = forward = via uvedge.vb if not uvface.flipped
425 neighbor_lookup, conflicts = dict(), dict()
426 for uvedge in island.boundary:
427 uvvertex = uvedge.va if uvedge.uvface.flipped else uvedge.vb
428 if uvvertex not in neighbor_lookup:
429 neighbor_lookup[uvvertex] = uvedge
430 else:
431 if uvvertex not in conflicts:
432 conflicts[uvvertex] = [neighbor_lookup[uvvertex], uvedge]
433 else:
434 conflicts[uvvertex].append(uvedge)
436 for uvedge in island.boundary:
437 uvvertex = uvedge.vb if uvedge.uvface.flipped else uvedge.va
438 if uvvertex not in conflicts:
439 # using the 'get' method so as to handle single-connected vertices properly
440 uvedge.neighbor_right = neighbor_lookup.get(uvvertex, uvedge)
441 uvedge.neighbor_right.neighbor_left = uvedge
442 else:
443 conflicts[uvvertex].append(uvedge)
445 # resolve merged vertices with more boundaries crossing
446 def direction_to_float(vector):
447 return (1 - vector.x/vector.length) if vector.y > 0 else (vector.x/vector.length - 1)
448 for uvvertex, uvedges in conflicts.items():
449 def is_inwards(uvedge):
450 return uvedge.uvface.flipped == (uvedge.va is uvvertex)
452 def uvedge_sortkey(uvedge):
453 if is_inwards(uvedge):
454 return direction_to_float(uvedge.va.co - uvedge.vb.co)
455 else:
456 return direction_to_float(uvedge.vb.co - uvedge.va.co)
458 uvedges.sort(key=uvedge_sortkey)
459 for right, left in (
460 zip(uvedges[:-1:2], uvedges[1::2]) if is_inwards(uvedges[0])
461 else zip([uvedges[-1]] + uvedges[1::2], uvedges[:-1:2])):
462 left.neighbor_right = right
463 right.neighbor_left = left
464 return True
466 def mark_cuts(self):
467 """Mark cut edges in the original mesh so that the user can see"""
468 for bpy_edge in self.data.edges:
469 edge = self.edges[bpy_edge.index]
470 bpy_edge.use_seam = len(edge.uvedges) > 1 and edge.is_main_cut
472 def generate_stickers(self, default_width, do_create_numbers=True):
473 """Add sticker faces where they are needed."""
474 def uvedge_priority(uvedge):
475 """Retuns whether it is a good idea to stick something on this edge's face"""
476 # TODO: it should take into account overlaps with faces and with other stickers
477 return uvedge.uvface.face.area / sum((vb.co - va.co).length for (va, vb) in pairs(uvedge.uvface.vertices))
479 def add_sticker(uvedge, index, target_island):
480 uvedge.sticker = Sticker(uvedge, default_width, index, target_island)
481 uvedge.island.add_marker(uvedge.sticker)
483 for edge in self.edges.values():
484 if edge.is_main_cut and len(edge.uvedges) >= 2 and edge.vector.length_squared > 0:
485 uvedge_a, uvedge_b = edge.uvedges[:2]
486 if uvedge_priority(uvedge_a) < uvedge_priority(uvedge_b):
487 uvedge_a, uvedge_b = uvedge_b, uvedge_a
488 target_island = uvedge_a.island
489 left_edge, right_edge = uvedge_a.neighbor_left.edge, uvedge_a.neighbor_right.edge
490 if do_create_numbers:
491 for uvedge in [uvedge_b] + edge.uvedges[2:]:
492 if ((uvedge.neighbor_left.edge is not right_edge or uvedge.neighbor_right.edge is not left_edge) and
493 uvedge not in (uvedge_a.neighbor_left, uvedge_a.neighbor_right)):
494 # it will not be clear to see that these uvedges should be sticked together
495 # So, create an arrow and put the index on all stickers
496 target_island.sticker_numbering += 1
497 index = str(target_island.sticker_numbering)
498 if is_upsidedown_wrong(index):
499 index += "."
500 target_island.add_marker(Arrow(uvedge_a, default_width, index))
501 break
502 else:
503 # if all uvedges to be sticked are easy to see, create no numbers
504 index = None
505 else:
506 index = None
507 add_sticker(uvedge_b, index, target_island)
508 elif len(edge.uvedges) > 2:
509 index = None
510 target_island = edge.uvedges[0].island
511 if len(edge.uvedges) > 2:
512 for uvedge in edge.uvedges[2:]:
513 add_sticker(uvedge, index, target_island)
515 def generate_numbers_alone(self, size):
516 global_numbering = 0
517 for edge in self.edges.values():
518 if edge.is_main_cut and len(edge.uvedges) >= 2:
519 global_numbering += 1
520 index = str(global_numbering)
521 if is_upsidedown_wrong(index):
522 index += "."
523 for uvedge in edge.uvedges:
524 uvedge.island.add_marker(NumberAlone(uvedge, index, size))
526 def enumerate_islands(self):
527 for num, island in enumerate(self.islands, 1):
528 island.number = num
529 island.generate_label()
531 def scale_islands(self, scale):
532 for island in self.islands:
533 for point in chain((vertex.co for vertex in island.vertices), island.fake_vertices):
534 point *= scale
536 def finalize_islands(self, is_landscape=False, title_height=0):
537 for island in self.islands:
538 if title_height:
539 island.title = "[{}] {}".format(island.abbreviation, island.label)
540 points = list(vertex.co for vertex in island.vertices) + island.fake_vertices
541 angle = M.geometry.box_fit_2d(points)
542 rot = M.Matrix.Rotation(angle, 2)
543 # ensure that the island matches page orientation (portrait/landscape)
544 dimensions = M.Vector(max(r * v for v in points) - min(r * v for v in points) for r in rot)
545 if dimensions.x > dimensions.y != is_landscape:
546 rot = M.Matrix.Rotation(angle + pi / 2, 2)
547 for point in points:
548 # note: we need an in-place operation, and Vector.rotate() seems to work for 3d vectors only
549 point[:] = rot * point
550 for marker in island.markers:
551 marker.rot = rot * marker.rot
552 bottom_left = M.Vector((min(v.x for v in points), min(v.y for v in points) - title_height))
553 for point in points:
554 point -= bottom_left
555 island.bounding_box = M.Vector((max(v.x for v in points), max(v.y for v in points)))
557 def largest_island_ratio(self, page_size):
558 return max(i / p for island in self.islands for (i, p) in zip(island.bounding_box, page_size))
560 def fit_islands(self, cage_size):
561 """Move islands so that they fit onto pages, based on their bounding boxes"""
563 def try_emplace(island, page_islands, cage_size, stops_x, stops_y, occupied_cache):
564 """Tries to put island to each pair from stops_x, stops_y
565 and checks if it overlaps with any islands present on the page.
566 Returns True and positions the given island on success."""
567 bbox_x, bbox_y = island.bounding_box.xy
568 for x in stops_x:
569 if x + bbox_x > cage_size.x:
570 continue
571 for y in stops_y:
572 if y + bbox_y > cage_size.y or (x, y) in occupied_cache:
573 continue
574 for i, obstacle in enumerate(page_islands):
575 # if this obstacle overlaps with the island, try another stop
576 if (x + bbox_x > obstacle.pos.x and
577 obstacle.pos.x + obstacle.bounding_box.x > x and
578 y + bbox_y > obstacle.pos.y and
579 obstacle.pos.y + obstacle.bounding_box.y > y):
580 if x >= obstacle.pos.x and y >= obstacle.pos.y:
581 occupied_cache.add((x, y))
582 # just a stupid heuristic to make subsequent searches faster
583 if i > 0:
584 page_islands[1:i+1] = page_islands[:i]
585 page_islands[0] = obstacle
586 break
587 else:
588 # if no obstacle called break, this position is okay
589 island.pos.xy = x, y
590 page_islands.append(island)
591 stops_x.append(x + bbox_x)
592 stops_y.append(y + bbox_y)
593 return True
594 return False
596 def drop_portion(stops, border, divisor):
597 stops.sort()
598 # distance from left neighbor to the right one, excluding the first stop
599 distances = [right - left for left, right in zip(stops, chain(stops[2:], [border]))]
600 quantile = sorted(distances)[len(distances) // divisor]
601 return [stop for stop, distance in zip(stops, chain([quantile], distances)) if distance >= quantile]
603 if any(island.bounding_box.x > cage_size.x or island.bounding_box.y > cage_size.y for island in self.islands):
604 raise UnfoldError(
605 "An island is too big to fit onto page of the given size. "
606 "Either downscale the model or find and split that island manually.\n"
607 "Export failed, sorry.")
608 # sort islands by their diagonal... just a guess
609 remaining_islands = sorted(self.islands, reverse=True, key=lambda island: island.bounding_box.length_squared)
610 page_num = 1
612 while remaining_islands:
613 # create a new page and try to fit as many islands onto it as possible
614 page = Page(page_num)
615 page_num += 1
616 occupied_cache = set()
617 stops_x, stops_y = [0], [0]
618 for island in remaining_islands:
619 try_emplace(island, page.islands, cage_size, stops_x, stops_y, occupied_cache)
620 # if overwhelmed with stops, drop a quarter of them
621 if len(stops_x)**2 > 4 * len(self.islands) + 100:
622 stops_x = drop_portion(stops_x, cage_size.x, 4)
623 stops_y = drop_portion(stops_y, cage_size.y, 4)
624 remaining_islands = [island for island in remaining_islands if island not in page.islands]
625 self.pages.append(page)
627 def save_uv(self, cage_size=M.Vector((1, 1)), separate_image=False, tex=None):
628 # TODO: mode switching should be handled by higher-level code
629 bpy.ops.object.mode_set()
630 # note: assuming that the active object's data is self.mesh
631 if not tex:
632 tex = self.data.uv_textures.new()
633 if not tex:
634 return None
635 tex.name = "Unfolded"
636 tex.active = True
637 # TODO: this is somewhat dirty, but I do not see a nicer way in the API
638 loop = self.data.uv_layers[self.data.uv_layers.active_index]
639 if separate_image:
640 for island in self.islands:
641 island.save_uv_separate(loop)
642 else:
643 for island in self.islands:
644 island.save_uv(loop, cage_size)
645 return tex
647 def save_image(self, tex, page_size_pixels: M.Vector, filename):
648 for page in self.pages:
649 image = create_blank_image("{} {} Unfolded".format(self.data.name[:14], page.name), page_size_pixels, alpha=1)
650 image.filepath_raw = page.image_path = "{}_{}.png".format(filename, page.name)
651 faces = [uvface.face.index for island in page.islands for uvface in island.faces]
652 bake(faces, tex, image)
653 image.save()
654 image.user_clear()
655 bpy.data.images.remove(image)
657 def save_separate_images(self, tex, scale, filepath, embed=None):
658 # omitting this may cause a "Circular reference in texture stack" error
659 recall = {texface: texface.image for texface in tex.data}
660 for texface in tex.data:
661 texface.image = None
662 for i, island in enumerate(self.islands, 1):
663 image_name = "{} isl{}".format(self.data.name[:15], i)
664 image = create_blank_image(image_name, island.bounding_box * scale, alpha=0)
665 bake([uvface.face.index for uvface in island.faces], tex, image)
666 if embed:
667 island.embedded_image = embed(image)
668 else:
669 from os import makedirs
670 image_dir = filepath
671 makedirs(image_dir, exist_ok=True)
672 image_path = os_path.join(image_dir, "island{}.png".format(i))
673 image.filepath_raw = image_path
674 image.save()
675 island.image_path = image_path
676 image.user_clear()
677 bpy.data.images.remove(image)
678 for texface, img in recall.items():
679 texface.image = img
682 class Vertex:
683 """BPy Vertex wrapper"""
684 __slots__ = ('index', 'co', 'edges', 'uvs')
686 def __init__(self, bpy_vertex, matrix):
687 self.index = bpy_vertex.index
688 self.co = matrix * bpy_vertex.co
689 self.edges = list()
690 self.uvs = list()
692 def __hash__(self):
693 return hash(self.index)
695 def __eq__(self, other):
696 return self.index == other.index
699 class Edge:
700 """Wrapper for BPy Edge"""
701 __slots__ = ('va', 'vb', 'faces', 'main_faces', 'uvedges',
702 'vector', 'angle',
703 'is_main_cut', 'force_cut', 'priority', 'freestyle')
705 def __init__(self, edge, mesh, matrix=1):
706 self.va = mesh.vertices[edge.vertices[0]]
707 self.vb = mesh.vertices[edge.vertices[1]]
708 self.vector = self.vb.co - self.va.co
709 self.faces = list()
710 # if self.main_faces is set, then self.uvedges[:2] must correspond to self.main_faces, in their order
711 # this constraint is assured at the time of finishing mesh.generate_cuts
712 self.uvedges = list()
714 self.force_cut = edge.use_seam # such edges will always be cut
715 self.main_faces = None # two faces that may be connected in the island
716 # is_main_cut defines whether the two main faces are connected
717 # all the others will be assumed to be cut
718 self.is_main_cut = True
719 self.priority = None
720 self.angle = None
721 self.freestyle = getattr(edge, "use_freestyle_mark", False) # freestyle edges will be highlighted
722 self.va.edges.append(self) # FIXME: editing foreign attribute
723 self.vb.edges.append(self) # FIXME: editing foreign attribute
725 def choose_main_faces(self):
726 """Choose two main faces that might get connected in an island"""
727 if len(self.faces) == 2:
728 self.main_faces = self.faces
729 elif len(self.faces) > 2:
730 # find (with brute force) the pair of indices whose faces have the most similar normals
731 i, j = argmax_pair(self.faces, key=lambda a, b: abs(a.normal.dot(b.normal)))
732 self.main_faces = [self.faces[i], self.faces[j]]
734 def calculate_angle(self):
735 """Calculate the angle between the main faces"""
736 face_a, face_b = self.main_faces
737 if face_a.normal.length_squared == 0 or face_b.normal.length_squared == 0:
738 self.angle = -3 # just a very sharp angle
739 return
740 # correction if normals are flipped
741 a_is_clockwise = ((face_a.vertices.index(self.va) - face_a.vertices.index(self.vb)) % len(face_a.vertices) == 1)
742 b_is_clockwise = ((face_b.vertices.index(self.va) - face_b.vertices.index(self.vb)) % len(face_b.vertices) == 1)
743 is_equal_flip = True
744 if face_a.uvface and face_b.uvface:
745 a_is_clockwise ^= face_a.uvface.flipped
746 b_is_clockwise ^= face_b.uvface.flipped
747 is_equal_flip = (face_a.uvface.flipped == face_b.uvface.flipped)
748 # TODO: maybe this need not be true in _really_ ugly cases: assert(a_is_clockwise != b_is_clockwise)
749 if a_is_clockwise != b_is_clockwise:
750 if (a_is_clockwise == (face_b.normal.cross(face_a.normal).dot(self.vector) > 0)) == is_equal_flip:
751 # the angle is convex
752 self.angle = face_a.normal.angle(face_b.normal)
753 else:
754 # the angle is concave
755 self.angle = -face_a.normal.angle(face_b.normal)
756 else:
757 # normals are flipped, so we know nothing
758 # so let us assume the angle be convex
759 self.angle = face_a.normal.angle(-face_b.normal)
761 def generate_priority(self, priority_effect, average_length):
762 """Calculate the priority value for cutting"""
763 angle = self.angle
764 if angle > 0:
765 self.priority = priority_effect['CONVEX'] * angle / pi
766 else:
767 self.priority = priority_effect['CONCAVE'] * (-angle) / pi
768 self.priority += (self.vector.length / average_length) * priority_effect['LENGTH']
770 def is_cut(self, face):
771 """Return False if this edge will the given face to another one in the resulting net
772 (useful for edges with more than two faces connected)"""
773 # Return whether there is a cut between the two main faces
774 if self.main_faces and face in self.main_faces:
775 return self.is_main_cut
776 # All other faces (third and more) are automatically treated as cut
777 else:
778 return True
780 def other_uvedge(self, this):
781 """Get an uvedge of this edge that is not the given one
782 causes an IndexError if case of less than two adjacent edges"""
783 return self.uvedges[1] if this is self.uvedges[0] else self.uvedges[0]
786 class Face:
787 """Wrapper for BPy Face"""
788 __slots__ = ('index', 'edges', 'vertices', 'uvface',
789 'loop_start', 'area', 'normal')
791 def __init__(self, bpy_face, mesh):
792 self.index = bpy_face.index
793 self.edges = list()
794 self.vertices = [mesh.vertices[i] for i in bpy_face.vertices]
795 self.loop_start = bpy_face.loop_start
796 self.area = bpy_face.area
797 self.uvface = None
798 self.normal = M.geometry.normal(v.co for v in self.vertices)
799 for verts_indices in bpy_face.edge_keys:
800 edge = mesh.edges_by_verts_indices[verts_indices]
801 self.edges.append(edge)
802 edge.faces.append(self) # FIXME: editing foreign attribute
804 def is_twisted(self):
805 if len(self.vertices) > 3:
806 center = sum((vertex.co for vertex in self.vertices), M.Vector((0, 0, 0))) / len(self.vertices)
807 plane_d = center.dot(self.normal)
808 diameter = max((center - vertex.co).length for vertex in self.vertices)
809 for vertex in self.vertices:
810 # check coplanarity
811 if abs(vertex.co.dot(self.normal) - plane_d) > diameter * 0.01:
812 return True
813 return False
815 def __hash__(self):
816 return hash(self.index)
819 class Island:
820 """Part of the net to be exported"""
821 __slots__ = ('faces', 'edges', 'vertices', 'fake_vertices', 'uvverts_by_id', 'boundary', 'markers',
822 'pos', 'bounding_box',
823 'image_path', 'embedded_image',
824 'number', 'label', 'abbreviation', 'title',
825 'has_safe_geometry', 'is_inside_out',
826 'sticker_numbering')
828 def __init__(self, face):
829 """Create an Island from a single Face"""
830 self.faces = list()
831 self.edges = set()
832 self.vertices = set()
833 self.fake_vertices = list()
834 self.markers = list()
835 self.label = None
836 self.abbreviation = None
837 self.title = None
838 self.pos = M.Vector((0, 0))
839 self.image_path = None
840 self.embedded_image = None
841 self.is_inside_out = False # swaps concave <-> convex edges
842 self.has_safe_geometry = True
843 self.sticker_numbering = 0
844 uvface = UVFace(face, self)
845 self.vertices.update(uvface.vertices)
846 self.edges.update(uvface.edges)
847 self.faces.append(uvface)
848 # speedup for Island.join
849 self.uvverts_by_id = {uvvertex.vertex.index: [uvvertex] for uvvertex in self.vertices}
850 # UVEdges on the boundary
851 self.boundary = list(self.edges)
853 def join(self, other, edge: Edge, size_limit=None, epsilon=1e-6) -> bool:
855 Try to join other island on given edge
856 Returns False if they would overlap
859 class Intersection(Exception):
860 pass
862 class GeometryError(Exception):
863 pass
865 def is_below(self, other, correct_geometry=True):
866 if self is other:
867 return False
868 if self.top < other.bottom:
869 return True
870 if other.top < self.bottom:
871 return False
872 if self.max.tup <= other.min.tup:
873 return True
874 if other.max.tup <= self.min.tup:
875 return False
876 self_vector = self.max.co - self.min.co
877 min_to_min = other.min.co - self.min.co
878 cross_b1 = self_vector.cross(min_to_min)
879 cross_b2 = self_vector.cross(other.max.co - self.min.co)
880 if cross_b2 < cross_b1:
881 cross_b1, cross_b2 = cross_b2, cross_b1
882 if cross_b2 > 0 and (cross_b1 > 0 or (cross_b1 == 0 and not self.is_uvface_upwards())):
883 return True
884 if cross_b1 < 0 and (cross_b2 < 0 or (cross_b2 == 0 and self.is_uvface_upwards())):
885 return False
886 other_vector = other.max.co - other.min.co
887 cross_a1 = other_vector.cross(-min_to_min)
888 cross_a2 = other_vector.cross(self.max.co - other.min.co)
889 if cross_a2 < cross_a1:
890 cross_a1, cross_a2 = cross_a2, cross_a1
891 if cross_a2 > 0 and (cross_a1 > 0 or (cross_a1 == 0 and not other.is_uvface_upwards())):
892 return False
893 if cross_a1 < 0 and (cross_a2 < 0 or (cross_a2 == 0 and other.is_uvface_upwards())):
894 return True
895 if cross_a1 == cross_b1 == cross_a2 == cross_b2 == 0:
896 if correct_geometry:
897 raise GeometryError
898 elif self.is_uvface_upwards() == other.is_uvface_upwards():
899 raise Intersection
900 return False
901 if self.min.tup == other.min.tup or self.max.tup == other.max.tup:
902 return cross_a2 > cross_b2
903 raise Intersection
905 class QuickSweepline:
906 """Efficient sweepline based on binary search, checking neighbors only"""
907 def __init__(self):
908 self.children = blist()
910 def add(self, item, cmp=is_below):
911 low, high = 0, len(self.children)
912 while low < high:
913 mid = (low + high) // 2
914 if cmp(self.children[mid], item):
915 low = mid + 1
916 else:
917 high = mid
918 self.children.insert(low, item)
920 def remove(self, item, cmp=is_below):
921 index = self.children.index(item)
922 self.children.pop(index)
923 if index > 0 and index < len(self.children):
924 # check for intersection
925 if cmp(self.children[index], self.children[index-1]):
926 raise GeometryError
928 class BruteSweepline:
929 """Safe sweepline which checks all its members pairwise"""
930 def __init__(self):
931 self.children = set()
932 self.last_min = None, []
933 self.last_max = None, []
935 def add(self, item, cmp=is_below):
936 for child in self.children:
937 if child.min is not item.min and child.max is not item.max:
938 cmp(item, child, False)
939 self.children.add(item)
941 def remove(self, item):
942 self.children.remove(item)
944 def sweep(sweepline, segments):
945 """Sweep across the segments and raise an exception if necessary"""
946 # careful, 'segments' may be a use-once iterator
947 events_add = sorted(segments, reverse=True, key=lambda uvedge: uvedge.min.tup)
948 events_remove = sorted(events_add, reverse=True, key=lambda uvedge: uvedge.max.tup)
949 while events_remove:
950 while events_add and events_add[-1].min.tup <= events_remove[-1].max.tup:
951 sweepline.add(events_add.pop())
952 sweepline.remove(events_remove.pop())
954 def root_find(value, tree):
955 """Find the root of a given value in a forest-like dictionary
956 also updates the dictionary using path compression"""
957 parent, relink = tree.get(value), list()
958 while parent is not None:
959 relink.append(value)
960 value, parent = parent, tree.get(parent)
961 tree.update(dict.fromkeys(relink, value))
962 return value
964 def slope_from(position):
965 def slope(uvedge):
966 vec = (uvedge.vb.co - uvedge.va.co) if uvedge.va.tup == position else (uvedge.va.co - uvedge.vb.co)
967 return (vec.y / vec.length + 1) if ((vec.x, vec.y) > (0, 0)) else (-1 - vec.y / vec.length)
968 return slope
970 # find edge in other and in self
971 for uvedge in edge.uvedges:
972 if uvedge.uvface.face in uvedge.edge.main_faces:
973 if uvedge.uvface.island is self and uvedge in self.boundary:
974 uvedge_a = uvedge
975 elif uvedge.uvface.island is other and uvedge in other.boundary:
976 uvedge_b = uvedge
977 else:
978 return False
980 # check if vertices and normals are aligned correctly
981 verts_flipped = uvedge_b.va.vertex is uvedge_a.va.vertex
982 flipped = verts_flipped ^ uvedge_a.uvface.flipped ^ uvedge_b.uvface.flipped
983 # determine rotation
984 # NOTE: if the edges differ in length, the matrix will involve uniform scaling.
985 # Such situation may occur in the case of twisted n-gons
986 first_b, second_b = (uvedge_b.va, uvedge_b.vb) if not verts_flipped else (uvedge_b.vb, uvedge_b.va)
987 if not flipped:
988 rot = fitting_matrix(first_b.co - second_b.co, uvedge_a.vb.co - uvedge_a.va.co)
989 else:
990 flip = M.Matrix(((-1, 0), (0, 1)))
991 rot = fitting_matrix(flip * (first_b.co - second_b.co), uvedge_a.vb.co - uvedge_a.va.co) * flip
992 trans = uvedge_a.vb.co - rot * first_b.co
993 # extract and transform island_b's boundary
994 phantoms = {uvvertex: UVVertex(rot*uvvertex.co + trans, uvvertex.vertex) for uvvertex in other.vertices}
996 # check the size of the resulting island
997 if size_limit:
998 # first check: bounding box
999 left = min(min(seg.min.co.x for seg in self.boundary), min(vertex.co.x for vertex in phantoms))
1000 right = max(max(seg.max.co.x for seg in self.boundary), max(vertex.co.x for vertex in phantoms))
1001 bottom = min(min(seg.bottom for seg in self.boundary), min(vertex.co.y for vertex in phantoms))
1002 top = max(max(seg.top for seg in self.boundary), max(vertex.co.y for vertex in phantoms))
1003 bbox_width = right - left
1004 bbox_height = top - bottom
1005 if min(bbox_width, bbox_height)**2 > size_limit.x**2 + size_limit.y**2:
1006 return False
1007 if (bbox_width > size_limit.x or bbox_height > size_limit.y) and (bbox_height > size_limit.x or bbox_width > size_limit.y):
1008 # further checks (TODO!)
1009 # for the time being, just throw this piece away
1010 return False
1012 distance_limit = edge.vector.length_squared * epsilon
1013 # try and merge UVVertices closer than sqrt(distance_limit)
1014 merged_uvedges = set()
1015 merged_uvedge_pairs = list()
1017 # merge all uvvertices that are close enough using a union-find structure
1018 # uvvertices will be merged only in cases other->self and self->self
1019 # all resulting groups are merged together to a uvvertex of self
1020 is_merged_mine = False
1021 shared_vertices = self.uvverts_by_id.keys() & other.uvverts_by_id.keys()
1022 for vertex_id in shared_vertices:
1023 uvs = self.uvverts_by_id[vertex_id] + other.uvverts_by_id[vertex_id]
1024 len_mine = len(self.uvverts_by_id[vertex_id])
1025 merged = dict()
1026 for i, a in enumerate(uvs[:len_mine]):
1027 i = root_find(i, merged)
1028 for j, b in enumerate(uvs[i+1:], i+1):
1029 b = b if j < len_mine else phantoms[b]
1030 j = root_find(j, merged)
1031 if i == j:
1032 continue
1033 i, j = (j, i) if j < i else (i, j)
1034 if (a.co - b.co).length_squared < distance_limit:
1035 merged[j] = i
1036 for source, target in merged.items():
1037 target = root_find(target, merged)
1038 phantoms[uvs[source]] = uvs[target]
1039 is_merged_mine |= (source < len_mine) # remember that a vertex of this island has been merged
1041 for uvedge in (chain(self.boundary, other.boundary) if is_merged_mine else other.boundary):
1042 for partner in uvedge.edge.uvedges:
1043 if partner is not uvedge:
1044 paired_a, paired_b = phantoms.get(partner.vb, partner.vb), phantoms.get(partner.va, partner.va)
1045 if (partner.uvface.flipped ^ flipped) != uvedge.uvface.flipped:
1046 paired_a, paired_b = paired_b, paired_a
1047 if phantoms.get(uvedge.va, uvedge.va) is paired_a and phantoms.get(uvedge.vb, uvedge.vb) is paired_b:
1048 # if these two edges will get merged, add them both to the set
1049 merged_uvedges.update((uvedge, partner))
1050 merged_uvedge_pairs.append((uvedge, partner))
1051 break
1053 if uvedge_b not in merged_uvedges:
1054 raise UnfoldError("Export failed. Please report this error, including the model if you can.")
1056 boundary_other = [
1057 PhantomUVEdge(phantoms[uvedge.va], phantoms[uvedge.vb], flipped ^ uvedge.uvface.flipped)
1058 for uvedge in other.boundary if uvedge not in merged_uvedges]
1059 # TODO: if is_merged_mine, it might make sense to create a similar list from self.boundary as well
1061 incidence = {vertex.tup for vertex in phantoms.values()}.intersection(vertex.tup for vertex in self.vertices)
1062 incidence = {position: list() for position in incidence} # from now on, 'incidence' is a dict
1063 for uvedge in chain(boundary_other, self.boundary):
1064 if uvedge.va.co == uvedge.vb.co:
1065 continue
1066 for vertex in (uvedge.va, uvedge.vb):
1067 site = incidence.get(vertex.tup)
1068 if site is not None:
1069 site.append(uvedge)
1070 for position, segments in incidence.items():
1071 if len(segments) <= 2:
1072 continue
1073 segments.sort(key=slope_from(position))
1074 for right, left in pairs(segments):
1075 is_left_ccw = left.is_uvface_upwards() ^ (left.max.tup == position)
1076 is_right_ccw = right.is_uvface_upwards() ^ (right.max.tup == position)
1077 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:
1078 return False
1079 if (not is_right_ccw and right not in merged_uvedges) ^ (is_left_ccw and left not in merged_uvedges):
1080 return False
1082 # check for self-intersections
1083 try:
1084 try:
1085 sweepline = QuickSweepline() if self.has_safe_geometry and other.has_safe_geometry else BruteSweepline()
1086 sweep(sweepline, (uvedge for uvedge in chain(boundary_other, self.boundary)))
1087 self.has_safe_geometry &= other.has_safe_geometry
1088 except GeometryError:
1089 sweep(BruteSweepline(), (uvedge for uvedge in chain(boundary_other, self.boundary)))
1090 self.has_safe_geometry = False
1091 except Intersection:
1092 return False
1094 # mark all edges that connect the islands as not cut
1095 for uvedge in merged_uvedges:
1096 uvedge.edge.is_main_cut = False
1098 # include all trasformed vertices as mine
1099 self.vertices.update(phantoms.values())
1101 # update the uvverts_by_id dictionary
1102 for source, target in phantoms.items():
1103 present = self.uvverts_by_id.get(target.vertex.index)
1104 if not present:
1105 self.uvverts_by_id[target.vertex.index] = [target]
1106 else:
1107 # emulation of set behavior... sorry, it is faster
1108 if source in present:
1109 present.remove(source)
1110 if target not in present:
1111 present.append(target)
1113 # re-link uvedges and uvfaces to their transformed locations
1114 for uvedge in other.edges:
1115 uvedge.island = self
1116 uvedge.va = phantoms[uvedge.va]
1117 uvedge.vb = phantoms[uvedge.vb]
1118 uvedge.update()
1119 if is_merged_mine:
1120 for uvedge in self.edges:
1121 uvedge.va = phantoms.get(uvedge.va, uvedge.va)
1122 uvedge.vb = phantoms.get(uvedge.vb, uvedge.vb)
1123 self.edges.update(other.edges)
1125 for uvface in other.faces:
1126 uvface.island = self
1127 uvface.vertices = [phantoms[uvvertex] for uvvertex in uvface.vertices]
1128 uvface.uvvertex_by_id = {
1129 index: phantoms[uvvertex]
1130 for index, uvvertex in uvface.uvvertex_by_id.items()}
1131 uvface.flipped ^= flipped
1132 if is_merged_mine:
1133 # there may be own uvvertices that need to be replaced by phantoms
1134 for uvface in self.faces:
1135 if any(uvvertex in phantoms for uvvertex in uvface.vertices):
1136 uvface.vertices = [phantoms.get(uvvertex, uvvertex) for uvvertex in uvface.vertices]
1137 uvface.uvvertex_by_id = {
1138 index: phantoms.get(uvvertex, uvvertex)
1139 for index, uvvertex in uvface.uvvertex_by_id.items()}
1140 self.faces.extend(other.faces)
1142 self.boundary = [
1143 uvedge for uvedge in chain(self.boundary, other.boundary)
1144 if uvedge not in merged_uvedges]
1146 for uvedge, partner in merged_uvedge_pairs:
1147 # make sure that main faces are the ones actually merged (this changes nothing in most cases)
1148 uvedge.edge.main_faces[:] = uvedge.uvface.face, partner.uvface.face
1150 # everything seems to be OK
1151 return True
1153 def add_marker(self, marker):
1154 self.fake_vertices.extend(marker.bounds)
1155 self.markers.append(marker)
1157 def generate_label(self, label=None, abbreviation=None):
1158 """Assign a name to this island automatically"""
1159 abbr = abbreviation or self.abbreviation or str(self.number)
1160 # TODO: dots should be added in the last instant when outputting any text
1161 if is_upsidedown_wrong(abbr):
1162 abbr += "."
1163 self.label = label or self.label or "Island {}".format(self.number)
1164 self.abbreviation = abbr
1166 def save_uv(self, tex, cage_size):
1167 """Save UV Coordinates of all UVFaces to a given UV texture
1168 tex: UV Texture layer to use (BPy MeshUVLoopLayer struct)
1169 page_size: size of the page in pixels (vector)"""
1170 texface = tex.data
1171 for uvface in self.faces:
1172 for i, uvvertex in enumerate(uvface.vertices):
1173 uv = uvvertex.co + self.pos
1174 texface[uvface.face.loop_start + i].uv[0] = uv.x / cage_size.x
1175 texface[uvface.face.loop_start + i].uv[1] = uv.y / cage_size.y
1177 def save_uv_separate(self, tex):
1178 """Save UV Coordinates of all UVFaces to a given UV texture, spanning from 0 to 1
1179 tex: UV Texture layer to use (BPy MeshUVLoopLayer struct)
1180 page_size: size of the page in pixels (vector)"""
1181 texface = tex.data
1182 scale_x, scale_y = 1 / self.bounding_box.x, 1 / self.bounding_box.y
1183 for uvface in self.faces:
1184 for i, uvvertex in enumerate(uvface.vertices):
1185 texface[uvface.face.loop_start + i].uv[0] = uvvertex.co.x * scale_x
1186 texface[uvface.face.loop_start + i].uv[1] = uvvertex.co.y * scale_y
1189 class Page:
1190 """Container for several Islands"""
1191 __slots__ = ('islands', 'name', 'image_path')
1193 def __init__(self, num=1):
1194 self.islands = list()
1195 self.name = "page{}".format(num)
1196 self.image_path = None
1199 class UVVertex:
1200 """Vertex in 2D"""
1201 __slots__ = ('co', 'vertex', 'tup')
1203 def __init__(self, vector, vertex=None):
1204 self.co = vector.xy
1205 self.vertex = vertex
1206 self.tup = tuple(self.co)
1208 def __repr__(self):
1209 if self.vertex:
1210 return "UV {} [{:.3f}, {:.3f}]".format(self.vertex.index, self.co.x, self.co.y)
1211 else:
1212 return "UV * [{:.3f}, {:.3f}]".format(self.co.x, self.co.y)
1215 class UVEdge:
1216 """Edge in 2D"""
1217 # Every UVEdge is attached to only one UVFace
1218 # UVEdges are doubled as needed because they both have to point clockwise around their faces
1219 __slots__ = ('va', 'vb', 'island', 'uvface', 'edge',
1220 'min', 'max', 'bottom', 'top',
1221 'neighbor_left', 'neighbor_right', 'sticker')
1223 def __init__(self, vertex1: UVVertex, vertex2: UVVertex, island: Island, uvface, edge):
1224 self.va = vertex1
1225 self.vb = vertex2
1226 self.update()
1227 self.island = island
1228 self.uvface = uvface
1229 self.sticker = None
1230 self.edge = edge
1232 def update(self):
1233 """Update data if UVVertices have moved"""
1234 self.min, self.max = (self.va, self.vb) if (self.va.tup < self.vb.tup) else (self.vb, self.va)
1235 y1, y2 = self.va.co.y, self.vb.co.y
1236 self.bottom, self.top = (y1, y2) if y1 < y2 else (y2, y1)
1238 def is_uvface_upwards(self):
1239 return (self.va.tup < self.vb.tup) ^ self.uvface.flipped
1241 def __repr__(self):
1242 return "({0.va} - {0.vb})".format(self)
1245 class PhantomUVEdge:
1246 """Temporary 2D Segment for calculations"""
1247 __slots__ = ('va', 'vb', 'min', 'max', 'bottom', 'top')
1249 def __init__(self, vertex1: UVVertex, vertex2: UVVertex, flip):
1250 self.va, self.vb = (vertex2, vertex1) if flip else (vertex1, vertex2)
1251 self.min, self.max = (self.va, self.vb) if (self.va.tup < self.vb.tup) else (self.vb, self.va)
1252 y1, y2 = self.va.co.y, self.vb.co.y
1253 self.bottom, self.top = (y1, y2) if y1 < y2 else (y2, y1)
1255 def is_uvface_upwards(self):
1256 return self.va.tup < self.vb.tup
1258 def __repr__(self):
1259 return "[{0.va} - {0.vb}]".format(self)
1262 class UVFace:
1263 """Face in 2D"""
1264 __slots__ = ('vertices', 'edges', 'face', 'island', 'flipped', 'uvvertex_by_id')
1266 def __init__(self, face: Face, island: Island):
1267 """Creace an UVFace from a Face and a fixed edge.
1268 face: Face to take coordinates from
1269 island: Island to register itself in
1270 fixed_edge: Edge to connect to (that already has UV coordinates)"""
1271 self.vertices = list()
1272 self.face = face
1273 face.uvface = self
1274 self.island = island
1275 self.flipped = False # a flipped UVFace has edges clockwise
1277 rot = z_up_matrix(face.normal)
1278 self.uvvertex_by_id = dict() # link vertex id -> UVVertex
1279 for vertex in face.vertices:
1280 uvvertex = UVVertex(rot * vertex.co, vertex)
1281 self.vertices.append(uvvertex)
1282 self.uvvertex_by_id[vertex.index] = uvvertex
1283 self.edges = list()
1284 edge_by_verts = dict()
1285 for edge in face.edges:
1286 edge_by_verts[(edge.va.index, edge.vb.index)] = edge
1287 edge_by_verts[(edge.vb.index, edge.va.index)] = edge
1288 for va, vb in pairs(self.vertices):
1289 edge = edge_by_verts[(va.vertex.index, vb.vertex.index)]
1290 uvedge = UVEdge(va, vb, island, self, edge)
1291 self.edges.append(uvedge)
1292 edge.uvedges.append(uvedge) # FIXME: editing foreign attribute
1295 class Arrow:
1296 """Mark in the document: an arrow denoting the number of the edge it points to"""
1297 __slots__ = ('bounds', 'center', 'rot', 'text', 'size')
1299 def __init__(self, uvedge, size, index):
1300 self.text = str(index)
1301 edge = (uvedge.vb.co - uvedge.va.co) if not uvedge.uvface.flipped else (uvedge.va.co - uvedge.vb.co)
1302 self.center = (uvedge.va.co + uvedge.vb.co) / 2
1303 self.size = size
1304 tangent = edge.normalized()
1305 cos, sin = tangent
1306 self.rot = M.Matrix(((cos, -sin), (sin, cos)))
1307 normal = M.Vector((sin, -cos))
1308 self.bounds = [self.center, self.center + (1.2*normal + tangent)*size, self.center + (1.2*normal - tangent)*size]
1311 class Sticker:
1312 """Mark in the document: sticker tab"""
1313 __slots__ = ('bounds', 'center', 'rot', 'text', 'width', 'vertices')
1315 def __init__(self, uvedge, default_width=0.005, index=None, target_island=None):
1316 """Sticker is directly attached to the given UVEdge"""
1317 first_vertex, second_vertex = (uvedge.va, uvedge.vb) if not uvedge.uvface.flipped else (uvedge.vb, uvedge.va)
1318 edge = first_vertex.co - second_vertex.co
1319 sticker_width = min(default_width, edge.length / 2)
1320 other = uvedge.edge.other_uvedge(uvedge) # This is the other uvedge - the sticking target
1322 other_first, other_second = (other.va, other.vb) if not other.uvface.flipped else (other.vb, other.va)
1323 other_edge = other_second.co - other_first.co
1325 # angle a is at vertex uvedge.va, b is at uvedge.vb
1326 cos_a = cos_b = 0.5
1327 sin_a = sin_b = 0.75**0.5
1328 # len_a is length of the side adjacent to vertex a, len_b likewise
1329 len_a = len_b = sticker_width / sin_a
1331 # fix overlaps with the most often neighbour - its sticking target
1332 if first_vertex == other_second:
1333 cos_a = max(cos_a, (edge*other_edge) / (edge.length**2)) # angles between pi/3 and 0
1334 elif second_vertex == other_first:
1335 cos_b = max(cos_b, (edge*other_edge) / (edge.length**2)) # angles between pi/3 and 0
1337 # Fix tabs for sticking targets with small angles
1338 # Index of other uvedge in its face (not in its island)
1339 other_idx = other.uvface.edges.index(other)
1340 # Left and right neighbors in the face
1341 other_face_neighbor_left = other.uvface.edges[(other_idx+1) % len(other.uvface.edges)]
1342 other_face_neighbor_right = other.uvface.edges[(other_idx-1) % len(other.uvface.edges)]
1343 other_edge_neighbor_a = other_face_neighbor_left.vb.co - other.vb.co
1344 other_edge_neighbor_b = other_face_neighbor_right.va.co - other.va.co
1345 # Adjacent angles in the face
1346 cos_a = max(cos_a, (-other_edge*other_edge_neighbor_a) / (other_edge.length*other_edge_neighbor_a.length))
1347 cos_b = max(cos_b, (other_edge*other_edge_neighbor_b) / (other_edge.length*other_edge_neighbor_b.length))
1349 # Calculate the lengths of the glue tab edges using the possibly smaller angles
1350 sin_a = abs(1 - cos_a**2)**0.5
1351 len_b = min(len_a, (edge.length * sin_a) / (sin_a * cos_b + sin_b * cos_a))
1352 len_a = 0 if sin_a == 0 else min(sticker_width / sin_a, (edge.length - len_b*cos_b) / cos_a)
1354 sin_b = abs(1 - cos_b**2)**0.5
1355 len_a = min(len_a, (edge.length * sin_b) / (sin_a * cos_b + sin_b * cos_a))
1356 len_b = 0 if sin_b == 0 else min(sticker_width / sin_b, (edge.length - len_a * cos_a) / cos_b)
1358 v3 = UVVertex(second_vertex.co + M.Matrix(((cos_b, -sin_b), (sin_b, cos_b))) * edge * len_b / edge.length)
1359 v4 = UVVertex(first_vertex.co + M.Matrix(((-cos_a, -sin_a), (sin_a, -cos_a))) * edge * len_a / edge.length)
1360 if v3.co != v4.co:
1361 self.vertices = [second_vertex, v3, v4, first_vertex]
1362 else:
1363 self.vertices = [second_vertex, v3, first_vertex]
1365 sin, cos = edge.y / edge.length, edge.x / edge.length
1366 self.rot = M.Matrix(((cos, -sin), (sin, cos)))
1367 self.width = sticker_width * 0.9
1368 if index and target_island is not uvedge.island:
1369 self.text = "{}:{}".format(target_island.abbreviation, index)
1370 else:
1371 self.text = index
1372 self.center = (uvedge.va.co + uvedge.vb.co) / 2 + self.rot*M.Vector((0, self.width*0.2))
1373 self.bounds = [v3.co, v4.co, self.center] if v3.co != v4.co else [v3.co, self.center]
1376 class NumberAlone:
1377 """Mark in the document: numbering inside the island denoting edges to be sticked"""
1378 __slots__ = ('bounds', 'center', 'rot', 'text', 'size')
1380 def __init__(self, uvedge, index, default_size=0.005):
1381 """Sticker is directly attached to the given UVEdge"""
1382 edge = (uvedge.va.co - uvedge.vb.co) if not uvedge.uvface.flipped else (uvedge.vb.co - uvedge.va.co)
1384 self.size = default_size
1385 sin, cos = edge.y / edge.length, edge.x / edge.length
1386 self.rot = M.Matrix(((cos, -sin), (sin, cos)))
1387 self.text = index
1388 self.center = (uvedge.va.co + uvedge.vb.co) / 2 - self.rot*M.Vector((0, self.size*1.2))
1389 self.bounds = [self.center]
1392 class SVG:
1393 """Simple SVG exporter"""
1395 def __init__(self, page_size: M.Vector, style, margin, pure_net=True, angle_epsilon=0.01):
1396 """Initialize document settings.
1397 page_size: document dimensions in meters
1398 pure_net: if True, do not use image"""
1399 self.page_size = page_size
1400 self.pure_net = pure_net
1401 self.style = style
1402 self.margin = margin
1403 self.text_size = 12
1404 self.angle_epsilon = angle_epsilon
1406 @classmethod
1407 def encode_image(cls, bpy_image):
1408 import tempfile
1409 import base64
1410 with tempfile.TemporaryDirectory() as directory:
1411 filename = directory + "/i.png"
1412 bpy_image.filepath_raw = filename
1413 bpy_image.save()
1414 return base64.encodebytes(open(filename, "rb").read()).decode('ascii')
1416 def format_vertex(self, vector, pos=M.Vector((0, 0))):
1417 """Return a string with both coordinates of the given vertex."""
1418 x, y = vector + pos
1419 return "{:.6f} {:.6f}".format((x + self.margin) * 1000, (self.page_size.y - y - self.margin) * 1000)
1421 def write(self, mesh, filename):
1422 """Write data to a file given by its name."""
1423 line_through = " L ".join # used for formatting of SVG path data
1424 rows = "\n".join
1426 dl = ["{:.2f}".format(length * self.style.line_width * 1000) for length in (2, 5, 10)]
1427 format_style = {
1428 'SOLID': "none", 'DOT': "{0},{1}".format(*dl), 'DASH': "{1},{2}".format(*dl),
1429 'LONGDASH': "{2},{1}".format(*dl), 'DASHDOT': "{2},{1},{0},{1}".format(*dl)}
1431 def format_color(vec):
1432 return "#{:02x}{:02x}{:02x}".format(round(vec[0] * 255), round(vec[1] * 255), round(vec[2] * 255))
1434 def format_matrix(matrix):
1435 return " ".join("{:.6f}".format(cell) for column in matrix for cell in column)
1437 def path_convert(string, relto=os_path.dirname(filename)):
1438 assert(os_path) # check the module was imported
1439 string = os_path.relpath(string, relto)
1440 if os_path.sep != '/':
1441 string = string.replace(os_path.sep, '/')
1442 return string
1444 styleargs = {
1445 name: format_color(getattr(self.style, name)) for name in (
1446 "outer_color", "outbg_color", "convex_color", "concave_color", "freestyle_color",
1447 "inbg_color", "sticker_fill", "text_color")}
1448 styleargs.update({
1449 name: format_style[getattr(self.style, name)] for name in
1450 ("outer_style", "convex_style", "concave_style", "freestyle_style")})
1451 styleargs.update({
1452 name: getattr(self.style, attr)[3] for name, attr in (
1453 ("outer_alpha", "outer_color"), ("outbg_alpha", "outbg_color"),
1454 ("convex_alpha", "convex_color"), ("concave_alpha", "concave_color"),
1455 ("freestyle_alpha", "freestyle_color"),
1456 ("inbg_alpha", "inbg_color"), ("sticker_alpha", "sticker_fill"),
1457 ("text_alpha", "text_color"))})
1458 styleargs.update({
1459 name: getattr(self.style, name) * self.style.line_width * 1000 for name in
1460 ("outer_width", "convex_width", "concave_width", "freestyle_width", "outbg_width", "inbg_width")})
1461 for num, page in enumerate(mesh.pages):
1462 page_filename = "{}_{}.svg".format(filename[:filename.rfind(".svg")], page.name) if len(mesh.pages) > 1 else filename
1463 with open(page_filename, 'w') as f:
1464 print(self.svg_base.format(width=self.page_size.x*1000, height=self.page_size.y*1000), file=f)
1465 print(self.css_base.format(**styleargs), file=f)
1466 if page.image_path:
1467 print(
1468 self.image_linked_tag.format(
1469 pos="{0:.6f} {0:.6f}".format(self.margin*1000),
1470 width=(self.page_size.x - 2 * self.margin)*1000,
1471 height=(self.page_size.y - 2 * self.margin)*1000,
1472 path=path_convert(page.image_path)),
1473 file=f)
1474 if len(page.islands) > 1:
1475 print("<g>", file=f)
1477 for island in page.islands:
1478 print("<g>", file=f)
1479 if island.image_path:
1480 print(
1481 self.image_linked_tag.format(
1482 pos=self.format_vertex(island.pos + M.Vector((0, island.bounding_box.y))),
1483 width=island.bounding_box.x*1000,
1484 height=island.bounding_box.y*1000,
1485 path=path_convert(island.image_path)),
1486 file=f)
1487 elif island.embedded_image:
1488 print(
1489 self.image_embedded_tag.format(
1490 pos=self.format_vertex(island.pos + M.Vector((0, island.bounding_box.y))),
1491 width=island.bounding_box.x*1000,
1492 height=island.bounding_box.y*1000,
1493 path=island.image_path),
1494 island.embedded_image, "'/>",
1495 file=f, sep="")
1496 if island.title:
1497 print(
1498 self.text_tag.format(
1499 size=1000 * self.text_size,
1500 x=1000 * (island.bounding_box.x*0.5 + island.pos.x + self.margin),
1501 y=1000 * (self.page_size.y - island.pos.y - self.margin - 0.2 * self.text_size),
1502 label=island.title),
1503 file=f)
1505 data_markers, data_stickerfill, data_outer, data_convex, data_concave, data_freestyle = (list() for i in range(6))
1506 for marker in island.markers:
1507 if isinstance(marker, Sticker):
1508 data_stickerfill.append("M {} Z".format(
1509 line_through(self.format_vertex(vertex.co, island.pos) for vertex in marker.vertices)))
1510 if marker.text:
1511 data_markers.append(self.text_transformed_tag.format(
1512 label=marker.text,
1513 pos=self.format_vertex(marker.center, island.pos),
1514 mat=format_matrix(marker.rot),
1515 size=marker.width * 1000))
1516 elif isinstance(marker, Arrow):
1517 size = marker.size * 1000
1518 position = marker.center + marker.rot*marker.size*M.Vector((0, -0.9))
1519 data_markers.append(self.arrow_marker_tag.format(
1520 index=marker.text,
1521 arrow_pos=self.format_vertex(marker.center, island.pos),
1522 scale=size,
1523 pos=self.format_vertex(position, island.pos - marker.size*M.Vector((0, 0.4))),
1524 mat=format_matrix(size * marker.rot)))
1525 elif isinstance(marker, NumberAlone):
1526 data_markers.append(self.text_transformed_tag.format(
1527 label=marker.text,
1528 pos=self.format_vertex(marker.center, island.pos),
1529 mat=format_matrix(marker.rot),
1530 size=marker.size * 1000))
1531 if data_stickerfill and self.style.sticker_fill[3] > 0:
1532 print("<path class='sticker' d='", rows(data_stickerfill), "'/>", file=f)
1534 outer_edges = set(island.boundary)
1535 while outer_edges:
1536 data_loop = list()
1537 uvedge = outer_edges.pop()
1538 while 1:
1539 if uvedge.sticker:
1540 data_loop.extend(self.format_vertex(vertex.co, island.pos) for vertex in uvedge.sticker.vertices[1:])
1541 else:
1542 vertex = uvedge.vb if uvedge.uvface.flipped else uvedge.va
1543 data_loop.append(self.format_vertex(vertex.co, island.pos))
1544 uvedge = uvedge.neighbor_right
1545 try:
1546 outer_edges.remove(uvedge)
1547 except KeyError:
1548 break
1549 data_outer.append("M {} Z".format(line_through(data_loop)))
1551 for uvedge in island.edges:
1552 edge = uvedge.edge
1553 if edge.is_cut(uvedge.uvface.face) and not uvedge.sticker:
1554 continue
1555 data_uvedge = "M {}".format(
1556 line_through(self.format_vertex(vertex.co, island.pos) for vertex in (uvedge.va, uvedge.vb)))
1557 if edge.freestyle:
1558 data_freestyle.append(data_uvedge)
1559 # each uvedge is in two opposite-oriented variants; we want to add each only once
1560 if uvedge.sticker or uvedge.uvface.flipped != (uvedge.va.vertex.index > uvedge.vb.vertex.index):
1561 if edge.angle > self.angle_epsilon:
1562 data_convex.append(data_uvedge)
1563 elif edge.angle < -self.angle_epsilon:
1564 data_concave.append(data_uvedge)
1565 if island.is_inside_out:
1566 data_convex, data_concave = data_concave, data_convex
1568 if data_freestyle:
1569 print("<path class='freestyle' d='", rows(data_freestyle), "'/>", file=f)
1570 if (data_convex or data_concave) and not self.pure_net and self.style.use_inbg:
1571 print("<path class='inner_background' d='", rows(data_convex + data_concave), "'/>", file=f)
1572 if data_convex:
1573 print("<path class='convex' d='", rows(data_convex), "'/>", file=f)
1574 if data_concave:
1575 print("<path class='concave' d='", rows(data_concave), "'/>", file=f)
1576 if data_outer:
1577 if not self.pure_net and self.style.use_outbg:
1578 print("<path class='outer_background' d='", rows(data_outer), "'/>", file=f)
1579 print("<path class='outer' d='", rows(data_outer), "'/>", file=f)
1580 if data_markers:
1581 print(rows(data_markers), file=f)
1582 print("</g>", file=f)
1584 if len(page.islands) > 1:
1585 print("</g>", file=f)
1586 print("</svg>", file=f)
1588 image_linked_tag = "<image transform='translate({pos})' width='{width:.6f}' height='{height:.6f}' xlink:href='{path}'/>"
1589 image_embedded_tag = "<image transform='translate({pos})' width='{width:.6f}' height='{height:.6f}' xlink:href='data:image/png;base64,"
1590 text_tag = "<text transform='translate({x} {y})' style='font-size:{size:.2f}'><tspan>{label}</tspan></text>"
1591 text_transformed_tag = "<text transform='matrix({mat} {pos})' style='font-size:{size:.2f}'><tspan>{label}</tspan></text>"
1592 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'/>" \
1593 "<text transform='translate({pos})' style='font-size:{scale:.2f}'><tspan>{index}</tspan></text></g>"
1595 svg_base = """<?xml version='1.0' encoding='UTF-8' standalone='no'?>
1596 <svg xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' version='1.1'
1597 width='{width:.2f}mm' height='{height:.2f}mm' viewBox='0 0 {width:.2f} {height:.2f}'>"""
1599 css_base = """<style type="text/css">
1600 path {{
1601 fill: none;
1602 stroke-linecap: butt;
1603 stroke-linejoin: bevel;
1604 stroke-dasharray: none;
1606 path.outer {{
1607 stroke: {outer_color};
1608 stroke-dasharray: {outer_style};
1609 stroke-dashoffset: 0;
1610 stroke-width: {outer_width:.2};
1611 stroke-opacity: {outer_alpha:.2};
1613 path.convex {{
1614 stroke: {convex_color};
1615 stroke-dasharray: {convex_style};
1616 stroke-dashoffset:0;
1617 stroke-width:{convex_width:.2};
1618 stroke-opacity: {convex_alpha:.2}
1620 path.concave {{
1621 stroke: {concave_color};
1622 stroke-dasharray: {concave_style};
1623 stroke-dashoffset: 0;
1624 stroke-width: {concave_width:.2};
1625 stroke-opacity: {concave_alpha:.2}
1627 path.freestyle {{
1628 stroke: {freestyle_color};
1629 stroke-dasharray: {freestyle_style};
1630 stroke-dashoffset: 0;
1631 stroke-width: {freestyle_width:.2};
1632 stroke-opacity: {freestyle_alpha:.2}
1634 path.outer_background {{
1635 stroke: {outbg_color};
1636 stroke-opacity: {outbg_alpha};
1637 stroke-width: {outbg_width:.2}
1639 path.inner_background {{
1640 stroke: {inbg_color};
1641 stroke-opacity: {inbg_alpha};
1642 stroke-width: {inbg_width:.2}
1644 path.sticker {{
1645 fill: {sticker_fill};
1646 stroke: none;
1647 fill-opacity: {sticker_alpha:.2};
1649 path.arrow {{
1650 fill: #000;
1652 text {{
1653 font-style: normal;
1654 fill: {text_color};
1655 fill-opacity: {text_alpha:.2};
1656 stroke: none;
1658 text, tspan {{
1659 text-anchor:middle;
1661 </style>"""
1664 class PDF:
1665 """Simple PDF exporter"""
1667 mm_to_pt = 72 / 25.4
1668 character_width_packed = {
1669 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·ÌÍÎÏìíîï',
1670 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¿ßø',
1671 667: '&ABEKPSVXY\x8a\x9fÀÁÂÃÄÅÈÉÊËÝÞ', 722: 'CDHNRUwÇÐÑÙÚÛÜ', 737: '©®', 778: 'GOQÒÓÔÕÖØ', 833: 'Mm¼½¾', 889: '%æ', 944: 'W\x9c', 1000: '\x85\x89\x8c\x97\x99Æ', 1015: '@', }
1672 character_width = {c: value for (value, chars) in character_width_packed.items() for c in chars}
1674 def __init__(self, page_size: M.Vector, style, margin, pure_net=True, angle_epsilon=0.01):
1675 self.page_size = page_size
1676 self.style = style
1677 self.margin = M.Vector((margin, margin))
1678 self.pure_net = pure_net
1679 self.angle_epsilon = angle_epsilon
1681 def text_width(self, text, scale=None):
1682 return (scale or self.text_size) * sum(self.character_width.get(c, 556) for c in text) / 1000
1684 @classmethod
1685 def encode_image(cls, bpy_image):
1686 data = bytes(int(255 * px) for (i, px) in enumerate(bpy_image.pixels) if i % 4 != 3)
1687 image = {
1688 "Type": "XObject", "Subtype": "Image", "Width": bpy_image.size[0], "Height": bpy_image.size[1],
1689 "ColorSpace": "DeviceRGB", "BitsPerComponent": 8, "Interpolate": True,
1690 "Filter": ["ASCII85Decode", "FlateDecode"], "stream": data}
1691 return image
1693 def write(self, mesh, filename):
1694 def format_dict(obj, refs=tuple()):
1695 return "<< " + "".join("/{} {}\n".format(key, format_value(value, refs)) for (key, value) in obj.items()) + ">>"
1697 def line_through(seq):
1698 return "".join("{0.x:.6f} {0.y:.6f} {1} ".format(1000*v.co, c) for (v, c) in zip(seq, chain("m", repeat("l"))))
1700 def format_value(value, refs=tuple()):
1701 if value in refs:
1702 return "{} 0 R".format(refs.index(value) + 1)
1703 elif type(value) is dict:
1704 return format_dict(value, refs)
1705 elif type(value) in (list, tuple):
1706 return "[ " + " ".join(format_value(item, refs) for item in value) + " ]"
1707 elif type(value) is int:
1708 return str(value)
1709 elif type(value) is float:
1710 return "{:.6f}".format(value)
1711 elif type(value) is bool:
1712 return "true" if value else "false"
1713 else:
1714 return "/{}".format(value) # this script can output only PDF names, no strings
1716 def write_object(index, obj, refs, f, stream=None):
1717 byte_count = f.write("{} 0 obj\n".format(index))
1718 if type(obj) is not dict:
1719 stream, obj = obj, dict()
1720 elif "stream" in obj:
1721 stream = obj.pop("stream")
1722 if stream:
1723 if True or type(stream) is bytes:
1724 obj["Filter"] = ["ASCII85Decode", "FlateDecode"]
1725 stream = encode(stream)
1726 obj["Length"] = len(stream)
1727 byte_count += f.write(format_dict(obj, refs))
1728 if stream:
1729 byte_count += f.write("\nstream\n")
1730 byte_count += f.write(stream)
1731 byte_count += f.write("\nendstream")
1732 return byte_count + f.write("\nendobj\n")
1734 def encode(data):
1735 from base64 import a85encode
1736 from zlib import compress
1737 if hasattr(data, "encode"):
1738 data = data.encode()
1739 return a85encode(compress(data), adobe=True, wrapcol=250)[2:].decode()
1741 page_size_pt = 1000 * self.mm_to_pt * self.page_size
1742 root = {"Type": "Pages", "MediaBox": [0, 0, page_size_pt.x, page_size_pt.y], "Kids": list()}
1743 catalog = {"Type": "Catalog", "Pages": root}
1744 font = {
1745 "Type": "Font", "Subtype": "Type1", "Name": "F1",
1746 "BaseFont": "Helvetica", "Encoding": "MacRomanEncoding"}
1748 dl = [length * self.style.line_width * 1000 for length in (1, 4, 9)]
1749 format_style = {
1750 'SOLID': list(), 'DOT': [dl[0], dl[1]], 'DASH': [dl[1], dl[2]],
1751 'LONGDASH': [dl[2], dl[1]], 'DASHDOT': [dl[2], dl[1], dl[0], dl[1]]}
1752 styles = {
1753 "Gtext": {"ca": self.style.text_color[3], "Font": [font, 1000 * self.text_size]},
1754 "Gsticker": {"ca": self.style.sticker_fill[3]}}
1755 for name in ("outer", "convex", "concave", "freestyle"):
1756 gs = {
1757 "LW": self.style.line_width * 1000 * getattr(self.style, name + "_width"),
1758 "CA": getattr(self.style, name + "_color")[3],
1759 "D": [format_style[getattr(self.style, name + "_style")], 0]}
1760 styles["G" + name] = gs
1761 for name in ("outbg", "inbg"):
1762 gs = {
1763 "LW": self.style.line_width * 1000 * getattr(self.style, name + "_width"),
1764 "CA": getattr(self.style, name + "_color")[3],
1765 "D": [format_style['SOLID'], 0]}
1766 styles["G" + name] = gs
1768 objects = [root, catalog, font]
1769 objects.extend(styles.values())
1771 for page in mesh.pages:
1772 commands = ["{0:.6f} 0 0 {0:.6f} 0 0 cm".format(self.mm_to_pt)]
1773 resources = {"Font": {"F1": font}, "ExtGState": styles, "XObject": dict()}
1774 for island in page.islands:
1775 commands.append("q 1 0 0 1 {0.x:.6f} {0.y:.6f} cm".format(1000*(self.margin + island.pos)))
1776 if island.embedded_image:
1777 identifier = "Im{}".format(len(resources["XObject"]) + 1)
1778 commands.append(self.command_image.format(1000 * island.bounding_box, identifier))
1779 objects.append(island.embedded_image)
1780 resources["XObject"][identifier] = island.embedded_image
1782 if island.title:
1783 commands.append(self.command_label.format(
1784 size=1000*self.text_size,
1785 x=500 * (island.bounding_box.x - self.text_width(island.title)),
1786 y=1000 * 0.2 * self.text_size,
1787 label=island.title))
1789 data_markers, data_stickerfill, data_outer, data_convex, data_concave, data_freestyle = (list() for i in range(6))
1790 for marker in island.markers:
1791 if isinstance(marker, Sticker):
1792 data_stickerfill.append(line_through(marker.vertices) + "f")
1793 if marker.text:
1794 data_markers.append(self.command_sticker.format(
1795 label=marker.text,
1796 pos=1000*marker.center,
1797 mat=marker.rot,
1798 align=-500 * self.text_width(marker.text, marker.width),
1799 size=1000*marker.width))
1800 elif isinstance(marker, Arrow):
1801 size = 1000 * marker.size
1802 position = 1000 * (marker.center + marker.rot*marker.size*M.Vector((0, -0.9)))
1803 data_markers.append(self.command_arrow.format(
1804 index=marker.text,
1805 arrow_pos=1000 * marker.center,
1806 pos=position - 1000 * M.Vector((0.5 * self.text_width(marker.text), 0.4 * self.text_size)),
1807 mat=size * marker.rot,
1808 size=size))
1809 elif isinstance(marker, NumberAlone):
1810 data_markers.append(self.command_number.format(
1811 label=marker.text,
1812 pos=1000*marker.center,
1813 mat=marker.rot,
1814 size=1000*marker.size))
1816 outer_edges = set(island.boundary)
1817 while outer_edges:
1818 data_loop = list()
1819 uvedge = outer_edges.pop()
1820 while 1:
1821 if uvedge.sticker:
1822 data_loop.extend(uvedge.sticker.vertices[1:])
1823 else:
1824 vertex = uvedge.vb if uvedge.uvface.flipped else uvedge.va
1825 data_loop.append(vertex)
1826 uvedge = uvedge.neighbor_right
1827 try:
1828 outer_edges.remove(uvedge)
1829 except KeyError:
1830 break
1831 data_outer.append(line_through(data_loop) + "s")
1833 for uvedge in island.edges:
1834 edge = uvedge.edge
1835 if edge.is_cut(uvedge.uvface.face) and not uvedge.sticker:
1836 continue
1837 data_uvedge = line_through((uvedge.va, uvedge.vb)) + "S"
1838 if edge.freestyle:
1839 data_freestyle.append(data_uvedge)
1840 # each uvedge is in two opposite-oriented variants; we want to add each only once
1841 if uvedge.sticker or uvedge.uvface.flipped != (uvedge.va.vertex.index > uvedge.vb.vertex.index):
1842 if edge.angle > self.angle_epsilon:
1843 data_convex.append(data_uvedge)
1844 elif edge.angle < -self.angle_epsilon:
1845 data_concave.append(data_uvedge)
1846 if island.is_inside_out:
1847 data_convex, data_concave = data_concave, data_convex
1849 if data_stickerfill and self.style.sticker_fill[3] > 0:
1850 commands.append("/Gsticker gs {0[0]:.3f} {0[1]:.3f} {0[2]:.3f} rg".format(self.style.sticker_fill))
1851 commands.extend(data_stickerfill)
1852 if data_freestyle:
1853 commands.append("/Gfreestyle gs {0[0]:.3f} {0[1]:.3f} {0[2]:.3f} RG".format(self.style.freestyle_color))
1854 commands.extend(data_freestyle)
1855 if (data_convex or data_concave) and not self.pure_net and self.style.use_inbg:
1856 commands.append("/Ginbg gs {0[0]:.3f} {0[1]:.3f} {0[2]:.3f} RG".format(self.style.inbg_color))
1857 commands.extend(chain(data_convex, data_concave))
1858 if data_convex:
1859 commands.append("/Gconvex gs {0[0]:.3f} {0[1]:.3f} {0[2]:.3f} RG".format(self.style.convex_color))
1860 commands.extend(data_convex)
1861 if data_concave:
1862 commands.append("/Gconcave gs {0[0]:.3f} {0[1]:.3f} {0[2]:.3f} RG".format(self.style.concave_color))
1863 commands.extend(data_concave)
1864 if data_outer:
1865 if not self.pure_net and self.style.use_outbg:
1866 commands.append("/Goutbg gs {0[0]:.3f} {0[1]:.3f} {0[2]:.3f} RG".format(self.style.outbg_color))
1867 commands.extend(data_outer)
1868 commands.append("/Gouter gs {0[0]:.3f} {0[1]:.3f} {0[2]:.3f} RG".format(self.style.outer_color))
1869 commands.extend(data_outer)
1870 commands.append("/Gtext gs {0[0]:.3f} {0[1]:.3f} {0[2]:.3f} rg".format(self.style.text_color))
1871 commands.extend(data_markers)
1872 commands.append("Q")
1873 content = "\n".join(commands)
1874 page = {"Type": "Page", "Parent": root, "Contents": content, "Resources": resources}
1875 root["Kids"].append(page)
1876 objects.extend((page, content))
1878 root["Count"] = len(root["Kids"])
1879 with open(filename, "w+") as f:
1880 xref_table = list()
1881 position = f.write("%PDF-1.4\n")
1882 for index, obj in enumerate(objects, 1):
1883 xref_table.append(position)
1884 position += write_object(index, obj, objects, f)
1885 xref_pos = position
1886 f.write("xref_table\n0 {}\n".format(len(xref_table) + 1))
1887 f.write("{:010} {:05} f\n".format(0, 65536))
1888 for position in xref_table:
1889 f.write("{:010} {:05} n\n".format(position, 0))
1890 f.write("trailer\n")
1891 f.write(format_dict({"Size": len(xref_table), "Root": catalog}, objects))
1892 f.write("\nstartxref\n{}\n%%EOF\n".format(xref_pos))
1894 command_label = "/Gtext gs BT {x:.6f} {y:.6f} Td ({label}) Tj ET"
1895 command_image = "q {0.x:.6f} 0 0 {0.y:.6f} 0 0 cm 1 0 0 -1 0 1 cm /{1} Do Q"
1896 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"
1897 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"
1898 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"
1901 class Unfold(bpy.types.Operator):
1902 """Blender Operator: unfold the selected object."""
1904 bl_idname = "mesh.unfold"
1905 bl_label = "Unfold"
1906 bl_description = "Mark seams so that the mesh can be exported as a paper model"
1907 bl_options = {'REGISTER', 'UNDO'}
1908 edit = bpy.props.BoolProperty(default=False, options={'HIDDEN'})
1909 priority_effect_convex = bpy.props.FloatProperty(
1910 name="Priority Convex", description="Priority effect for edges in convex angles",
1911 default=default_priority_effect['CONVEX'], soft_min=-1, soft_max=10, subtype='FACTOR')
1912 priority_effect_concave = bpy.props.FloatProperty(
1913 name="Priority Concave", description="Priority effect for edges in concave angles",
1914 default=default_priority_effect['CONCAVE'], soft_min=-1, soft_max=10, subtype='FACTOR')
1915 priority_effect_length = bpy.props.FloatProperty(
1916 name="Priority Length", description="Priority effect of edge length",
1917 default=default_priority_effect['LENGTH'], soft_min=-10, soft_max=1, subtype='FACTOR')
1918 do_create_uvmap = bpy.props.BoolProperty(
1919 name="Create UVMap", description="Create a new UV Map showing the islands and page layout", default=False)
1920 object = None
1922 @classmethod
1923 def poll(cls, context):
1924 return context.active_object and context.active_object.type == "MESH"
1926 def draw(self, context):
1927 layout = self.layout
1928 col = layout.column()
1929 col.active = not self.object or len(self.object.data.uv_textures) < 8
1930 col.prop(self.properties, "do_create_uvmap")
1931 layout.label(text="Edge Cutting Factors:")
1932 col = layout.column(align=True)
1933 col.label(text="Face Angle:")
1934 col.prop(self.properties, "priority_effect_convex", text="Convex")
1935 col.prop(self.properties, "priority_effect_concave", text="Concave")
1936 layout.prop(self.properties, "priority_effect_length", text="Edge Length")
1938 def execute(self, context):
1939 sce = bpy.context.scene
1940 settings = sce.paper_model
1941 recall_mode = context.object.mode
1942 bpy.ops.object.mode_set(mode='OBJECT')
1943 recall_display_islands, sce.paper_model.display_islands = sce.paper_model.display_islands, False
1945 self.object = context.active_object
1946 mesh = self.object.data
1948 cage_size = M.Vector((settings.output_size_x, settings.output_size_y)) if settings.limit_by_page else None
1949 priority_effect = {
1950 'CONVEX': self.priority_effect_convex,
1951 'CONCAVE': self.priority_effect_concave,
1952 'LENGTH': self.priority_effect_length}
1953 try:
1954 unfolder = Unfolder(self.object)
1955 unfolder.prepare(
1956 cage_size, self.do_create_uvmap, mark_seams=True,
1957 priority_effect=priority_effect, scale=sce.unit_settings.scale_length/settings.scale)
1958 except UnfoldError as error:
1959 self.report(type={'ERROR_INVALID_INPUT'}, message=error.args[0])
1960 bpy.ops.object.mode_set(mode=recall_mode)
1961 sce.paper_model.display_islands = recall_display_islands
1962 return {'CANCELLED'}
1963 if mesh.paper_island_list:
1964 unfolder.copy_island_names(mesh.paper_island_list)
1966 island_list = mesh.paper_island_list
1967 attributes = {item.label: (item.abbreviation, item.auto_label, item.auto_abbrev) for item in island_list}
1968 island_list.clear() # remove previously defined islands
1969 for island in unfolder.mesh.islands:
1970 # add islands to UI list and set default descriptions
1971 list_item = island_list.add()
1972 # add faces' IDs to the island
1973 for uvface in island.faces:
1974 lface = list_item.faces.add()
1975 lface.id = uvface.face.index
1977 list_item["label"] = island.label
1978 list_item["abbreviation"], list_item["auto_label"], list_item["auto_abbrev"] = attributes.get(
1979 island.label,
1980 (island.abbreviation, True, True))
1981 island_item_changed(list_item, context)
1983 mesh.paper_island_index = -1
1984 mesh.show_edge_seams = True
1986 bpy.ops.object.mode_set(mode=recall_mode)
1987 sce.paper_model.display_islands = recall_display_islands
1988 return {'FINISHED'}
1991 class ClearAllSeams(bpy.types.Operator):
1992 """Blender Operator: clear all seams of the active Mesh and all its unfold data"""
1994 bl_idname = "mesh.clear_all_seams"
1995 bl_label = "Clear All Seams"
1996 bl_description = "Clear all the seams and unfolded islands of the active object"
1998 @classmethod
1999 def poll(cls, context):
2000 return context.active_object and context.active_object.type == 'MESH'
2002 def execute(self, context):
2003 ob = context.active_object
2004 mesh = ob.data
2006 for edge in mesh.edges:
2007 edge.use_seam = False
2008 mesh.paper_island_list.clear()
2010 return {'FINISHED'}
2013 def page_size_preset_changed(self, context):
2014 """Update the actual document size to correct values"""
2015 if hasattr(self, "limit_by_page") and not self.limit_by_page:
2016 return
2017 if self.page_size_preset == 'A4':
2018 self.output_size_x = 0.210
2019 self.output_size_y = 0.297
2020 elif self.page_size_preset == 'A3':
2021 self.output_size_x = 0.297
2022 self.output_size_y = 0.420
2023 elif self.page_size_preset == 'US_LETTER':
2024 self.output_size_x = 0.216
2025 self.output_size_y = 0.279
2026 elif self.page_size_preset == 'US_LEGAL':
2027 self.output_size_x = 0.216
2028 self.output_size_y = 0.356
2031 class PaperModelStyle(bpy.types.PropertyGroup):
2032 line_styles = [
2033 ('SOLID', "Solid (----)", "Solid line"),
2034 ('DOT', "Dots (. . .)", "Dotted line"),
2035 ('DASH', "Short Dashes (- - -)", "Solid line"),
2036 ('LONGDASH', "Long Dashes (-- --)", "Solid line"),
2037 ('DASHDOT', "Dash-dotted (-- .)", "Solid line")
2039 outer_color = bpy.props.FloatVectorProperty(
2040 name="Outer Lines", description="Color of net outline",
2041 default=(0.0, 0.0, 0.0, 1.0), min=0, max=1, subtype='COLOR', size=4)
2042 outer_style = bpy.props.EnumProperty(
2043 name="Outer Lines Drawing Style", description="Drawing style of net outline",
2044 default='SOLID', items=line_styles)
2045 line_width = bpy.props.FloatProperty(
2046 name="Base Lines Thickness", description="Base thickness of net lines, each actual value is a multiple of this length",
2047 default=1e-4, min=0, soft_max=5e-3, precision=5, step=1e-2, subtype="UNSIGNED", unit="LENGTH")
2048 outer_width = bpy.props.FloatProperty(
2049 name="Outer Lines Thickness", description="Relative thickness of net outline",
2050 default=3, min=0, soft_max=10, precision=1, step=10, subtype='FACTOR')
2051 use_outbg = bpy.props.BoolProperty(
2052 name="Highlight Outer Lines", description="Add another line below every line to improve contrast",
2053 default=True)
2054 outbg_color = bpy.props.FloatVectorProperty(
2055 name="Outer Highlight", description="Color of the highlight for outer lines",
2056 default=(1.0, 1.0, 1.0, 1.0), min=0, max=1, subtype='COLOR', size=4)
2057 outbg_width = bpy.props.FloatProperty(
2058 name="Outer Highlight Thickness", description="Relative thickness of the highlighting lines",
2059 default=5, min=0, soft_max=10, precision=1, step=10, subtype='FACTOR')
2061 convex_color = bpy.props.FloatVectorProperty(
2062 name="Inner Convex Lines", description="Color of lines to be folded to a convex angle",
2063 default=(0.0, 0.0, 0.0, 1.0), min=0, max=1, subtype='COLOR', size=4)
2064 convex_style = bpy.props.EnumProperty(
2065 name="Convex Lines Drawing Style", description="Drawing style of lines to be folded to a convex angle",
2066 default='DASH', items=line_styles)
2067 convex_width = bpy.props.FloatProperty(
2068 name="Convex Lines Thickness", description="Relative thickness of concave lines",
2069 default=2, min=0, soft_max=10, precision=1, step=10, subtype='FACTOR')
2070 concave_color = bpy.props.FloatVectorProperty(
2071 name="Inner Concave Lines", description="Color of lines to be folded to a concave angle",
2072 default=(0.0, 0.0, 0.0, 1.0), min=0, max=1, subtype='COLOR', size=4)
2073 concave_style = bpy.props.EnumProperty(
2074 name="Concave Lines Drawing Style", description="Drawing style of lines to be folded to a concave angle",
2075 default='DASHDOT', items=line_styles)
2076 concave_width = bpy.props.FloatProperty(
2077 name="Concave Lines Thickness", description="Relative thickness of concave lines",
2078 default=2, min=0, soft_max=10, precision=1, step=10, subtype='FACTOR')
2079 freestyle_color = bpy.props.FloatVectorProperty(
2080 name="Freestyle Edges", description="Color of lines marked as Freestyle Edge",
2081 default=(0.0, 0.0, 0.0, 1.0), min=0, max=1, subtype='COLOR', size=4)
2082 freestyle_style = bpy.props.EnumProperty(
2083 name="Freestyle Edges Drawing Style", description="Drawing style of Freestyle Edges",
2084 default='SOLID', items=line_styles)
2085 freestyle_width = bpy.props.FloatProperty(
2086 name="Freestyle Edges Thickness", description="Relative thickness of Freestyle edges",
2087 default=2, min=0, soft_max=10, precision=1, step=10, subtype='FACTOR')
2088 use_inbg = bpy.props.BoolProperty(
2089 name="Highlight Inner Lines", description="Add another line below every line to improve contrast",
2090 default=True)
2091 inbg_color = bpy.props.FloatVectorProperty(
2092 name="Inner Highlight", description="Color of the highlight for inner lines",
2093 default=(1.0, 1.0, 1.0, 1.0), min=0, max=1, subtype='COLOR', size=4)
2094 inbg_width = bpy.props.FloatProperty(
2095 name="Inner Highlight Thickness", description="Relative thickness of the highlighting lines",
2096 default=2, min=0, soft_max=10, precision=1, step=10, subtype='FACTOR')
2098 sticker_fill = bpy.props.FloatVectorProperty(
2099 name="Tabs Fill", description="Fill color of sticking tabs",
2100 default=(0.9, 0.9, 0.9, 1.0), min=0, max=1, subtype='COLOR', size=4)
2101 text_color = bpy.props.FloatVectorProperty(
2102 name="Text Color", description="Color of all text used in the document",
2103 default=(0.0, 0.0, 0.0, 1.0), min=0, max=1, subtype='COLOR', size=4)
2104 bpy.utils.register_class(PaperModelStyle)
2107 class ExportPaperModel(bpy.types.Operator):
2108 """Blender Operator: save the selected object's net and optionally bake its texture"""
2110 bl_idname = "export_mesh.paper_model"
2111 bl_label = "Export Paper Model"
2112 bl_description = "Export the selected object's net and optionally bake its texture"
2113 filepath = bpy.props.StringProperty(
2114 name="File Path", description="Target file to save the SVG", options={'SKIP_SAVE'})
2115 filename = bpy.props.StringProperty(
2116 name="File Name", description="Name of the file", options={'SKIP_SAVE'})
2117 directory = bpy.props.StringProperty(
2118 name="Directory", description="Directory of the file", options={'SKIP_SAVE'})
2119 page_size_preset = bpy.props.EnumProperty(
2120 name="Page Size", description="Size of the exported document",
2121 default='A4', update=page_size_preset_changed, items=global_paper_sizes)
2122 output_size_x = bpy.props.FloatProperty(
2123 name="Page Width", description="Width of the exported document",
2124 default=0.210, soft_min=0.105, soft_max=0.841, subtype="UNSIGNED", unit="LENGTH")
2125 output_size_y = bpy.props.FloatProperty(
2126 name="Page Height", description="Height of the exported document",
2127 default=0.297, soft_min=0.148, soft_max=1.189, subtype="UNSIGNED", unit="LENGTH")
2128 output_margin = bpy.props.FloatProperty(
2129 name="Page Margin", description="Distance from page borders to the printable area",
2130 default=0.005, min=0, soft_max=0.1, step=0.1, subtype="UNSIGNED", unit="LENGTH")
2131 output_type = bpy.props.EnumProperty(
2132 name="Textures", description="Source of a texture for the model",
2133 default='NONE', items=[
2134 ('NONE', "No Texture", "Export the net only"),
2135 ('TEXTURE', "From Materials", "Render the diffuse color and all painted textures"),
2136 ('AMBIENT_OCCLUSION', "Ambient Occlusion", "Render the Ambient Occlusion pass"),
2137 ('RENDER', "Full Render", "Render the material in actual scene illumination"),
2138 ('SELECTED_TO_ACTIVE', "Selected to Active", "Render all selected surrounding objects as a texture")
2140 do_create_stickers = bpy.props.BoolProperty(
2141 name="Create Tabs", description="Create gluing tabs around the net (useful for paper)",
2142 default=True)
2143 do_create_numbers = bpy.props.BoolProperty(
2144 name="Create Numbers", description="Enumerate edges to make it clear which edges should be sticked together",
2145 default=True)
2146 sticker_width = bpy.props.FloatProperty(
2147 name="Tabs and Text Size", description="Width of gluing tabs and their numbers",
2148 default=0.005, soft_min=0, soft_max=0.05, step=0.1, subtype="UNSIGNED", unit="LENGTH")
2149 angle_epsilon = bpy.props.FloatProperty(
2150 name="Hidden Edge Angle", description="Folds with angle below this limit will not be drawn",
2151 default=pi/360, min=0, soft_max=pi/4, step=0.01, subtype="ANGLE", unit="ROTATION")
2152 output_dpi = bpy.props.FloatProperty(
2153 name="Resolution (DPI)", description="Resolution of images in pixels per inch",
2154 default=90, min=1, soft_min=30, soft_max=600, subtype="UNSIGNED")
2155 file_format = bpy.props.EnumProperty(
2156 name="Document Format", description="File format of the exported net",
2157 default='PDF', items=[
2158 ('PDF', "PDF", "Adobe Portable Document Format 1.4"),
2159 ('SVG', "SVG", "W3C Scalable Vector Graphics"),
2161 image_packing = bpy.props.EnumProperty(
2162 name="Image Packing Method", description="Method of attaching baked image(s) to the SVG",
2163 default='ISLAND_EMBED', items=[
2164 ('PAGE_LINK', "Single Linked", "Bake one image per page of output and save it separately"),
2165 ('ISLAND_LINK', "Linked", "Bake images separately for each island and save them in a directory"),
2166 ('ISLAND_EMBED', "Embedded", "Bake images separately for each island and embed them into the SVG")
2168 scale = bpy.props.FloatProperty(
2169 name="Scale", description="Divisor of all dimensions when exporting",
2170 default=1, soft_min=1.0, soft_max=10000.0, step=100, subtype='UNSIGNED', precision=1)
2171 do_create_uvmap = bpy.props.BoolProperty(
2172 name="Create UVMap", description="Create a new UV Map showing the islands and page layout",
2173 default=False, options={'SKIP_SAVE'})
2174 ui_expanded_document = bpy.props.BoolProperty(
2175 name="Show Document Settings Expanded", description="Shows the box 'Document Settings' expanded in user interface",
2176 default=True, options={'SKIP_SAVE'})
2177 ui_expanded_style = bpy.props.BoolProperty(
2178 name="Show Style Settings Expanded", description="Shows the box 'Colors and Style' expanded in user interface",
2179 default=False, options={'SKIP_SAVE'})
2180 style = bpy.props.PointerProperty(type=PaperModelStyle)
2182 unfolder = None
2183 largest_island_ratio = 0
2185 @classmethod
2186 def poll(cls, context):
2187 return context.active_object and context.active_object.type == 'MESH'
2189 def execute(self, context):
2190 try:
2191 if self.object.data.paper_island_list:
2192 self.unfolder.copy_island_names(self.object.data.paper_island_list)
2193 self.unfolder.save(self.properties)
2194 self.report({'INFO'}, "Saved a {}-page document".format(len(self.unfolder.mesh.pages)))
2195 return {'FINISHED'}
2196 except UnfoldError as error:
2197 self.report(type={'ERROR_INVALID_INPUT'}, message=error.args[0])
2198 return {'CANCELLED'}
2200 def get_scale_ratio(self, sce):
2201 margin = self.output_margin + self.sticker_width + 1e-5
2202 if min(self.output_size_x, self.output_size_y) <= 2 * margin:
2203 return False
2204 output_inner_size = M.Vector((self.output_size_x - 2*margin, self.output_size_y - 2*margin))
2205 ratio = self.unfolder.mesh.largest_island_ratio(output_inner_size)
2206 return ratio * sce.unit_settings.scale_length / self.scale
2208 def invoke(self, context, event):
2209 sce = context.scene
2210 recall_mode = context.object.mode
2211 bpy.ops.object.mode_set(mode='OBJECT')
2213 self.scale = sce.paper_model.scale
2214 self.object = context.active_object
2215 cage_size = M.Vector((sce.paper_model.output_size_x, sce.paper_model.output_size_y)) if sce.paper_model.limit_by_page else None
2216 try:
2217 self.unfolder = Unfolder(self.object)
2218 self.unfolder.prepare(
2219 cage_size, create_uvmap=self.do_create_uvmap,
2220 scale=sce.unit_settings.scale_length/self.scale)
2221 except UnfoldError as error:
2222 self.report(type={'ERROR_INVALID_INPUT'}, message=error.args[0])
2223 bpy.ops.object.mode_set(mode=recall_mode)
2224 return {'CANCELLED'}
2225 scale_ratio = self.get_scale_ratio(sce)
2226 if scale_ratio > 1:
2227 self.scale = ceil(self.scale * scale_ratio)
2228 wm = context.window_manager
2229 wm.fileselect_add(self)
2231 bpy.ops.object.mode_set(mode=recall_mode)
2232 return {'RUNNING_MODAL'}
2234 def draw(self, context):
2235 layout = self.layout
2237 layout.prop(self.properties, "do_create_uvmap")
2239 row = layout.row(align=True)
2240 row.menu("VIEW3D_MT_paper_model_presets", text=bpy.types.VIEW3D_MT_paper_model_presets.bl_label)
2241 row.operator("export_mesh.paper_model_preset_add", text="", icon='ZOOMIN')
2242 row.operator("export_mesh.paper_model_preset_add", text="", icon='ZOOMOUT').remove_active = True
2244 # a little hack: this prints out something like "Scale: 1: 72"
2245 layout.prop(self.properties, "scale", text="Scale: 1")
2246 scale_ratio = self.get_scale_ratio(context.scene)
2247 if scale_ratio > 1:
2248 layout.label(
2249 text="An island is roughly {:.1f}x bigger than page".format(scale_ratio),
2250 icon="ERROR")
2251 elif scale_ratio > 0:
2252 layout.label(text="Largest island is roughly 1/{:.1f} of page".format(1 / scale_ratio))
2254 if context.scene.unit_settings.scale_length != 1:
2255 layout.label(
2256 text="Unit scale {:.1f} makes page size etc. not display correctly".format(
2257 context.scene.unit_settings.scale_length), icon="ERROR")
2258 box = layout.box()
2259 row = box.row(align=True)
2260 row.prop(
2261 self.properties, "ui_expanded_document", text="",
2262 icon=('TRIA_DOWN' if self.ui_expanded_document else 'TRIA_RIGHT'), emboss=False)
2263 row.label(text="Document Settings")
2265 if self.ui_expanded_document:
2266 box.prop(self.properties, "file_format", text="Format")
2267 box.prop(self.properties, "page_size_preset")
2268 col = box.column(align=True)
2269 col.active = self.page_size_preset == 'USER'
2270 col.prop(self.properties, "output_size_x")
2271 col.prop(self.properties, "output_size_y")
2272 box.prop(self.properties, "output_margin")
2273 col = box.column()
2274 col.prop(self.properties, "do_create_stickers")
2275 col.prop(self.properties, "do_create_numbers")
2276 col = box.column()
2277 col.active = self.do_create_stickers or self.do_create_numbers
2278 col.prop(self.properties, "sticker_width")
2279 box.prop(self.properties, "angle_epsilon")
2281 box.prop(self.properties, "output_type")
2282 col = box.column()
2283 col.active = (self.output_type != 'NONE')
2284 if len(self.object.data.uv_textures) == 8:
2285 col.label(text="No UV slots left, No Texture is the only option.", icon='ERROR')
2286 elif context.scene.render.engine not in ('BLENDER_RENDER', 'CYCLES') and self.output_type != 'NONE':
2287 col.label(text="Blender Internal engine will be used for texture baking.", icon='ERROR')
2288 col.prop(self.properties, "output_dpi")
2289 row = col.row()
2290 row.active = self.file_format == 'SVG'
2291 row.prop(self.properties, "image_packing", text="Images")
2293 box = layout.box()
2294 row = box.row(align=True)
2295 row.prop(
2296 self.properties, "ui_expanded_style", text="",
2297 icon=('TRIA_DOWN' if self.ui_expanded_style else 'TRIA_RIGHT'), emboss=False)
2298 row.label(text="Colors and Style")
2300 if self.ui_expanded_style:
2301 box.prop(self.style, "line_width", text="Default line width")
2302 col = box.column()
2303 col.prop(self.style, "outer_color")
2304 col.prop(self.style, "outer_width", text="Relative width")
2305 col.prop(self.style, "outer_style", text="Style")
2306 col = box.column()
2307 col.active = self.output_type != 'NONE'
2308 col.prop(self.style, "use_outbg", text="Outer Lines Highlight:")
2309 sub = col.column()
2310 sub.active = self.output_type != 'NONE' and self.style.use_outbg
2311 sub.prop(self.style, "outbg_color", text="")
2312 sub.prop(self.style, "outbg_width", text="Relative width")
2313 col = box.column()
2314 col.prop(self.style, "convex_color")
2315 col.prop(self.style, "convex_width", text="Relative width")
2316 col.prop(self.style, "convex_style", text="Style")
2317 col = box.column()
2318 col.prop(self.style, "concave_color")
2319 col.prop(self.style, "concave_width", text="Relative width")
2320 col.prop(self.style, "concave_style", text="Style")
2321 col = box.column()
2322 col.prop(self.style, "freestyle_color")
2323 col.prop(self.style, "freestyle_width", text="Relative width")
2324 col.prop(self.style, "freestyle_style", text="Style")
2325 col = box.column()
2326 col.active = self.output_type != 'NONE'
2327 col.prop(self.style, "use_inbg", text="Inner Lines Highlight:")
2328 sub = col.column()
2329 sub.active = self.output_type != 'NONE' and self.style.use_inbg
2330 sub.prop(self.style, "inbg_color", text="")
2331 sub.prop(self.style, "inbg_width", text="Relative width")
2332 col = box.column()
2333 col.active = self.do_create_stickers
2334 col.prop(self.style, "sticker_fill")
2335 box.prop(self.style, "text_color")
2338 def menu_func(self, context):
2339 self.layout.operator("export_mesh.paper_model", text="Paper Model (.svg)")
2342 class VIEW3D_MT_paper_model_presets(bpy.types.Menu):
2343 bl_label = "Paper Model Presets"
2344 preset_subdir = "export_mesh"
2345 preset_operator = "script.execute_preset"
2346 draw = bpy.types.Menu.draw_preset
2349 class AddPresetPaperModel(bl_operators.presets.AddPresetBase, bpy.types.Operator):
2350 """Add or remove a Paper Model Preset"""
2351 bl_idname = "export_mesh.paper_model_preset_add"
2352 bl_label = "Add Paper Model Preset"
2353 preset_menu = "VIEW3D_MT_paper_model_presets"
2354 preset_subdir = "export_mesh"
2355 preset_defines = ["op = bpy.context.active_operator"]
2357 @property
2358 def preset_values(self):
2359 op = bpy.ops.export_mesh.paper_model
2360 properties = op.get_rna_type().properties.items()
2361 blacklist = bpy.types.Operator.bl_rna.properties.keys()
2362 return [
2363 "op.{}".format(prop_id) for (prop_id, prop) in properties
2364 if not (prop.is_hidden or prop.is_skip_save or prop_id in blacklist)]
2367 class VIEW3D_PT_paper_model_tools(bpy.types.Panel):
2368 bl_label = "Tools"
2369 bl_space_type = "VIEW_3D"
2370 bl_region_type = "TOOLS"
2371 bl_category = "Paper Model"
2373 def draw(self, context):
2374 layout = self.layout
2375 sce = context.scene
2376 obj = context.active_object
2377 mesh = obj.data if obj and obj.type == 'MESH' else None
2379 layout.operator("export_mesh.paper_model")
2381 col = layout.column(align=True)
2382 col.label("Customization:")
2383 col.operator("mesh.unfold")
2385 if context.mode == 'EDIT_MESH':
2386 row = layout.row(align=True)
2387 row.operator("mesh.mark_seam", text="Mark Seam").clear = False
2388 row.operator("mesh.mark_seam", text="Clear Seam").clear = True
2389 else:
2390 layout.operator("mesh.clear_all_seams")
2392 props = sce.paper_model
2393 layout.prop(props, "scale", text="Model Scale: 1")
2395 layout.prop(props, "limit_by_page")
2396 col = layout.column()
2397 col.active = props.limit_by_page
2398 col.prop(props, "page_size_preset")
2399 sub = col.column(align=True)
2400 sub.active = props.page_size_preset == 'USER'
2401 sub.prop(props, "output_size_x")
2402 sub.prop(props, "output_size_y")
2405 class VIEW3D_PT_paper_model_islands(bpy.types.Panel):
2406 bl_label = "Islands"
2407 bl_space_type = "VIEW_3D"
2408 bl_region_type = "TOOLS"
2409 bl_category = "Paper Model"
2411 def draw(self, context):
2412 layout = self.layout
2413 sce = context.scene
2414 obj = context.active_object
2415 mesh = obj.data if obj and obj.type == 'MESH' else None
2417 if mesh and mesh.paper_island_list:
2418 layout.label(
2419 text="1 island:" if len(mesh.paper_island_list) == 1 else
2420 "{} islands:".format(len(mesh.paper_island_list)))
2421 layout.template_list(
2422 'UI_UL_list', 'paper_model_island_list', mesh,
2423 'paper_island_list', mesh, 'paper_island_index', rows=1, maxrows=5)
2424 if mesh.paper_island_index >= 0:
2425 list_item = mesh.paper_island_list[mesh.paper_island_index]
2426 sub = layout.column(align=True)
2427 sub.prop(list_item, "auto_label")
2428 sub.prop(list_item, "label")
2429 sub.prop(list_item, "auto_abbrev")
2430 row = sub.row()
2431 row.active = not list_item.auto_abbrev
2432 row.prop(list_item, "abbreviation")
2433 else:
2434 layout.label(text="Not unfolded")
2435 layout.box().label("Use the 'Unfold' tool")
2436 sub = layout.column(align=True)
2437 sub.active = bool(mesh and mesh.paper_island_list)
2438 sub.prop(sce.paper_model, "display_islands", icon='RESTRICT_VIEW_OFF')
2439 row = sub.row(align=True)
2440 row.active = bool(sce.paper_model.display_islands and mesh and mesh.paper_island_list)
2441 row.prop(sce.paper_model, "islands_alpha", slider=True)
2444 def display_islands(self, context):
2445 # TODO: save the vertex positions and don't recalculate them always?
2446 ob = context.active_object
2447 if not ob or ob.type != 'MESH':
2448 return
2449 mesh = ob.data
2450 if not mesh.paper_island_list or mesh.paper_island_index == -1:
2451 return
2453 bgl.glMatrixMode(bgl.GL_PROJECTION)
2454 perspMatrix = context.space_data.region_3d.perspective_matrix
2455 perspBuff = bgl.Buffer(bgl.GL_FLOAT, (4, 4), perspMatrix.transposed())
2456 bgl.glLoadMatrixf(perspBuff)
2457 bgl.glMatrixMode(bgl.GL_MODELVIEW)
2458 objectBuff = bgl.Buffer(bgl.GL_FLOAT, (4, 4), ob.matrix_world.transposed())
2459 bgl.glLoadMatrixf(objectBuff)
2460 bgl.glEnable(bgl.GL_BLEND)
2461 bgl.glBlendFunc(bgl.GL_SRC_ALPHA, bgl.GL_ONE_MINUS_SRC_ALPHA)
2462 bgl.glEnable(bgl.GL_POLYGON_OFFSET_FILL)
2463 bgl.glPolygonOffset(0, -10) # offset in Zbuffer to remove flicker
2464 bgl.glPolygonMode(bgl.GL_FRONT_AND_BACK, bgl.GL_FILL)
2465 bgl.glColor4f(1.0, 0.4, 0.0, self.islands_alpha)
2466 island = mesh.paper_island_list[mesh.paper_island_index]
2467 for lface in island.faces:
2468 face = mesh.polygons[lface.id]
2469 bgl.glBegin(bgl.GL_POLYGON)
2470 for vertex_id in face.vertices:
2471 vertex = mesh.vertices[vertex_id]
2472 bgl.glVertex4f(*vertex.co.to_4d())
2473 bgl.glEnd()
2474 bgl.glPolygonOffset(0.0, 0.0)
2475 bgl.glDisable(bgl.GL_POLYGON_OFFSET_FILL)
2476 bgl.glLoadIdentity()
2477 display_islands.handle = None
2480 def display_islands_changed(self, context):
2481 """Switch highlighting islands on/off"""
2482 if self.display_islands:
2483 if not display_islands.handle:
2484 display_islands.handle = bpy.types.SpaceView3D.draw_handler_add(
2485 display_islands, (self, context), 'WINDOW', 'POST_VIEW')
2486 else:
2487 if display_islands.handle:
2488 bpy.types.SpaceView3D.draw_handler_remove(display_islands.handle, 'WINDOW')
2489 display_islands.handle = None
2492 def label_changed(self, context):
2493 """The label of an island was changed"""
2494 # accessing properties via [..] to avoid a recursive call after the update
2495 self["auto_label"] = not self.label or self.label.isspace()
2496 island_item_changed(self, context)
2499 def island_item_changed(self, context):
2500 """The labelling of an island was changed"""
2501 def increment(abbrev, collisions):
2502 letters = "ABCDEFGHIJKLMNPQRSTUVWXYZ123456789"
2503 while abbrev in collisions:
2504 abbrev = abbrev.rstrip(letters[-1])
2505 abbrev = abbrev[:2] + letters[letters.find(abbrev[-1]) + 1 if len(abbrev) == 3 else 0]
2506 return abbrev
2508 # accessing properties via [..] to avoid a recursive call after the update
2509 island_list = context.active_object.data.paper_island_list
2510 if self.auto_label:
2511 self["label"] = "" # avoid self-conflict
2512 number = 1
2513 while any(item.label == "Island {}".format(number) for item in island_list):
2514 number += 1
2515 self["label"] = "Island {}".format(number)
2516 if self.auto_abbrev:
2517 self["abbreviation"] = "" # avoid self-conflict
2518 abbrev = "".join(first_letters(self.label))[:3].upper()
2519 self["abbreviation"] = increment(abbrev, {item.abbreviation for item in island_list})
2520 elif len(self.abbreviation) > 3:
2521 self["abbreviation"] = self.abbreviation[:3]
2522 self.name = "[{}] {} ({} {})".format(
2523 self.abbreviation, self.label, len(self.faces), "faces" if len(self.faces) > 1 else "face")
2526 class FaceList(bpy.types.PropertyGroup):
2527 id = bpy.props.IntProperty(name="Face ID")
2528 bpy.utils.register_class(FaceList)
2531 class IslandList(bpy.types.PropertyGroup):
2532 faces = bpy.props.CollectionProperty(
2533 name="Faces", description="Faces belonging to this island", type=FaceList)
2534 label = bpy.props.StringProperty(
2535 name="Label", description="Label on this island",
2536 default="", update=label_changed)
2537 abbreviation = bpy.props.StringProperty(
2538 name="Abbreviation", description="Three-letter label to use when there is not enough space",
2539 default="", update=island_item_changed)
2540 auto_label = bpy.props.BoolProperty(
2541 name="Auto Label", description="Generate the label automatically",
2542 default=True, update=island_item_changed)
2543 auto_abbrev = bpy.props.BoolProperty(
2544 name="Auto Abbreviation", description="Generate the abbreviation automatically",
2545 default=True, update=island_item_changed)
2546 bpy.utils.register_class(IslandList)
2549 class PaperModelSettings(bpy.types.PropertyGroup):
2550 display_islands = bpy.props.BoolProperty(
2551 name="Highlight selected island", description="Highlight faces corresponding to the selected island in the 3D View",
2552 options={'SKIP_SAVE'}, update=display_islands_changed)
2553 islands_alpha = bpy.props.FloatProperty(
2554 name="Opacity", description="Opacity of island highlighting",
2555 min=0.0, max=1.0, default=0.3)
2556 limit_by_page = bpy.props.BoolProperty(
2557 name="Limit Island Size", description="Do not create islands larger than given dimensions",
2558 default=False, update=page_size_preset_changed)
2559 page_size_preset = bpy.props.EnumProperty(
2560 name="Page Size", description="Maximal size of an island",
2561 default='A4', update=page_size_preset_changed, items=global_paper_sizes)
2562 output_size_x = bpy.props.FloatProperty(
2563 name="Width", description="Maximal width of an island",
2564 default=0.2, soft_min=0.105, soft_max=0.841, subtype="UNSIGNED", unit="LENGTH")
2565 output_size_y = bpy.props.FloatProperty(
2566 name="Height", description="Maximal height of an island",
2567 default=0.29, soft_min=0.148, soft_max=1.189, subtype="UNSIGNED", unit="LENGTH")
2568 scale = bpy.props.FloatProperty(
2569 name="Scale", description="Divisor of all dimensions when exporting",
2570 default=1, soft_min=1.0, soft_max=10000.0, step=100, subtype='UNSIGNED', precision=1)
2571 bpy.utils.register_class(PaperModelSettings)
2574 def register():
2575 bpy.utils.register_module(__name__)
2577 bpy.types.Scene.paper_model = bpy.props.PointerProperty(
2578 name="Paper Model", description="Settings of the Export Paper Model script",
2579 type=PaperModelSettings, options={'SKIP_SAVE'})
2580 bpy.types.Mesh.paper_island_list = bpy.props.CollectionProperty(
2581 name="Island List", type=IslandList)
2582 bpy.types.Mesh.paper_island_index = bpy.props.IntProperty(
2583 name="Island List Index",
2584 default=-1, min=-1, max=100, options={'SKIP_SAVE'})
2585 bpy.types.TOPBAR_MT_file_export.append(menu_func)
2588 def unregister():
2589 bpy.utils.unregister_module(__name__)
2590 bpy.types.TOPBAR_MT_file_export.remove(menu_func)
2591 if display_islands.handle:
2592 bpy.types.SpaceView3D.draw_handler_remove(display_islands.handle, 'WINDOW')
2593 display_islands.handle = None
2596 if __name__ == "__main__":
2597 register()