1 # -*- coding: utf-8 -*-
2 # This script is Free software. Please share and reuse.
3 # ♡2010-2019 Adam Dominec <adominec@gmail.com>
6 # This file consists of several components, in this order:
7 # * Unfolding and baking
8 # * Export (SVG or PDF)
10 # During the unfold process, the mesh is mirrored into a 2D structure: UVFace, UVEdge, UVVertex.
13 "name": "Export Paper Model",
14 "author": "Addam Dominec",
16 "blender": (2, 80, 0),
17 "location": "File > Export > Paper Model",
19 "description": "Export printable net of the active mesh",
20 "doc_url": "{BLENDER_MANUAL_URL}/addons/import_export/paper_model.html",
21 "category": "Import-Export",
24 # Task: split into four files (SVG and PDF separately)
25 # does any portion of baking belong into the export module?
26 # sketch out the code for GCODE and two-sided export
29 # sanitize the constructors Edge, Face, UVFace so that they don't edit their parent object
30 # The Exporter classes should take parameters as a whole pack, and parse it themselves
31 # remember objects selected before baking (except selected to active)
32 # add 'estimated number of pages' to the export UI
33 # QuickSweepline is very much broken -- it throws GeometryError for all nets > ~15 faces
34 # rotate islands to minimize area -- and change that only if necessary to fill the page size
35 # Sticker.vertices should be of type Vector
37 # check conflicts in island naming and either:
38 # * append a number to the conflicting names or
39 # * enumerate faces uniquely within all islands of the same name (requires a check that both label and abbr. equals)
45 from re
import compile as re_compile
46 from itertools
import chain
, repeat
, product
, combinations
47 from math
import pi
, ceil
, asin
, atan2
48 import os
.path
as os_path
50 default_priority_effect
= {
56 global_paper_sizes
= [
57 ('USER', "User defined", "User defined paper size"),
58 ('A4', "A4", "International standard paper size"),
59 ('A3', "A3", "International standard paper size"),
60 ('US_LETTER', "Letter", "North American paper size"),
61 ('US_LEGAL', "Legal", "North American paper size")
65 def first_letters(text
):
66 """Iterator over the first letter of each word"""
67 for match
in first_letters
.pattern
.finditer(text
):
68 yield text
[match
.start()]
69 first_letters
.pattern
= re_compile("((?<!\w)\w)|\d")
72 def is_upsidedown_wrong(name
):
73 """Tell if the string would get a different meaning if written upside down"""
75 mistakable
= set("69NZMWpbqd")
76 rotatable
= set("80oOxXIl").union(mistakable
)
77 return chars
.issubset(rotatable
) and not chars
.isdisjoint(mistakable
)
81 """Generate consecutive pairs throughout the given sequence; at last, it gives elements last, first."""
83 previous
= first
= next(i
)
90 def fitting_matrix(v1
, v2
):
91 """Get a matrix that rotates v1 to the same direction as v2"""
92 return (1 / v1
.length_squared
) * M
.Matrix((
93 (v1
.x
*v2
.x
+ v1
.y
*v2
.y
, v1
.y
*v2
.x
- v1
.x
*v2
.y
),
94 (v1
.x
*v2
.y
- v1
.y
*v2
.x
, v1
.x
*v2
.x
+ v1
.y
*v2
.y
)))
98 """Get a rotation matrix that aligns given vector upwards."""
103 (n
.x
*n
.z
/(b
*s
), n
.y
*n
.z
/(b
*s
), -b
/s
),
108 # no need for rotation
111 (0, (-1 if n
.z
< 0 else 1), 0),
116 def cage_fit(points
, aspect
):
117 """Find rotation for a minimum bounding box with a given aspect ratio
118 returns a tuple: rotation angle, box height"""
119 def guesses(polygon
):
120 """Yield all tentative extrema of the bounding box height wrt. polygon rotation"""
121 for a
, b
in pairs(polygon
):
124 direction
= (b
- a
).normalized()
125 sinx
, cosx
= -direction
.y
, direction
.x
126 rot
= M
.Matrix(((cosx
, -sinx
), (sinx
, cosx
)))
127 rot_polygon
= [rot
@ p
for p
in polygon
]
128 left
, right
= [fn(rot_polygon
, key
=lambda p
: p
.to_tuple()) for fn
in (min, max)]
129 bottom
, top
= [fn(rot_polygon
, key
=lambda p
: p
.yx
.to_tuple()) for fn
in (min, max)]
130 #print(f"{rot_polygon.index(left)}-{rot_polygon.index(right)}, {rot_polygon.index(bottom)}-{rot_polygon.index(top)}")
131 horz
, vert
= right
- left
, top
- bottom
132 # solve (rot * a).y == (rot * b).y
133 yield max(aspect
* horz
.x
, vert
.y
), sinx
, cosx
134 # solve (rot * a).x == (rot * b).x
135 yield max(horz
.x
, aspect
* vert
.y
), -cosx
, sinx
136 # solve aspect * (rot * (right - left)).x == (rot * (top - bottom)).y
137 # using substitution t = tan(rot / 2)
138 q
= aspect
* horz
.x
- vert
.y
139 r
= vert
.x
+ aspect
* horz
.y
140 t
= ((r
**2 + q
**2)**0.5 - r
) / q
if q
!= 0 else 0
141 t
= -1 / t
if abs(t
) > 1 else t
# pick the positive solution
142 siny
, cosy
= 2 * t
/ (1 + t
**2), (1 - t
**2) / (1 + t
**2)
143 rot
= M
.Matrix(((cosy
, -siny
), (siny
, cosy
)))
144 for p
in rot_polygon
:
145 p
[:] = rot
@ p
# note: this also modifies left, right, bottom, top
146 #print(f"solve {aspect * (right - left).x} == {(top - bottom).y} with aspect = {aspect}")
147 if left
.x
< right
.x
and bottom
.y
< top
.y
and all(left
.x
<= p
.x
<= right
.x
and bottom
.y
<= p
.y
<= top
.y
for p
in rot_polygon
):
148 #print(f"yield {max(aspect * (right - left).x, (top - bottom).y)}")
149 yield max(aspect
* (right
- left
).x
, (top
- bottom
).y
), sinx
*cosy
+ cosx
*siny
, cosx
*cosy
- sinx
*siny
150 polygon
= [points
[i
] for i
in M
.geometry
.convex_hull_2d(points
)]
151 height
, sinx
, cosx
= min(guesses(polygon
))
152 return atan2(sinx
, cosx
), height
155 def create_blank_image(image_name
, dimensions
, alpha
=1):
156 """Create a new image and assign white color to all its pixels"""
157 image_name
= image_name
[:64]
158 width
, height
= int(dimensions
.x
), int(dimensions
.y
)
159 image
= bpy
.data
.images
.new(image_name
, width
, height
, alpha
=True)
162 "There is something wrong with the material of the model. "
163 "Please report this on the BlenderArtists forum. Export failed.")
164 image
.pixels
= [1, 1, 1, alpha
] * (width
* height
)
165 image
.file_format
= 'PNG'
169 class UnfoldError(ValueError):
170 def mesh_select(self
):
171 if len(self
.args
) > 1:
172 elems
, bm
= self
.args
[1:3]
173 bpy
.context
.tool_settings
.mesh_select_mode
= [bool(elems
[key
]) for key
in ("verts", "edges", "faces")]
174 for elem
in chain(bm
.verts
, bm
.edges
, bm
.faces
):
176 for elem
in chain(*elems
.values()):
177 elem
.select_set(True)
178 bmesh
.update_edit_mesh(bpy
.context
.object.data
, False, False)
182 def __init__(self
, ob
):
183 self
.do_create_uvmap
= False
184 bm
= bmesh
.from_edit_mesh(ob
.data
)
185 self
.mesh
= Mesh(bm
, ob
.matrix_world
)
186 self
.mesh
.check_correct()
189 if not self
.do_create_uvmap
:
190 self
.mesh
.delete_uvmap()
192 def prepare(self
, cage_size
=None, priority_effect
=default_priority_effect
, scale
=1, limit_by_page
=False):
193 """Create the islands of the net"""
194 self
.mesh
.generate_cuts(cage_size
/ scale
if limit_by_page
and cage_size
else None, priority_effect
)
195 self
.mesh
.finalize_islands(cage_size
or M
.Vector((1, 1)))
196 self
.mesh
.enumerate_islands()
199 def copy_island_names(self
, island_list
):
200 """Copy island label and abbreviation from the best matching island in the list"""
201 orig_islands
= [{face
.id for face
in item
.faces
} for item
in island_list
]
203 for i
, island
in enumerate(self
.mesh
.islands
):
204 islfaces
= {face
.index
for face
in island
.faces
}
205 matching
.extend((len(islfaces
.intersection(item
)), i
, j
) for j
, item
in enumerate(orig_islands
))
206 matching
.sort(reverse
=True)
207 available_new
= [True for island
in self
.mesh
.islands
]
208 available_orig
= [True for item
in island_list
]
209 for face_count
, i
, j
in matching
:
210 if available_new
[i
] and available_orig
[j
]:
211 available_new
[i
] = available_orig
[j
] = False
212 self
.mesh
.islands
[i
].label
= island_list
[j
].label
213 self
.mesh
.islands
[i
].abbreviation
= island_list
[j
].abbreviation
215 def save(self
, properties
):
216 """Export the document"""
217 # Note about scale: input is directly in blender length
218 # Mesh.scale_islands multiplies everything by a user-defined ratio
219 # exporters (SVG or PDF) multiply everything by 1000 (output in millimeters)
220 Exporter
= SVG
if properties
.file_format
== 'SVG' else PDF
221 filepath
= properties
.filepath
222 extension
= properties
.file_format
.lower()
223 filepath
= bpy
.path
.ensure_ext(filepath
, "." + extension
)
224 # page size in meters
225 page_size
= M
.Vector((properties
.output_size_x
, properties
.output_size_y
))
226 # printable area size in meters
227 printable_size
= page_size
- 2 * properties
.output_margin
* M
.Vector((1, 1))
228 unit_scale
= bpy
.context
.scene
.unit_settings
.scale_length
229 ppm
= properties
.output_dpi
* 100 / 2.54 # pixels per meter
231 # after this call, all dimensions will be in meters
232 self
.mesh
.scale_islands(unit_scale
/properties
.scale
)
233 if properties
.do_create_stickers
:
234 self
.mesh
.generate_stickers(properties
.sticker_width
, properties
.do_create_numbers
)
235 elif properties
.do_create_numbers
:
236 self
.mesh
.generate_numbers_alone(properties
.sticker_width
)
238 text_height
= properties
.sticker_width
if (properties
.do_create_numbers
and len(self
.mesh
.islands
) > 1) else 0
239 # title height must be somewhat larger that text size, glyphs go below the baseline
240 self
.mesh
.finalize_islands(printable_size
, title_height
=text_height
* 1.2)
241 self
.mesh
.fit_islands(printable_size
)
243 if properties
.output_type
!= 'NONE':
244 # bake an image and save it as a PNG to disk or into memory
245 image_packing
= properties
.image_packing
if properties
.file_format
== 'SVG' else 'ISLAND_EMBED'
246 use_separate_images
= image_packing
in ('ISLAND_LINK', 'ISLAND_EMBED')
247 self
.mesh
.save_uv(cage_size
=printable_size
, separate_image
=use_separate_images
)
249 sce
= bpy
.context
.scene
252 # TODO: do we really need all this recollection?
253 recall
= rd
.engine
, sce
.cycles
.bake_type
, sce
.cycles
.samples
, bk
.use_selected_to_active
, bk
.margin
, bk
.cage_extrusion
, bk
.use_cage
, bk
.use_clear
255 recall_pass
= {p
: getattr(bk
, f
"use_pass_{p}") for p
in ('ambient_occlusion', 'color', 'diffuse', 'direct', 'emit', 'glossy', 'indirect', 'subsurface', 'transmission')}
256 for p
in recall_pass
:
257 setattr(bk
, f
"use_pass_{p}", (properties
.output_type
!= 'TEXTURE'))
258 lookup
= {'TEXTURE': 'DIFFUSE', 'AMBIENT_OCCLUSION': 'AO', 'RENDER': 'COMBINED', 'SELECTED_TO_ACTIVE': 'COMBINED'}
259 sce
.cycles
.bake_type
= lookup
[properties
.output_type
]
260 bk
.use_selected_to_active
= (properties
.output_type
== 'SELECTED_TO_ACTIVE')
261 bk
.margin
, bk
.cage_extrusion
, bk
.use_cage
, bk
.use_clear
= 1, 10, False, False
262 if properties
.output_type
== 'TEXTURE':
263 bk
.use_pass_direct
, bk
.use_pass_indirect
, bk
.use_pass_color
= False, False, True
264 sce
.cycles
.samples
= 1
266 sce
.cycles
.samples
= properties
.bake_samples
267 if sce
.cycles
.bake_type
== 'COMBINED':
268 bk
.use_pass_direct
, bk
.use_pass_indirect
= True, True
269 bk
.use_pass_diffuse
, bk
.use_pass_glossy
, bk
.use_pass_transmission
, bk
.use_pass_subsurface
, bk
.use_pass_ambient_occlusion
, bk
.use_pass_emit
= True, False, False, True, True, True
271 if image_packing
== 'PAGE_LINK':
272 self
.mesh
.save_image(printable_size
* ppm
, filepath
)
273 elif image_packing
== 'ISLAND_LINK':
274 image_dir
= filepath
[:filepath
.rfind(".")]
275 self
.mesh
.save_separate_images(ppm
, image_dir
)
276 elif image_packing
== 'ISLAND_EMBED':
277 self
.mesh
.save_separate_images(ppm
, filepath
, embed
=Exporter
.encode_image
)
279 rd
.engine
, sce
.cycles
.bake_type
, sce
.cycles
.samples
, bk
.use_selected_to_active
, bk
.margin
, bk
.cage_extrusion
, bk
.use_cage
, bk
.use_clear
= recall
280 for p
, v
in recall_pass
.items():
281 setattr(bk
, f
"use_pass_{p}", v
)
283 exporter
= Exporter(page_size
, properties
.style
, properties
.output_margin
, (properties
.output_type
== 'NONE'), properties
.angle_epsilon
)
284 exporter
.do_create_stickers
= properties
.do_create_stickers
285 exporter
.text_size
= properties
.sticker_width
286 exporter
.write(self
.mesh
, filepath
)
290 """Wrapper for Bpy Mesh"""
292 def __init__(self
, bmesh
, matrix
):
294 self
.matrix
= matrix
.to_3x3()
295 self
.looptex
= bmesh
.loops
.layers
.uv
.new("Unfolded")
296 self
.edges
= {bmedge
: Edge(bmedge
) for bmedge
in bmesh
.edges
}
297 self
.islands
= list()
299 for edge
in self
.edges
.values():
300 edge
.choose_main_faces()
302 edge
.calculate_angle()
303 self
.copy_freestyle_marks()
305 def delete_uvmap(self
):
306 self
.data
.loops
.layers
.uv
.remove(self
.looptex
) if self
.looptex
else None
308 def copy_freestyle_marks(self
):
309 # NOTE: this is a workaround for NotImplementedError on bmesh.edges.layers.freestyle
310 mesh
= bpy
.data
.meshes
.new("unfolder_temp")
311 self
.data
.to_mesh(mesh
)
312 for bmedge
, edge
in self
.edges
.items():
313 edge
.freestyle
= mesh
.edges
[bmedge
.index
].use_freestyle_mark
314 bpy
.data
.meshes
.remove(mesh
)
317 for bmedge
, edge
in self
.edges
.items():
318 if edge
.is_main_cut
and not bmedge
.is_boundary
:
321 def check_correct(self
, epsilon
=1e-6):
322 """Check for invalid geometry"""
323 def is_twisted(face
):
324 if len(face
.verts
) <= 3:
326 center
= face
.calc_center_median()
327 plane_d
= center
.dot(face
.normal
)
328 diameter
= max((center
- vertex
.co
).length
for vertex
in face
.verts
)
329 threshold
= 0.01 * diameter
330 return any(abs(v
.co
.dot(face
.normal
) - plane_d
) > threshold
for v
in face
.verts
)
332 null_edges
= {e
for e
in self
.edges
.keys() if e
.calc_length() < epsilon
and e
.link_faces
}
333 null_faces
= {f
for f
in self
.data
.faces
if f
.calc_area() < epsilon
}
334 twisted_faces
= {f
for f
in self
.data
.faces
if is_twisted(f
)}
335 inverted_scale
= self
.matrix
.determinant() <= 0
336 if not (null_edges
or null_faces
or twisted_faces
or inverted_scale
):
339 raise UnfoldError("The object is flipped inside-out.\n"
340 "You can use Object -> Apply -> Scale to fix it. Export failed.")
341 disease
= [("Remove Doubles", null_edges
or null_faces
), ("Triangulate", twisted_faces
)]
342 cure
= " and ".join(s
for s
, k
in disease
if k
)
344 "The model contains:\n" +
345 (" {} zero-length edge(s)\n".format(len(null_edges
)) if null_edges
else "") +
346 (" {} zero-area face(s)\n".format(len(null_faces
)) if null_faces
else "") +
347 (" {} twisted polygon(s)\n".format(len(twisted_faces
)) if twisted_faces
else "") +
348 "The offenders are selected and you can use {} to fix them. Export failed.".format(cure
),
349 {"verts": set(), "edges": null_edges
, "faces": null_faces | twisted_faces
}, self
.data
)
351 def generate_cuts(self
, page_size
, priority_effect
):
352 """Cut the mesh so that it can be unfolded to a flat net."""
353 normal_matrix
= self
.matrix
.inverted().transposed()
354 islands
= {Island(self
, face
, self
.matrix
, normal_matrix
) for face
in self
.data
.faces
}
355 uvfaces
= {face
: uvface
for island
in islands
for face
, uvface
in island
.faces
.items()}
356 uvedges
= {loop
: uvedge
for island
in islands
for loop
, uvedge
in island
.edges
.items()}
357 for loop
, uvedge
in uvedges
.items():
358 self
.edges
[loop
.edge
].uvedges
.append(uvedge
)
359 # check for edges that are cut permanently
360 edges
= [edge
for edge
in self
.edges
.values() if not edge
.force_cut
and edge
.main_faces
]
363 average_length
= sum(edge
.vector
.length
for edge
in edges
) / len(edges
)
365 edge
.generate_priority(priority_effect
, average_length
)
366 edges
.sort(reverse
=False, key
=lambda edge
: edge
.priority
)
370 edge_a
, edge_b
= (uvedges
[l
] for l
in edge
.main_faces
)
371 old_island
= join(edge_a
, edge_b
, size_limit
=page_size
)
373 islands
.remove(old_island
)
375 self
.islands
= sorted(islands
, reverse
=True, key
=lambda island
: len(island
.faces
))
377 for edge
in self
.edges
.values():
378 # some edges did not know until now whether their angle is convex or concave
379 if edge
.main_faces
and (uvfaces
[edge
.main_faces
[0].face
].flipped
or uvfaces
[edge
.main_faces
[1].face
].flipped
):
380 edge
.calculate_angle()
381 # ensure that the order of faces corresponds to the order of uvedges
383 reordered
= [None, None]
384 for uvedge
in edge
.uvedges
:
386 index
= edge
.main_faces
.index(uvedge
.loop
)
387 reordered
[index
] = uvedge
389 reordered
.append(uvedge
)
390 edge
.uvedges
= reordered
392 for island
in self
.islands
:
393 # if the normals are ambiguous, flip them so that there are more convex edges than concave ones
394 if any(uvface
.flipped
for uvface
in island
.faces
.values()):
395 island_edges
= {self
.edges
[uvedge
.edge
] for uvedge
in island
.edges
}
396 balance
= sum((+1 if edge
.angle
> 0 else -1) for edge
in island_edges
if not edge
.is_cut(uvedge
.uvface
.face
))
398 island
.is_inside_out
= True
400 # construct a linked list from each island's boundary
401 # uvedge.neighbor_right is clockwise = forward = via uvedge.vb if not uvface.flipped
402 neighbor_lookup
, conflicts
= dict(), dict()
403 for uvedge
in island
.boundary
:
404 uvvertex
= uvedge
.va
if uvedge
.uvface
.flipped
else uvedge
.vb
405 if uvvertex
not in neighbor_lookup
:
406 neighbor_lookup
[uvvertex
] = uvedge
408 if uvvertex
not in conflicts
:
409 conflicts
[uvvertex
] = [neighbor_lookup
[uvvertex
], uvedge
]
411 conflicts
[uvvertex
].append(uvedge
)
413 for uvedge
in island
.boundary
:
414 uvvertex
= uvedge
.vb
if uvedge
.uvface
.flipped
else uvedge
.va
415 if uvvertex
not in conflicts
:
416 # using the 'get' method so as to handle single-connected vertices properly
417 uvedge
.neighbor_right
= neighbor_lookup
.get(uvvertex
, uvedge
)
418 uvedge
.neighbor_right
.neighbor_left
= uvedge
420 conflicts
[uvvertex
].append(uvedge
)
422 # resolve merged vertices with more boundaries crossing
423 def direction_to_float(vector
):
424 return (1 - vector
.x
/vector
.length
) if vector
.y
> 0 else (vector
.x
/vector
.length
- 1)
425 for uvvertex
, uvedges
in conflicts
.items():
426 def is_inwards(uvedge
):
427 return uvedge
.uvface
.flipped
== (uvedge
.va
is uvvertex
)
429 def uvedge_sortkey(uvedge
):
430 if is_inwards(uvedge
):
431 return direction_to_float(uvedge
.va
.co
- uvedge
.vb
.co
)
433 return direction_to_float(uvedge
.vb
.co
- uvedge
.va
.co
)
435 uvedges
.sort(key
=uvedge_sortkey
)
437 zip(uvedges
[:-1:2], uvedges
[1::2]) if is_inwards(uvedges
[0])
438 else zip([uvedges
[-1]] + uvedges
[1::2], uvedges
[:-1:2])):
439 left
.neighbor_right
= right
440 right
.neighbor_left
= left
443 def generate_stickers(self
, default_width
, do_create_numbers
=True):
444 """Add sticker faces where they are needed."""
445 def uvedge_priority(uvedge
):
446 """Returns whether it is a good idea to stick something on this edge's face"""
447 # TODO: it should take into account overlaps with faces and with other stickers
448 face
= uvedge
.uvface
.face
449 return face
.calc_area() / face
.calc_perimeter()
451 def add_sticker(uvedge
, index
, target_uvedge
):
452 uvedge
.sticker
= Sticker(uvedge
, default_width
, index
, target_uvedge
)
453 uvedge
.uvface
.island
.add_marker(uvedge
.sticker
)
455 def is_index_obvious(uvedge
, target
):
456 if uvedge
in (target
.neighbor_left
, target
.neighbor_right
):
458 if uvedge
.neighbor_left
.loop
.edge
is target
.neighbor_right
.loop
.edge
and uvedge
.neighbor_right
.loop
.edge
is target
.neighbor_left
.loop
.edge
:
462 for edge
in self
.edges
.values():
464 if edge
.is_main_cut
and len(edge
.uvedges
) >= 2 and edge
.vector
.length_squared
> 0:
465 target
, source
= edge
.uvedges
[:2]
466 if uvedge_priority(target
) < uvedge_priority(source
):
467 target
, source
= source
, target
468 target_island
= target
.uvface
.island
469 if do_create_numbers
:
470 for uvedge
in [source
] + edge
.uvedges
[2:]:
471 if not is_index_obvious(uvedge
, target
):
472 # it will not be clear to see that these uvedges should be sticked together
473 # So, create an arrow and put the index on all stickers
474 target_island
.sticker_numbering
+= 1
475 index
= str(target_island
.sticker_numbering
)
476 if is_upsidedown_wrong(index
):
478 target_island
.add_marker(Arrow(target
, default_width
, index
))
480 add_sticker(source
, index
, target
)
481 elif len(edge
.uvedges
) > 2:
482 target
= edge
.uvedges
[0]
483 if len(edge
.uvedges
) > 2:
484 for source
in edge
.uvedges
[2:]:
485 add_sticker(source
, index
, target
)
487 def generate_numbers_alone(self
, size
):
489 for edge
in self
.edges
.values():
490 if edge
.is_main_cut
and len(edge
.uvedges
) >= 2:
491 global_numbering
+= 1
492 index
= str(global_numbering
)
493 if is_upsidedown_wrong(index
):
495 for uvedge
in edge
.uvedges
:
496 uvedge
.uvface
.island
.add_marker(NumberAlone(uvedge
, index
, size
))
498 def enumerate_islands(self
):
499 for num
, island
in enumerate(self
.islands
, 1):
501 island
.generate_label()
503 def scale_islands(self
, scale
):
504 for island
in self
.islands
:
505 vertices
= set(island
.vertices
.values())
506 for point
in chain((vertex
.co
for vertex
in vertices
), island
.fake_vertices
):
509 def finalize_islands(self
, cage_size
, title_height
=0):
510 for island
in self
.islands
:
512 island
.title
= "[{}] {}".format(island
.abbreviation
, island
.label
)
513 points
= [vertex
.co
for vertex
in set(island
.vertices
.values())] + island
.fake_vertices
514 angle
, _
= cage_fit(points
, (cage_size
.y
- title_height
) / cage_size
.x
)
515 rot
= M
.Matrix
.Rotation(angle
, 2)
517 # note: we need an in-place operation, and Vector.rotate() seems to work for 3d vectors only
518 point
[:] = rot
@ point
519 for marker
in island
.markers
:
520 marker
.rot
= rot
@ marker
.rot
521 bottom_left
= M
.Vector((min(v
.x
for v
in points
), min(v
.y
for v
in points
) - title_height
))
523 top_right
= M
.Vector((max(v
.x
for v
in points
), max(v
.y
for v
in points
) - title_height
))
524 #print(f"fitted aspect: {(top_right.y - bottom_left.y) / (top_right.x - bottom_left.x)}")
527 island
.bounding_box
= M
.Vector((max(v
.x
for v
in points
), max(v
.y
for v
in points
)))
529 def largest_island_ratio(self
, cage_size
):
530 return max(i
/ p
for island
in self
.islands
for (i
, p
) in zip(island
.bounding_box
, cage_size
))
532 def fit_islands(self
, cage_size
):
533 """Move islands so that they fit onto pages, based on their bounding boxes"""
535 def try_emplace(island
, page_islands
, stops_x
, stops_y
, occupied_cache
):
536 """Tries to put island to each pair from stops_x, stops_y
537 and checks if it overlaps with any islands present on the page.
538 Returns True and positions the given island on success."""
539 bbox_x
, bbox_y
= island
.bounding_box
.xy
541 if x
+ bbox_x
> cage_size
.x
:
544 if y
+ bbox_y
> cage_size
.y
or (x
, y
) in occupied_cache
:
546 for i
, obstacle
in enumerate(page_islands
):
547 # if this obstacle overlaps with the island, try another stop
548 if (x
+ bbox_x
> obstacle
.pos
.x
and
549 obstacle
.pos
.x
+ obstacle
.bounding_box
.x
> x
and
550 y
+ bbox_y
> obstacle
.pos
.y
and
551 obstacle
.pos
.y
+ obstacle
.bounding_box
.y
> y
):
552 if x
>= obstacle
.pos
.x
and y
>= obstacle
.pos
.y
:
553 occupied_cache
.add((x
, y
))
554 # just a stupid heuristic to make subsequent searches faster
556 page_islands
[1:i
+1] = page_islands
[:i
]
557 page_islands
[0] = obstacle
560 # if no obstacle called break, this position is okay
562 page_islands
.append(island
)
563 stops_x
.append(x
+ bbox_x
)
564 stops_y
.append(y
+ bbox_y
)
568 def drop_portion(stops
, border
, divisor
):
570 # distance from left neighbor to the right one, excluding the first stop
571 distances
= [right
- left
for left
, right
in zip(stops
, chain(stops
[2:], [border
]))]
572 quantile
= sorted(distances
)[len(distances
) // divisor
]
573 return [stop
for stop
, distance
in zip(stops
, chain([quantile
], distances
)) if distance
>= quantile
]
575 if any(island
.bounding_box
.x
> cage_size
.x
or island
.bounding_box
.y
> cage_size
.y
for island
in self
.islands
):
577 "An island is too big to fit onto page of the given size. "
578 "Either downscale the model or find and split that island manually.\n"
579 "Export failed, sorry.")
580 # sort islands by their diagonal... just a guess
581 remaining_islands
= sorted(self
.islands
, reverse
=True, key
=lambda island
: island
.bounding_box
.length_squared
)
582 page_num
= 1 # TODO delete me
584 while remaining_islands
:
585 # create a new page and try to fit as many islands onto it as possible
586 page
= Page(page_num
)
588 occupied_cache
= set()
589 stops_x
, stops_y
= [0], [0]
590 for island
in remaining_islands
:
591 try_emplace(island
, page
.islands
, stops_x
, stops_y
, occupied_cache
)
592 # if overwhelmed with stops, drop a quarter of them
593 if len(stops_x
)**2 > 4 * len(self
.islands
) + 100:
594 stops_x
= drop_portion(stops_x
, cage_size
.x
, 4)
595 stops_y
= drop_portion(stops_y
, cage_size
.y
, 4)
596 remaining_islands
= [island
for island
in remaining_islands
if island
not in page
.islands
]
597 self
.pages
.append(page
)
599 def save_uv(self
, cage_size
=M
.Vector((1, 1)), separate_image
=False):
601 for island
in self
.islands
:
602 island
.save_uv_separate(self
.looptex
)
604 for island
in self
.islands
:
605 island
.save_uv(self
.looptex
, cage_size
)
607 def save_image(self
, page_size_pixels
: M
.Vector
, filename
):
608 for page
in self
.pages
:
609 image
= create_blank_image("Page {}".format(page
.name
), page_size_pixels
, alpha
=1)
610 image
.filepath_raw
= page
.image_path
= "{}_{}.png".format(filename
, page
.name
)
611 faces
= [face
for island
in page
.islands
for face
in island
.faces
]
612 self
.bake(faces
, image
)
615 bpy
.data
.images
.remove(image
)
617 def save_separate_images(self
, scale
, filepath
, embed
=None):
618 for i
, island
in enumerate(self
.islands
):
619 image_name
= "Island {}".format(i
)
620 image
= create_blank_image(image_name
, island
.bounding_box
* scale
, alpha
=0)
621 self
.bake(island
.faces
.keys(), image
)
623 island
.embedded_image
= embed(image
)
625 from os
import makedirs
627 makedirs(image_dir
, exist_ok
=True)
628 image_path
= os_path
.join(image_dir
, "island{}.png".format(i
))
629 image
.filepath_raw
= image_path
631 island
.image_path
= image_path
633 bpy
.data
.images
.remove(image
)
635 def bake(self
, faces
, image
):
637 raise UnfoldError("The mesh has no UV Map slots left. Either delete a UV Map or export the net without textures.")
638 ob
= bpy
.context
.active_object
640 # in Cycles, the image for baking is defined by the active Image Node
642 for mat
in me
.materials
:
644 img
= mat
.node_tree
.nodes
.new('ShaderNodeTexImage')
646 temp_nodes
[mat
] = img
647 mat
.node_tree
.nodes
.active
= img
648 # move all excess faces to negative numbers (that is the only way to disable them)
649 ignored_uvs
= [loop
[self
.looptex
].uv
for f
in self
.data
.faces
if f
not in faces
for loop
in f
.loops
]
650 for uv
in ignored_uvs
:
652 bake_type
= bpy
.context
.scene
.cycles
.bake_type
653 sta
= bpy
.context
.scene
.render
.bake
.use_selected_to_active
655 ob
.update_from_editmode()
656 me
.uv_layers
.active
= me
.uv_layers
[self
.looptex
.name
]
657 bpy
.ops
.object.bake(type=bake_type
, margin
=1, use_selected_to_active
=sta
, cage_extrusion
=100, use_clear
=False)
658 except RuntimeError as e
:
659 raise UnfoldError(*e
.args
)
661 for mat
, node
in temp_nodes
.items():
662 mat
.node_tree
.nodes
.remove(node
)
663 for uv
in ignored_uvs
:
668 """Wrapper for BPy Edge"""
669 __slots__
= ('data', 'va', 'vb', 'main_faces', 'uvedges',
671 'is_main_cut', 'force_cut', 'priority', 'freestyle')
673 def __init__(self
, edge
):
675 self
.va
, self
.vb
= edge
.verts
676 self
.vector
= self
.vb
.co
- self
.va
.co
677 # if self.main_faces is set, then self.uvedges[:2] must correspond to self.main_faces, in their order
678 # this constraint is assured at the time of finishing mesh.generate_cuts
679 self
.uvedges
= list()
681 self
.force_cut
= edge
.seam
# such edges will always be cut
682 self
.main_faces
= None # two faces that may be connected in the island
683 # is_main_cut defines whether the two main faces are connected
684 # all the others will be assumed to be cut
685 self
.is_main_cut
= True
688 self
.freestyle
= False
690 def choose_main_faces(self
):
691 """Choose two main faces that might get connected in an island"""
692 from itertools
import combinations
693 loops
= self
.data
.link_loops
695 return abs(pair
[0].face
.normal
.dot(pair
[1].face
.normal
))
697 self
.main_faces
= list(loops
)
699 # find (with brute force) the pair of indices whose loops have the most similar normals
700 self
.main_faces
= max(combinations(loops
, 2), key
=score
)
701 if self
.main_faces
and self
.main_faces
[1].vert
== self
.va
:
702 self
.main_faces
= self
.main_faces
[::-1]
704 def calculate_angle(self
):
705 """Calculate the angle between the main faces"""
706 loop_a
, loop_b
= self
.main_faces
707 normal_a
, normal_b
= (l
.face
.normal
for l
in self
.main_faces
)
708 if not normal_a
or not normal_b
:
709 self
.angle
= -3 # just a very sharp angle
711 s
= normal_a
.cross(normal_b
).dot(self
.vector
.normalized())
712 s
= max(min(s
, 1.0), -1.0) # deal with rounding errors
714 if loop_a
.link_loop_next
.vert
!= loop_b
.vert
or loop_b
.link_loop_next
.vert
!= loop_a
.vert
:
715 self
.angle
= abs(self
.angle
)
717 def generate_priority(self
, priority_effect
, average_length
):
718 """Calculate the priority value for cutting"""
721 self
.priority
= priority_effect
['CONVEX'] * angle
/ pi
723 self
.priority
= priority_effect
['CONCAVE'] * (-angle
) / pi
724 self
.priority
+= (self
.vector
.length
/ average_length
) * priority_effect
['LENGTH']
726 def is_cut(self
, face
):
727 """Return False if this edge will the given face to another one in the resulting net
728 (useful for edges with more than two faces connected)"""
729 # Return whether there is a cut between the two main faces
730 if self
.main_faces
and face
in {loop
.face
for loop
in self
.main_faces
}:
731 return self
.is_main_cut
732 # All other faces (third and more) are automatically treated as cut
736 def other_uvedge(self
, this
):
737 """Get an uvedge of this edge that is not the given one
738 causes an IndexError if case of less than two adjacent edges"""
739 return self
.uvedges
[1] if this
is self
.uvedges
[0] else self
.uvedges
[0]
743 """Part of the net to be exported"""
744 __slots__
= ('mesh', 'faces', 'edges', 'vertices', 'fake_vertices', 'boundary', 'markers',
745 'pos', 'bounding_box',
746 'image_path', 'embedded_image',
747 'number', 'label', 'abbreviation', 'title',
748 'has_safe_geometry', 'is_inside_out',
751 def __init__(self
, mesh
, face
, matrix
, normal_matrix
):
752 """Create an Island from a single Face"""
754 self
.faces
= dict() # face -> uvface
755 self
.edges
= dict() # loop -> uvedge
756 self
.vertices
= dict() # loop -> uvvertex
757 self
.fake_vertices
= list()
758 self
.markers
= list()
760 self
.abbreviation
= None
762 self
.pos
= M
.Vector((0, 0))
763 self
.image_path
= None
764 self
.embedded_image
= None
765 self
.is_inside_out
= False # swaps concave <-> convex edges
766 self
.has_safe_geometry
= True
767 self
.sticker_numbering
= 0
769 uvface
= UVFace(face
, self
, matrix
, normal_matrix
)
770 self
.vertices
.update(uvface
.vertices
)
771 self
.edges
.update(uvface
.edges
)
772 self
.faces
[face
] = uvface
773 # UVEdges on the boundary
774 self
.boundary
= list(self
.edges
.values())
776 def add_marker(self
, marker
):
777 self
.fake_vertices
.extend(marker
.bounds
)
778 self
.markers
.append(marker
)
780 def generate_label(self
, label
=None, abbreviation
=None):
781 """Assign a name to this island automatically"""
782 abbr
= abbreviation
or self
.abbreviation
or str(self
.number
)
783 # TODO: dots should be added in the last instant when outputting any text
784 if is_upsidedown_wrong(abbr
):
786 self
.label
= label
or self
.label
or "Island {}".format(self
.number
)
787 self
.abbreviation
= abbr
789 def save_uv(self
, tex
, cage_size
):
790 """Save UV Coordinates of all UVFaces to a given UV texture
791 tex: UV Texture layer to use (BMLayerItem)
792 page_size: size of the page in pixels (vector)"""
793 scale_x
, scale_y
= 1 / cage_size
.x
, 1 / cage_size
.y
794 for loop
, uvvertex
in self
.vertices
.items():
795 uv
= uvvertex
.co
+ self
.pos
796 loop
[tex
].uv
= uv
.x
* scale_x
, uv
.y
* scale_y
798 def save_uv_separate(self
, tex
):
799 """Save UV Coordinates of all UVFaces to a given UV texture, spanning from 0 to 1
800 tex: UV Texture layer to use (BMLayerItem)
801 page_size: size of the page in pixels (vector)"""
802 scale_x
, scale_y
= 1 / self
.bounding_box
.x
, 1 / self
.bounding_box
.y
803 for loop
, uvvertex
in self
.vertices
.items():
804 loop
[tex
].uv
= uvvertex
.co
.x
* scale_x
, uvvertex
.co
.y
* scale_y
806 def join(uvedge_a
, uvedge_b
, size_limit
=None, epsilon
=1e-6):
808 Try to join other island on given edge
809 Returns False if they would overlap
812 class Intersection(Exception):
815 class GeometryError(Exception):
818 def is_below(self
, other
, correct_geometry
=True):
821 if self
.top
< other
.bottom
:
823 if other
.top
< self
.bottom
:
825 if self
.max.tup
<= other
.min.tup
:
827 if other
.max.tup
<= self
.min.tup
:
829 self_vector
= self
.max.co
- self
.min.co
830 min_to_min
= other
.min.co
- self
.min.co
831 cross_b1
= self_vector
.cross(min_to_min
)
832 cross_b2
= self_vector
.cross(other
.max.co
- self
.min.co
)
833 if cross_b2
< cross_b1
:
834 cross_b1
, cross_b2
= cross_b2
, cross_b1
835 if cross_b2
> 0 and (cross_b1
> 0 or (cross_b1
== 0 and not self
.is_uvface_upwards())):
837 if cross_b1
< 0 and (cross_b2
< 0 or (cross_b2
== 0 and self
.is_uvface_upwards())):
839 other_vector
= other
.max.co
- other
.min.co
840 cross_a1
= other_vector
.cross(-min_to_min
)
841 cross_a2
= other_vector
.cross(self
.max.co
- other
.min.co
)
842 if cross_a2
< cross_a1
:
843 cross_a1
, cross_a2
= cross_a2
, cross_a1
844 if cross_a2
> 0 and (cross_a1
> 0 or (cross_a1
== 0 and not other
.is_uvface_upwards())):
846 if cross_a1
< 0 and (cross_a2
< 0 or (cross_a2
== 0 and other
.is_uvface_upwards())):
848 if cross_a1
== cross_b1
== cross_a2
== cross_b2
== 0:
851 elif self
.is_uvface_upwards() == other
.is_uvface_upwards():
854 if self
.min.tup
== other
.min.tup
or self
.max.tup
== other
.max.tup
:
855 return cross_a2
> cross_b2
858 class QuickSweepline
:
859 """Efficient sweepline based on binary search, checking neighbors only"""
861 self
.children
= list()
863 def add(self
, item
, cmp=is_below
):
864 low
, high
= 0, len(self
.children
)
866 mid
= (low
+ high
) // 2
867 if cmp(self
.children
[mid
], item
):
871 self
.children
.insert(low
, item
)
873 def remove(self
, item
, cmp=is_below
):
874 index
= self
.children
.index(item
)
875 self
.children
.pop(index
)
876 if index
> 0 and index
< len(self
.children
):
877 # check for intersection
878 if cmp(self
.children
[index
], self
.children
[index
-1]):
881 class BruteSweepline
:
882 """Safe sweepline which checks all its members pairwise"""
884 self
.children
= set()
886 def add(self
, item
, cmp=is_below
):
887 for child
in self
.children
:
888 if child
.min is not item
.min and child
.max is not item
.max:
889 cmp(item
, child
, False)
890 self
.children
.add(item
)
892 def remove(self
, item
):
893 self
.children
.remove(item
)
895 def sweep(sweepline
, segments
):
896 """Sweep across the segments and raise an exception if necessary"""
897 # careful, 'segments' may be a use-once iterator
898 events_add
= sorted(segments
, reverse
=True, key
=lambda uvedge
: uvedge
.min.tup
)
899 events_remove
= sorted(events_add
, reverse
=True, key
=lambda uvedge
: uvedge
.max.tup
)
901 while events_add
and events_add
[-1].min.tup
<= events_remove
[-1].max.tup
:
902 sweepline
.add(events_add
.pop())
903 sweepline
.remove(events_remove
.pop())
905 def root_find(value
, tree
):
906 """Find the root of a given value in a forest-like dictionary
907 also updates the dictionary using path compression"""
908 parent
, relink
= tree
.get(value
), list()
909 while parent
is not None:
911 value
, parent
= parent
, tree
.get(parent
)
912 tree
.update(dict.fromkeys(relink
, value
))
915 def slope_from(position
):
917 vec
= (uvedge
.vb
.co
- uvedge
.va
.co
) if uvedge
.va
.tup
== position
else (uvedge
.va
.co
- uvedge
.vb
.co
)
918 return (vec
.y
/ vec
.length
+ 1) if ((vec
.x
, vec
.y
) > (0, 0)) else (-1 - vec
.y
/ vec
.length
)
921 island_a
, island_b
= (e
.uvface
.island
for e
in (uvedge_a
, uvedge_b
))
922 if island_a
is island_b
:
924 elif len(island_b
.faces
) > len(island_a
.faces
):
925 uvedge_a
, uvedge_b
= uvedge_b
, uvedge_a
926 island_a
, island_b
= island_b
, island_a
927 # check if vertices and normals are aligned correctly
928 verts_flipped
= uvedge_b
.loop
.vert
is uvedge_a
.loop
.vert
929 flipped
= verts_flipped ^ uvedge_a
.uvface
.flipped ^ uvedge_b
.uvface
.flipped
931 # NOTE: if the edges differ in length, the matrix will involve uniform scaling.
932 # Such situation may occur in the case of twisted n-gons
933 first_b
, second_b
= (uvedge_b
.va
, uvedge_b
.vb
) if not verts_flipped
else (uvedge_b
.vb
, uvedge_b
.va
)
935 rot
= fitting_matrix(first_b
.co
- second_b
.co
, uvedge_a
.vb
.co
- uvedge_a
.va
.co
)
937 flip
= M
.Matrix(((-1, 0), (0, 1)))
938 rot
= fitting_matrix(flip
@ (first_b
.co
- second_b
.co
), uvedge_a
.vb
.co
- uvedge_a
.va
.co
) @ flip
939 trans
= uvedge_a
.vb
.co
- rot
@ first_b
.co
940 # preview of island_b's vertices after the join operation
941 phantoms
= {uvvertex
: UVVertex(rot
@ uvvertex
.co
+ trans
) for uvvertex
in island_b
.vertices
.values()}
943 # check the size of the resulting island
945 points
= [vert
.co
for vert
in chain(island_a
.vertices
.values(), phantoms
.values())]
946 left
, right
, bottom
, top
= (fn(co
[i
] for co
in points
) for i
in (0, 1) for fn
in (min, max))
947 bbox_width
= right
- left
948 bbox_height
= top
- bottom
949 if min(bbox_width
, bbox_height
)**2 > size_limit
.x
**2 + size_limit
.y
**2:
951 if (bbox_width
> size_limit
.x
or bbox_height
> size_limit
.y
) and (bbox_height
> size_limit
.x
or bbox_width
> size_limit
.y
):
952 _
, height
= cage_fit(points
, size_limit
.y
/ size_limit
.x
)
953 if height
> size_limit
.y
:
956 distance_limit
= uvedge_a
.loop
.edge
.calc_length() * epsilon
957 # try and merge UVVertices closer than sqrt(distance_limit)
958 merged_uvedges
= set()
959 merged_uvedge_pairs
= list()
961 # merge all uvvertices that are close enough using a union-find structure
962 # uvvertices will be merged only in cases island_b->island_a and island_a->island_a
963 # all resulting groups are merged together to a uvvertex of island_a
964 is_merged_mine
= False
965 shared_vertices
= {loop
.vert
for loop
in chain(island_a
.vertices
, island_b
.vertices
)}
966 for vertex
in shared_vertices
:
967 uvs_a
= {island_a
.vertices
.get(loop
) for loop
in vertex
.link_loops
} - {None}
968 uvs_b
= {island_b
.vertices
.get(loop
) for loop
in vertex
.link_loops
} - {None}
969 for a
, b
in product(uvs_a
, uvs_b
):
970 if (a
.co
- phantoms
[b
].co
).length_squared
< distance_limit
:
971 phantoms
[b
] = root_find(a
, phantoms
)
972 for a1
, a2
in combinations(uvs_a
, 2):
973 if (a1
.co
- a2
.co
).length_squared
< distance_limit
:
974 a1
, a2
= (root_find(a
, phantoms
) for a
in (a1
, a2
))
977 is_merged_mine
= True
978 for source
, target
in phantoms
.items():
979 target
= root_find(target
, phantoms
)
980 phantoms
[source
] = target
982 for uvedge
in (chain(island_a
.boundary
, island_b
.boundary
) if is_merged_mine
else island_b
.boundary
):
983 for loop
in uvedge
.loop
.link_loops
:
984 partner
= island_b
.edges
.get(loop
) or island_a
.edges
.get(loop
)
985 if partner
is not None and partner
is not uvedge
:
986 paired_a
, paired_b
= phantoms
.get(partner
.vb
, partner
.vb
), phantoms
.get(partner
.va
, partner
.va
)
987 if (partner
.uvface
.flipped ^ flipped
) != uvedge
.uvface
.flipped
:
988 paired_a
, paired_b
= paired_b
, paired_a
989 if phantoms
.get(uvedge
.va
, uvedge
.va
) is paired_a
and phantoms
.get(uvedge
.vb
, uvedge
.vb
) is paired_b
:
990 # if these two edges will get merged, add them both to the set
991 merged_uvedges
.update((uvedge
, partner
))
992 merged_uvedge_pairs
.append((uvedge
, partner
))
995 if uvedge_b
not in merged_uvedges
:
996 raise UnfoldError("Export failed. Please report this error, including the model if you can.")
999 PhantomUVEdge(phantoms
[uvedge
.va
], phantoms
[uvedge
.vb
], flipped ^ uvedge
.uvface
.flipped
)
1000 for uvedge
in island_b
.boundary
if uvedge
not in merged_uvedges
]
1001 # TODO: if is_merged_mine, it might make sense to create a similar list from island_a.boundary as well
1003 incidence
= {vertex
.tup
for vertex
in phantoms
.values()}.intersection(vertex
.tup
for vertex
in island_a
.vertices
.values())
1004 incidence
= {position
: list() for position
in incidence
} # from now on, 'incidence' is a dict
1005 for uvedge
in chain(boundary_other
, island_a
.boundary
):
1006 if uvedge
.va
.co
== uvedge
.vb
.co
:
1008 for vertex
in (uvedge
.va
, uvedge
.vb
):
1009 site
= incidence
.get(vertex
.tup
)
1010 if site
is not None:
1012 for position
, segments
in incidence
.items():
1013 if len(segments
) <= 2:
1015 segments
.sort(key
=slope_from(position
))
1016 for right
, left
in pairs(segments
):
1017 is_left_ccw
= left
.is_uvface_upwards() ^
(left
.max.tup
== position
)
1018 is_right_ccw
= right
.is_uvface_upwards() ^
(right
.max.tup
== position
)
1019 if is_right_ccw
and not is_left_ccw
and type(right
) is not type(left
) and right
not in merged_uvedges
and left
not in merged_uvedges
:
1021 if (not is_right_ccw
and right
not in merged_uvedges
) ^
(is_left_ccw
and left
not in merged_uvedges
):
1024 # check for self-intersections
1027 sweepline
= QuickSweepline() if island_a
.has_safe_geometry
and island_b
.has_safe_geometry
else BruteSweepline()
1028 sweep(sweepline
, (uvedge
for uvedge
in chain(boundary_other
, island_a
.boundary
)))
1029 island_a
.has_safe_geometry
&= island_b
.has_safe_geometry
1030 except GeometryError
:
1031 sweep(BruteSweepline(), (uvedge
for uvedge
in chain(boundary_other
, island_a
.boundary
)))
1032 island_a
.has_safe_geometry
= False
1033 except Intersection
:
1036 # mark all edges that connect the islands as not cut
1037 for uvedge
in merged_uvedges
:
1038 island_a
.mesh
.edges
[uvedge
.loop
.edge
].is_main_cut
= False
1040 # include all trasformed vertices as mine
1041 island_a
.vertices
.update({loop
: phantoms
[uvvertex
] for loop
, uvvertex
in island_b
.vertices
.items()})
1043 # re-link uvedges and uvfaces to their transformed locations
1044 for uvedge
in island_b
.edges
.values():
1045 uvedge
.va
= phantoms
[uvedge
.va
]
1046 uvedge
.vb
= phantoms
[uvedge
.vb
]
1049 for uvedge
in island_a
.edges
.values():
1050 uvedge
.va
= phantoms
.get(uvedge
.va
, uvedge
.va
)
1051 uvedge
.vb
= phantoms
.get(uvedge
.vb
, uvedge
.vb
)
1052 island_a
.edges
.update(island_b
.edges
)
1054 for uvface
in island_b
.faces
.values():
1055 uvface
.island
= island_a
1056 uvface
.vertices
= {loop
: phantoms
[uvvertex
] for loop
, uvvertex
in uvface
.vertices
.items()}
1057 uvface
.flipped ^
= flipped
1059 # there may be own uvvertices that need to be replaced by phantoms
1060 for uvface
in island_a
.faces
.values():
1061 if any(uvvertex
in phantoms
for uvvertex
in uvface
.vertices
):
1062 uvface
.vertices
= {loop
: phantoms
.get(uvvertex
, uvvertex
) for loop
, uvvertex
in uvface
.vertices
.items()}
1063 island_a
.faces
.update(island_b
.faces
)
1065 island_a
.boundary
= [
1066 uvedge
for uvedge
in chain(island_a
.boundary
, island_b
.boundary
)
1067 if uvedge
not in merged_uvedges
]
1069 for uvedge
, partner
in merged_uvedge_pairs
:
1070 # make sure that main faces are the ones actually merged (this changes nothing in most cases)
1071 edge
= island_a
.mesh
.edges
[uvedge
.loop
.edge
]
1072 edge
.main_faces
= uvedge
.loop
, partner
.loop
1074 # everything seems to be OK
1079 """Container for several Islands"""
1080 __slots__
= ('islands', 'name', 'image_path')
1082 def __init__(self
, num
=1):
1083 self
.islands
= list()
1084 self
.name
= "page{}".format(num
) # TODO delete me
1085 self
.image_path
= None
1090 __slots__
= ('co', 'tup')
1092 def __init__(self
, vector
):
1094 self
.tup
= tuple(self
.co
)
1099 # Every UVEdge is attached to only one UVFace
1100 # UVEdges are doubled as needed because they both have to point clockwise around their faces
1101 __slots__
= ('va', 'vb', 'uvface', 'loop',
1102 'min', 'max', 'bottom', 'top',
1103 'neighbor_left', 'neighbor_right', 'sticker')
1105 def __init__(self
, vertex1
: UVVertex
, vertex2
: UVVertex
, uvface
, loop
):
1109 self
.uvface
= uvface
1114 """Update data if UVVertices have moved"""
1115 self
.min, self
.max = (self
.va
, self
.vb
) if (self
.va
.tup
< self
.vb
.tup
) else (self
.vb
, self
.va
)
1116 y1
, y2
= self
.va
.co
.y
, self
.vb
.co
.y
1117 self
.bottom
, self
.top
= (y1
, y2
) if y1
< y2
else (y2
, y1
)
1119 def is_uvface_upwards(self
):
1120 return (self
.va
.tup
< self
.vb
.tup
) ^ self
.uvface
.flipped
1123 return "({0.va} - {0.vb})".format(self
)
1126 class PhantomUVEdge
:
1127 """Temporary 2D Segment for calculations"""
1128 __slots__
= ('va', 'vb', 'min', 'max', 'bottom', 'top')
1130 def __init__(self
, vertex1
: UVVertex
, vertex2
: UVVertex
, flip
):
1131 self
.va
, self
.vb
= (vertex2
, vertex1
) if flip
else (vertex1
, vertex2
)
1132 self
.min, self
.max = (self
.va
, self
.vb
) if (self
.va
.tup
< self
.vb
.tup
) else (self
.vb
, self
.va
)
1133 y1
, y2
= self
.va
.co
.y
, self
.vb
.co
.y
1134 self
.bottom
, self
.top
= (y1
, y2
) if y1
< y2
else (y2
, y1
)
1136 def is_uvface_upwards(self
):
1137 return self
.va
.tup
< self
.vb
.tup
1140 return "[{0.va} - {0.vb}]".format(self
)
1145 __slots__
= ('vertices', 'edges', 'face', 'island', 'flipped')
1147 def __init__(self
, face
: bmesh
.types
.BMFace
, island
: Island
, matrix
=1, normal_matrix
=1):
1149 self
.island
= island
1150 self
.flipped
= False # a flipped UVFace has edges clockwise
1152 flatten
= z_up_matrix(normal_matrix
@ face
.normal
) @ matrix
1153 self
.vertices
= {loop
: UVVertex(flatten
@ loop
.vert
.co
) for loop
in face
.loops
}
1154 self
.edges
= {loop
: UVEdge(self
.vertices
[loop
], self
.vertices
[loop
.link_loop_next
], self
, loop
) for loop
in face
.loops
}
1158 """Mark in the document: an arrow denoting the number of the edge it points to"""
1159 __slots__
= ('bounds', 'center', 'rot', 'text', 'size')
1161 def __init__(self
, uvedge
, size
, index
):
1162 self
.text
= str(index
)
1163 edge
= (uvedge
.vb
.co
- uvedge
.va
.co
) if not uvedge
.uvface
.flipped
else (uvedge
.va
.co
- uvedge
.vb
.co
)
1164 self
.center
= (uvedge
.va
.co
+ uvedge
.vb
.co
) / 2
1166 tangent
= edge
.normalized()
1168 self
.rot
= M
.Matrix(((cos
, -sin
), (sin
, cos
)))
1169 normal
= M
.Vector((sin
, -cos
))
1170 self
.bounds
= [self
.center
, self
.center
+ (1.2 * normal
+ tangent
) * size
, self
.center
+ (1.2 * normal
- tangent
) * size
]
1174 """Mark in the document: sticker tab"""
1175 __slots__
= ('bounds', 'center', 'rot', 'text', 'width', 'vertices')
1177 def __init__(self
, uvedge
, default_width
, index
, other
: UVEdge
):
1178 """Sticker is directly attached to the given UVEdge"""
1179 first_vertex
, second_vertex
= (uvedge
.va
, uvedge
.vb
) if not uvedge
.uvface
.flipped
else (uvedge
.vb
, uvedge
.va
)
1180 edge
= first_vertex
.co
- second_vertex
.co
1181 sticker_width
= min(default_width
, edge
.length
/ 2)
1182 other_first
, other_second
= (other
.va
, other
.vb
) if not other
.uvface
.flipped
else (other
.vb
, other
.va
)
1183 other_edge
= other_second
.co
- other_first
.co
1185 # angle a is at vertex uvedge.va, b is at uvedge.vb
1187 sin_a
= sin_b
= 0.75**0.5
1188 # len_a is length of the side adjacent to vertex a, len_b likewise
1189 len_a
= len_b
= sticker_width
/ sin_a
1191 # fix overlaps with the most often neighbour - its sticking target
1192 if first_vertex
== other_second
:
1193 cos_a
= max(cos_a
, edge
.dot(other_edge
) / (edge
.length_squared
)) # angles between pi/3 and 0
1194 elif second_vertex
== other_first
:
1195 cos_b
= max(cos_b
, edge
.dot(other_edge
) / (edge
.length_squared
)) # angles between pi/3 and 0
1197 # Fix tabs for sticking targets with small angles
1199 other_face_neighbor_left
= other
.neighbor_left
1200 other_face_neighbor_right
= other
.neighbor_right
1201 other_edge_neighbor_a
= other_face_neighbor_left
.vb
.co
- other
.vb
.co
1202 other_edge_neighbor_b
= other_face_neighbor_right
.va
.co
- other
.va
.co
1203 # Adjacent angles in the face
1204 cos_a
= max(cos_a
, -other_edge
.dot(other_edge_neighbor_a
) / (other_edge
.length
*other_edge_neighbor_a
.length
))
1205 cos_b
= max(cos_b
, other_edge
.dot(other_edge_neighbor_b
) / (other_edge
.length
*other_edge_neighbor_b
.length
))
1206 except AttributeError: # neighbor data may be missing for edges with 3+ faces
1208 except ZeroDivisionError:
1211 # Calculate the lengths of the glue tab edges using the possibly smaller angles
1212 sin_a
= abs(1 - cos_a
**2)**0.5
1213 len_b
= min(len_a
, (edge
.length
* sin_a
) / (sin_a
* cos_b
+ sin_b
* cos_a
))
1214 len_a
= 0 if sin_a
== 0 else min(sticker_width
/ sin_a
, (edge
.length
- len_b
*cos_b
) / cos_a
)
1216 sin_b
= abs(1 - cos_b
**2)**0.5
1217 len_a
= min(len_a
, (edge
.length
* sin_b
) / (sin_a
* cos_b
+ sin_b
* cos_a
))
1218 len_b
= 0 if sin_b
== 0 else min(sticker_width
/ sin_b
, (edge
.length
- len_a
* cos_a
) / cos_b
)
1220 v3
= UVVertex(second_vertex
.co
+ M
.Matrix(((cos_b
, -sin_b
), (sin_b
, cos_b
))) @ edge
* len_b
/ edge
.length
)
1221 v4
= UVVertex(first_vertex
.co
+ M
.Matrix(((-cos_a
, -sin_a
), (sin_a
, -cos_a
))) @ edge
* len_a
/ edge
.length
)
1223 self
.vertices
= [second_vertex
, v3
, v4
, first_vertex
]
1225 self
.vertices
= [second_vertex
, v3
, first_vertex
]
1227 sin
, cos
= edge
.y
/ edge
.length
, edge
.x
/ edge
.length
1228 self
.rot
= M
.Matrix(((cos
, -sin
), (sin
, cos
)))
1229 self
.width
= sticker_width
* 0.9
1230 if index
and uvedge
.uvface
.island
is not other
.uvface
.island
:
1231 self
.text
= "{}:{}".format(other
.uvface
.island
.abbreviation
, index
)
1234 self
.center
= (uvedge
.va
.co
+ uvedge
.vb
.co
) / 2 + self
.rot
@ M
.Vector((0, self
.width
* 0.2))
1235 self
.bounds
= [v3
.co
, v4
.co
, self
.center
] if v3
.co
!= v4
.co
else [v3
.co
, self
.center
]
1239 """Mark in the document: numbering inside the island denoting edges to be sticked"""
1240 __slots__
= ('bounds', 'center', 'rot', 'text', 'size')
1242 def __init__(self
, uvedge
, index
, default_size
=0.005):
1243 """Sticker is directly attached to the given UVEdge"""
1244 edge
= (uvedge
.va
.co
- uvedge
.vb
.co
) if not uvedge
.uvface
.flipped
else (uvedge
.vb
.co
- uvedge
.va
.co
)
1246 self
.size
= default_size
1247 sin
, cos
= edge
.y
/ edge
.length
, edge
.x
/ edge
.length
1248 self
.rot
= M
.Matrix(((cos
, -sin
), (sin
, cos
)))
1250 self
.center
= (uvedge
.va
.co
+ uvedge
.vb
.co
) / 2 - self
.rot
@ M
.Vector((0, self
.size
* 1.2))
1251 self
.bounds
= [self
.center
]
1255 """Simple SVG exporter"""
1257 def __init__(self
, page_size
: M
.Vector
, style
, margin
, pure_net
=True, angle_epsilon
=0.01):
1258 """Initialize document settings.
1259 page_size: document dimensions in meters
1260 pure_net: if True, do not use image"""
1261 self
.page_size
= page_size
1262 self
.pure_net
= pure_net
1264 self
.margin
= margin
1266 self
.angle_epsilon
= angle_epsilon
1269 def encode_image(cls
, bpy_image
):
1272 with tempfile
.TemporaryDirectory() as directory
:
1273 filename
= directory
+ "/i.png"
1274 bpy_image
.filepath_raw
= filename
1276 return base64
.encodebytes(open(filename
, "rb").read()).decode('ascii')
1278 def format_vertex(self
, vector
, pos
=M
.Vector((0, 0))):
1279 """Return a string with both coordinates of the given vertex."""
1281 return "{:.6f} {:.6f}".format((x
+ self
.margin
) * 1000, (self
.page_size
.y
- y
- self
.margin
) * 1000)
1283 def write(self
, mesh
, filename
):
1284 """Write data to a file given by its name."""
1285 line_through
= " L ".join
# used for formatting of SVG path data
1288 dl
= ["{:.2f}".format(length
* self
.style
.line_width
* 1000) for length
in (2, 5, 10)]
1290 'SOLID': "none", 'DOT': "{0},{1}".format(*dl
), 'DASH': "{1},{2}".format(*dl
),
1291 'LONGDASH': "{2},{1}".format(*dl
), 'DASHDOT': "{2},{1},{0},{1}".format(*dl
)}
1293 def format_color(vec
):
1294 return "#{:02x}{:02x}{:02x}".format(round(vec
[0] * 255), round(vec
[1] * 255), round(vec
[2] * 255))
1296 def format_matrix(matrix
):
1297 return " ".join("{:.6f}".format(cell
) for column
in matrix
for cell
in column
)
1299 def path_convert(string
, relto
=os_path
.dirname(filename
)):
1300 assert(os_path
) # check the module was imported
1301 string
= os_path
.relpath(string
, relto
)
1302 if os_path
.sep
!= '/':
1303 string
= string
.replace(os_path
.sep
, '/')
1307 name
: format_color(getattr(self
.style
, name
)) for name
in (
1308 "outer_color", "outbg_color", "convex_color", "concave_color", "freestyle_color",
1309 "inbg_color", "sticker_fill", "text_color")}
1311 name
: format_style
[getattr(self
.style
, name
)] for name
in
1312 ("outer_style", "convex_style", "concave_style", "freestyle_style")})
1314 name
: getattr(self
.style
, attr
)[3] for name
, attr
in (
1315 ("outer_alpha", "outer_color"), ("outbg_alpha", "outbg_color"),
1316 ("convex_alpha", "convex_color"), ("concave_alpha", "concave_color"),
1317 ("freestyle_alpha", "freestyle_color"),
1318 ("inbg_alpha", "inbg_color"), ("sticker_alpha", "sticker_fill"),
1319 ("text_alpha", "text_color"))})
1321 name
: getattr(self
.style
, name
) * self
.style
.line_width
* 1000 for name
in
1322 ("outer_width", "convex_width", "concave_width", "freestyle_width", "outbg_width", "inbg_width")})
1323 for num
, page
in enumerate(mesh
.pages
):
1324 page_filename
= "{}_{}.svg".format(filename
[:filename
.rfind(".svg")], page
.name
) if len(mesh
.pages
) > 1 else filename
1325 with
open(page_filename
, 'w') as f
:
1326 print(self
.svg_base
.format(width
=self
.page_size
.x
*1000, height
=self
.page_size
.y
*1000), file=f
)
1327 print(self
.css_base
.format(**styleargs
), file=f
)
1330 self
.image_linked_tag
.format(
1331 pos
="{0:.6f} {0:.6f}".format(self
.margin
*1000),
1332 width
=(self
.page_size
.x
- 2 * self
.margin
)*1000,
1333 height
=(self
.page_size
.y
- 2 * self
.margin
)*1000,
1334 path
=path_convert(page
.image_path
)),
1336 if len(page
.islands
) > 1:
1337 print("<g>", file=f
)
1339 for island
in page
.islands
:
1340 print("<g>", file=f
)
1341 if island
.image_path
:
1343 self
.image_linked_tag
.format(
1344 pos
=self
.format_vertex(island
.pos
+ M
.Vector((0, island
.bounding_box
.y
))),
1345 width
=island
.bounding_box
.x
*1000,
1346 height
=island
.bounding_box
.y
*1000,
1347 path
=path_convert(island
.image_path
)),
1349 elif island
.embedded_image
:
1351 self
.image_embedded_tag
.format(
1352 pos
=self
.format_vertex(island
.pos
+ M
.Vector((0, island
.bounding_box
.y
))),
1353 width
=island
.bounding_box
.x
*1000,
1354 height
=island
.bounding_box
.y
*1000,
1355 path
=island
.image_path
),
1356 island
.embedded_image
, "'/>",
1360 self
.text_tag
.format(
1361 size
=1000 * self
.text_size
,
1362 x
=1000 * (island
.bounding_box
.x
*0.5 + island
.pos
.x
+ self
.margin
),
1363 y
=1000 * (self
.page_size
.y
- island
.pos
.y
- self
.margin
- 0.2 * self
.text_size
),
1364 label
=island
.title
),
1367 data_markers
, data_stickerfill
, data_outer
, data_convex
, data_concave
, data_freestyle
= (list() for i
in range(6))
1368 for marker
in island
.markers
:
1369 if isinstance(marker
, Sticker
):
1370 data_stickerfill
.append("M {} Z".format(
1371 line_through(self
.format_vertex(vertex
.co
, island
.pos
) for vertex
in marker
.vertices
)))
1373 data_markers
.append(self
.text_transformed_tag
.format(
1375 pos
=self
.format_vertex(marker
.center
, island
.pos
),
1376 mat
=format_matrix(marker
.rot
),
1377 size
=marker
.width
* 1000))
1378 elif isinstance(marker
, Arrow
):
1379 size
= marker
.size
* 1000
1380 position
= marker
.center
+ marker
.size
* marker
.rot
@ M
.Vector((0, -0.9))
1381 data_markers
.append(self
.arrow_marker_tag
.format(
1383 arrow_pos
=self
.format_vertex(marker
.center
, island
.pos
),
1385 pos
=self
.format_vertex(position
, island
.pos
- marker
.size
*M
.Vector((0, 0.4))),
1386 mat
=format_matrix(size
* marker
.rot
)))
1387 elif isinstance(marker
, NumberAlone
):
1388 data_markers
.append(self
.text_transformed_tag
.format(
1390 pos
=self
.format_vertex(marker
.center
, island
.pos
),
1391 mat
=format_matrix(marker
.rot
),
1392 size
=marker
.size
* 1000))
1393 if data_stickerfill
and self
.style
.sticker_fill
[3] > 0:
1394 print("<path class='sticker' d='", rows(data_stickerfill
), "'/>", file=f
)
1396 outer_edges
= set(island
.boundary
)
1399 uvedge
= outer_edges
.pop()
1402 data_loop
.extend(self
.format_vertex(vertex
.co
, island
.pos
) for vertex
in uvedge
.sticker
.vertices
[1:])
1404 vertex
= uvedge
.vb
if uvedge
.uvface
.flipped
else uvedge
.va
1405 data_loop
.append(self
.format_vertex(vertex
.co
, island
.pos
))
1406 uvedge
= uvedge
.neighbor_right
1408 outer_edges
.remove(uvedge
)
1411 data_outer
.append("M {} Z".format(line_through(data_loop
)))
1413 visited_edges
= set()
1414 for loop
, uvedge
in island
.edges
.items():
1415 edge
= mesh
.edges
[loop
.edge
]
1416 if edge
.is_cut(uvedge
.uvface
.face
) and not uvedge
.sticker
:
1418 data_uvedge
= "M {}".format(
1419 line_through(self
.format_vertex(vertex
.co
, island
.pos
) for vertex
in (uvedge
.va
, uvedge
.vb
)))
1421 data_freestyle
.append(data_uvedge
)
1422 # each uvedge is in two opposite-oriented variants; we want to add each only once
1423 vertex_pair
= frozenset((uvedge
.va
, uvedge
.vb
))
1424 if vertex_pair
not in visited_edges
:
1425 visited_edges
.add(vertex_pair
)
1426 if edge
.angle
> self
.angle_epsilon
:
1427 data_convex
.append(data_uvedge
)
1428 elif edge
.angle
< -self
.angle_epsilon
:
1429 data_concave
.append(data_uvedge
)
1430 if island
.is_inside_out
:
1431 data_convex
, data_concave
= data_concave
, data_convex
1434 print("<path class='freestyle' d='", rows(data_freestyle
), "'/>", file=f
)
1435 if (data_convex
or data_concave
) and not self
.pure_net
and self
.style
.use_inbg
:
1436 print("<path class='inner_background' d='", rows(data_convex
+ data_concave
), "'/>", file=f
)
1438 print("<path class='convex' d='", rows(data_convex
), "'/>", file=f
)
1440 print("<path class='concave' d='", rows(data_concave
), "'/>", file=f
)
1442 if not self
.pure_net
and self
.style
.use_outbg
:
1443 print("<path class='outer_background' d='", rows(data_outer
), "'/>", file=f
)
1444 print("<path class='outer' d='", rows(data_outer
), "'/>", file=f
)
1446 print(rows(data_markers
), file=f
)
1447 print("</g>", file=f
)
1449 if len(page
.islands
) > 1:
1450 print("</g>", file=f
)
1451 print("</svg>", file=f
)
1453 image_linked_tag
= "<image transform='translate({pos})' width='{width:.6f}' height='{height:.6f}' xlink:href='{path}'/>"
1454 image_embedded_tag
= "<image transform='translate({pos})' width='{width:.6f}' height='{height:.6f}' xlink:href='data:image/png;base64,"
1455 text_tag
= "<text transform='translate({x} {y})' style='font-size:{size:.2f}'><tspan>{label}</tspan></text>"
1456 text_transformed_tag
= "<text transform='matrix({mat} {pos})' style='font-size:{size:.2f}'><tspan>{label}</tspan></text>"
1457 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'/>" \
1458 "<text transform='translate({pos})' style='font-size:{scale:.2f}'><tspan>{index}</tspan></text></g>"
1460 svg_base
= """<?xml version='1.0' encoding='UTF-8' standalone='no'?>
1461 <svg xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' version='1.1'
1462 width='{width:.2f}mm' height='{height:.2f}mm' viewBox='0 0 {width:.2f} {height:.2f}'>"""
1464 css_base
= """<style type="text/css">
1467 stroke-linecap: butt;
1468 stroke-linejoin: bevel;
1469 stroke-dasharray: none;
1472 stroke: {outer_color};
1473 stroke-dasharray: {outer_style};
1474 stroke-dashoffset: 0;
1475 stroke-width: {outer_width:.2};
1476 stroke-opacity: {outer_alpha:.2};
1479 stroke: {convex_color};
1480 stroke-dasharray: {convex_style};
1481 stroke-dashoffset:0;
1482 stroke-width:{convex_width:.2};
1483 stroke-opacity: {convex_alpha:.2}
1486 stroke: {concave_color};
1487 stroke-dasharray: {concave_style};
1488 stroke-dashoffset: 0;
1489 stroke-width: {concave_width:.2};
1490 stroke-opacity: {concave_alpha:.2}
1493 stroke: {freestyle_color};
1494 stroke-dasharray: {freestyle_style};
1495 stroke-dashoffset: 0;
1496 stroke-width: {freestyle_width:.2};
1497 stroke-opacity: {freestyle_alpha:.2}
1499 path.outer_background {{
1500 stroke: {outbg_color};
1501 stroke-opacity: {outbg_alpha};
1502 stroke-width: {outbg_width:.2}
1504 path.inner_background {{
1505 stroke: {inbg_color};
1506 stroke-opacity: {inbg_alpha};
1507 stroke-width: {inbg_width:.2}
1510 fill: {sticker_fill};
1512 fill-opacity: {sticker_alpha:.2};
1520 fill-opacity: {text_alpha:.2};
1530 """Simple PDF exporter"""
1532 mm_to_pt
= 72 / 25.4
1533 character_width_packed
= {
1534 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·ÌÍÎÏìíîï',
1535 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¿ßø',
1536 667: '&ABEKPSVXY\x8a\x9fÀÁÂÃÄÅÈÉÊËÝÞ', 722: 'CDHNRUwÇÐÑÙÚÛÜ', 737: '©®', 778: 'GOQÒÓÔÕÖØ', 833: 'Mm¼½¾', 889: '%æ', 944: 'W\x9c', 1000: '\x85\x89\x8c\x97\x99Æ', 1015: '@', }
1537 character_width
= {c
: value
for (value
, chars
) in character_width_packed
.items() for c
in chars
}
1539 def __init__(self
, page_size
: M
.Vector
, style
, margin
, pure_net
=True, angle_epsilon
=0.01):
1540 self
.page_size
= page_size
1542 self
.margin
= M
.Vector((margin
, margin
))
1543 self
.pure_net
= pure_net
1544 self
.angle_epsilon
= angle_epsilon
1546 def text_width(self
, text
, scale
=None):
1547 return (scale
or self
.text_size
) * sum(self
.character_width
.get(c
, 556) for c
in text
) / 1000
1550 def encode_image(cls
, bpy_image
):
1551 data
= bytes(int(255 * px
) for (i
, px
) in enumerate(bpy_image
.pixels
) if i
% 4 != 3)
1553 "Type": "XObject", "Subtype": "Image", "Width": bpy_image
.size
[0], "Height": bpy_image
.size
[1],
1554 "ColorSpace": "DeviceRGB", "BitsPerComponent": 8, "Interpolate": True,
1555 "Filter": ["ASCII85Decode", "FlateDecode"], "stream": data
}
1558 def write(self
, mesh
, filename
):
1559 def format_dict(obj
, refs
=tuple()):
1560 return "<< " + "".join("/{} {}\n".format(key
, format_value(value
, refs
)) for (key
, value
) in obj
.items()) + ">>"
1562 def line_through(seq
):
1563 return "".join("{0.x:.6f} {0.y:.6f} {1} ".format(1000*v
.co
, c
) for (v
, c
) in zip(seq
, chain("m", repeat("l"))))
1565 def format_value(value
, refs
=tuple()):
1567 return "{} 0 R".format(refs
.index(value
) + 1)
1568 elif type(value
) is dict:
1569 return format_dict(value
, refs
)
1570 elif type(value
) in (list, tuple):
1571 return "[ " + " ".join(format_value(item
, refs
) for item
in value
) + " ]"
1572 elif type(value
) is int:
1574 elif type(value
) is float:
1575 return "{:.6f}".format(value
)
1576 elif type(value
) is bool:
1577 return "true" if value
else "false"
1579 return "/{}".format(value
) # this script can output only PDF names, no strings
1581 def write_object(index
, obj
, refs
, f
, stream
=None):
1582 byte_count
= f
.write("{} 0 obj\n".format(index
))
1583 if type(obj
) is not dict:
1584 stream
, obj
= obj
, dict()
1585 elif "stream" in obj
:
1586 stream
= obj
.pop("stream")
1588 if True or type(stream
) is bytes
:
1589 obj
["Filter"] = ["ASCII85Decode", "FlateDecode"]
1590 stream
= encode(stream
)
1591 obj
["Length"] = len(stream
)
1592 byte_count
+= f
.write(format_dict(obj
, refs
))
1594 byte_count
+= f
.write("\nstream\n")
1595 byte_count
+= f
.write(stream
)
1596 byte_count
+= f
.write("\nendstream")
1597 return byte_count
+ f
.write("\nendobj\n")
1600 from base64
import a85encode
1601 from zlib
import compress
1602 if hasattr(data
, "encode"):
1603 data
= data
.encode()
1604 return a85encode(compress(data
), adobe
=True, wrapcol
=250)[2:].decode()
1606 page_size_pt
= 1000 * self
.mm_to_pt
* self
.page_size
1607 root
= {"Type": "Pages", "MediaBox": [0, 0, page_size_pt
.x
, page_size_pt
.y
], "Kids": list()}
1608 catalog
= {"Type": "Catalog", "Pages": root
}
1610 "Type": "Font", "Subtype": "Type1", "Name": "F1",
1611 "BaseFont": "Helvetica", "Encoding": "MacRomanEncoding"}
1613 dl
= [length
* self
.style
.line_width
* 1000 for length
in (1, 4, 9)]
1615 'SOLID': list(), 'DOT': [dl
[0], dl
[1]], 'DASH': [dl
[1], dl
[2]],
1616 'LONGDASH': [dl
[2], dl
[1]], 'DASHDOT': [dl
[2], dl
[1], dl
[0], dl
[1]]}
1618 "Gtext": {"ca": self
.style
.text_color
[3], "Font": [font
, 1000 * self
.text_size
]},
1619 "Gsticker": {"ca": self
.style
.sticker_fill
[3]}}
1620 for name
in ("outer", "convex", "concave", "freestyle"):
1622 "LW": self
.style
.line_width
* 1000 * getattr(self
.style
, name
+ "_width"),
1623 "CA": getattr(self
.style
, name
+ "_color")[3],
1624 "D": [format_style
[getattr(self
.style
, name
+ "_style")], 0]}
1625 styles
["G" + name
] = gs
1626 for name
in ("outbg", "inbg"):
1628 "LW": self
.style
.line_width
* 1000 * getattr(self
.style
, name
+ "_width"),
1629 "CA": getattr(self
.style
, name
+ "_color")[3],
1630 "D": [format_style
['SOLID'], 0]}
1631 styles
["G" + name
] = gs
1633 objects
= [root
, catalog
, font
]
1634 objects
.extend(styles
.values())
1636 for page
in mesh
.pages
:
1637 commands
= ["{0:.6f} 0 0 {0:.6f} 0 0 cm".format(self
.mm_to_pt
)]
1638 resources
= {"Font": {"F1": font
}, "ExtGState": styles
, "XObject": dict()}
1639 for island
in page
.islands
:
1640 commands
.append("q 1 0 0 1 {0.x:.6f} {0.y:.6f} cm".format(1000*(self
.margin
+ island
.pos
)))
1641 if island
.embedded_image
:
1642 identifier
= "Im{}".format(len(resources
["XObject"]) + 1)
1643 commands
.append(self
.command_image
.format(1000 * island
.bounding_box
, identifier
))
1644 objects
.append(island
.embedded_image
)
1645 resources
["XObject"][identifier
] = island
.embedded_image
1648 commands
.append(self
.command_label
.format(
1649 size
=1000*self
.text_size
,
1650 x
=500 * (island
.bounding_box
.x
- self
.text_width(island
.title
)),
1651 y
=1000 * 0.2 * self
.text_size
,
1652 label
=island
.title
))
1654 data_markers
, data_stickerfill
, data_outer
, data_convex
, data_concave
, data_freestyle
= (list() for i
in range(6))
1655 for marker
in island
.markers
:
1656 if isinstance(marker
, Sticker
):
1657 data_stickerfill
.append(line_through(marker
.vertices
) + "f")
1659 data_markers
.append(self
.command_sticker
.format(
1661 pos
=1000*marker
.center
,
1663 align
=-500 * self
.text_width(marker
.text
, marker
.width
),
1664 size
=1000*marker
.width
))
1665 elif isinstance(marker
, Arrow
):
1666 size
= 1000 * marker
.size
1667 position
= 1000 * (marker
.center
+ marker
.size
* marker
.rot
@ M
.Vector((0, -0.9)))
1668 data_markers
.append(self
.command_arrow
.format(
1670 arrow_pos
=1000 * marker
.center
,
1671 pos
=position
- 1000 * M
.Vector((0.5 * self
.text_width(marker
.text
), 0.4 * self
.text_size
)),
1672 mat
=size
* marker
.rot
,
1674 elif isinstance(marker
, NumberAlone
):
1675 data_markers
.append(self
.command_number
.format(
1677 pos
=1000*marker
.center
,
1679 size
=1000*marker
.size
))
1681 outer_edges
= set(island
.boundary
)
1684 uvedge
= outer_edges
.pop()
1687 data_loop
.extend(uvedge
.sticker
.vertices
[1:])
1689 vertex
= uvedge
.vb
if uvedge
.uvface
.flipped
else uvedge
.va
1690 data_loop
.append(vertex
)
1691 uvedge
= uvedge
.neighbor_right
1693 outer_edges
.remove(uvedge
)
1696 data_outer
.append(line_through(data_loop
) + "s")
1698 for loop
, uvedge
in island
.edges
.items():
1699 edge
= mesh
.edges
[loop
.edge
]
1700 if edge
.is_cut(uvedge
.uvface
.face
) and not uvedge
.sticker
:
1702 data_uvedge
= line_through((uvedge
.va
, uvedge
.vb
)) + "S"
1704 data_freestyle
.append(data_uvedge
)
1705 # each uvedge exists in two opposite-oriented variants; we want to add each only once
1706 if uvedge
.sticker
or uvedge
.uvface
.flipped
!= (id(uvedge
.va
) > id(uvedge
.vb
)):
1707 if edge
.angle
> self
.angle_epsilon
:
1708 data_convex
.append(data_uvedge
)
1709 elif edge
.angle
< -self
.angle_epsilon
:
1710 data_concave
.append(data_uvedge
)
1711 if island
.is_inside_out
:
1712 data_convex
, data_concave
= data_concave
, data_convex
1714 if data_stickerfill
and self
.style
.sticker_fill
[3] > 0:
1715 commands
.append("/Gsticker gs {0[0]:.3f} {0[1]:.3f} {0[2]:.3f} rg".format(self
.style
.sticker_fill
))
1716 commands
.extend(data_stickerfill
)
1718 commands
.append("/Gfreestyle gs {0[0]:.3f} {0[1]:.3f} {0[2]:.3f} RG".format(self
.style
.freestyle_color
))
1719 commands
.extend(data_freestyle
)
1720 if (data_convex
or data_concave
) and not self
.pure_net
and self
.style
.use_inbg
:
1721 commands
.append("/Ginbg gs {0[0]:.3f} {0[1]:.3f} {0[2]:.3f} RG".format(self
.style
.inbg_color
))
1722 commands
.extend(chain(data_convex
, data_concave
))
1724 commands
.append("/Gconvex gs {0[0]:.3f} {0[1]:.3f} {0[2]:.3f} RG".format(self
.style
.convex_color
))
1725 commands
.extend(data_convex
)
1727 commands
.append("/Gconcave gs {0[0]:.3f} {0[1]:.3f} {0[2]:.3f} RG".format(self
.style
.concave_color
))
1728 commands
.extend(data_concave
)
1730 if not self
.pure_net
and self
.style
.use_outbg
:
1731 commands
.append("/Goutbg gs {0[0]:.3f} {0[1]:.3f} {0[2]:.3f} RG".format(self
.style
.outbg_color
))
1732 commands
.extend(data_outer
)
1733 commands
.append("/Gouter gs {0[0]:.3f} {0[1]:.3f} {0[2]:.3f} RG".format(self
.style
.outer_color
))
1734 commands
.extend(data_outer
)
1735 commands
.append("/Gtext gs {0[0]:.3f} {0[1]:.3f} {0[2]:.3f} rg".format(self
.style
.text_color
))
1736 commands
.extend(data_markers
)
1737 commands
.append("Q")
1738 content
= "\n".join(commands
)
1739 page
= {"Type": "Page", "Parent": root
, "Contents": content
, "Resources": resources
}
1740 root
["Kids"].append(page
)
1741 objects
.extend((page
, content
))
1743 root
["Count"] = len(root
["Kids"])
1744 with
open(filename
, "w+") as f
:
1746 position
= f
.write("%PDF-1.4\n")
1747 for index
, obj
in enumerate(objects
, 1):
1748 xref_table
.append(position
)
1749 position
+= write_object(index
, obj
, objects
, f
)
1751 f
.write("xref_table\n0 {}\n".format(len(xref_table
) + 1))
1752 f
.write("{:010} {:05} f\n".format(0, 65536))
1753 for position
in xref_table
:
1754 f
.write("{:010} {:05} n\n".format(position
, 0))
1755 f
.write("trailer\n")
1756 f
.write(format_dict({"Size": len(xref_table
), "Root": catalog
}, objects
))
1757 f
.write("\nstartxref\n{}\n%%EOF\n".format(xref_pos
))
1759 command_label
= "/Gtext gs BT {x:.6f} {y:.6f} Td ({label}) Tj ET"
1760 command_image
= "q {0.x:.6f} 0 0 {0.y:.6f} 0 0 cm 1 0 0 -1 0 1 cm /{1} Do Q"
1761 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"
1762 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"
1763 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"
1766 class Unfold(bpy
.types
.Operator
):
1767 """Blender Operator: unfold the selected object."""
1769 bl_idname
= "mesh.unfold"
1771 bl_description
= "Mark seams so that the mesh can be exported as a paper model"
1772 bl_options
= {'REGISTER', 'UNDO'}
1773 edit
: bpy
.props
.BoolProperty(default
=False, options
={'HIDDEN'})
1774 priority_effect_convex
: bpy
.props
.FloatProperty(
1775 name
="Priority Convex", description
="Priority effect for edges in convex angles",
1776 default
=default_priority_effect
['CONVEX'], soft_min
=-1, soft_max
=10, subtype
='FACTOR')
1777 priority_effect_concave
: bpy
.props
.FloatProperty(
1778 name
="Priority Concave", description
="Priority effect for edges in concave angles",
1779 default
=default_priority_effect
['CONCAVE'], soft_min
=-1, soft_max
=10, subtype
='FACTOR')
1780 priority_effect_length
: bpy
.props
.FloatProperty(
1781 name
="Priority Length", description
="Priority effect of edge length",
1782 default
=default_priority_effect
['LENGTH'], soft_min
=-10, soft_max
=1, subtype
='FACTOR')
1783 do_create_uvmap
: bpy
.props
.BoolProperty(
1784 name
="Create UVMap", description
="Create a new UV Map showing the islands and page layout", default
=False)
1788 def poll(cls
, context
):
1789 return context
.active_object
and context
.active_object
.type == "MESH"
1791 def draw(self
, context
):
1792 layout
= self
.layout
1793 col
= layout
.column()
1794 col
.active
= not self
.object or len(self
.object.data
.uv_layers
) < 8
1795 col
.prop(self
.properties
, "do_create_uvmap")
1796 layout
.label(text
="Edge Cutting Factors:")
1797 col
= layout
.column(align
=True)
1798 col
.label(text
="Face Angle:")
1799 col
.prop(self
.properties
, "priority_effect_convex", text
="Convex")
1800 col
.prop(self
.properties
, "priority_effect_concave", text
="Concave")
1801 layout
.prop(self
.properties
, "priority_effect_length", text
="Edge Length")
1803 def execute(self
, context
):
1804 sce
= bpy
.context
.scene
1805 settings
= sce
.paper_model
1806 recall_mode
= context
.object.mode
1807 bpy
.ops
.object.mode_set(mode
='EDIT')
1809 self
.object = context
.object
1811 cage_size
= M
.Vector((settings
.output_size_x
, settings
.output_size_y
))
1813 'CONVEX': self
.priority_effect_convex
,
1814 'CONCAVE': self
.priority_effect_concave
,
1815 'LENGTH': self
.priority_effect_length
}
1817 unfolder
= Unfolder(self
.object)
1818 unfolder
.do_create_uvmap
= self
.do_create_uvmap
1819 scale
= sce
.unit_settings
.scale_length
/ settings
.scale
1820 unfolder
.prepare(cage_size
, priority_effect
, scale
, settings
.limit_by_page
)
1821 unfolder
.mesh
.mark_cuts()
1822 except UnfoldError
as error
:
1823 self
.report(type={'ERROR_INVALID_INPUT'}, message
=error
.args
[0])
1825 bpy
.ops
.object.mode_set(mode
=recall_mode
)
1826 return {'CANCELLED'}
1827 mesh
= self
.object.data
1829 if mesh
.paper_island_list
:
1830 unfolder
.copy_island_names(mesh
.paper_island_list
)
1831 island_list
= mesh
.paper_island_list
1832 attributes
= {item
.label
: (item
.abbreviation
, item
.auto_label
, item
.auto_abbrev
) for item
in island_list
}
1833 island_list
.clear() # remove previously defined islands
1834 for island
in unfolder
.mesh
.islands
:
1835 # add islands to UI list and set default descriptions
1836 list_item
= island_list
.add()
1837 # add faces' IDs to the island
1838 for face
in island
.faces
:
1839 lface
= list_item
.faces
.add()
1840 lface
.id = face
.index
1841 list_item
["label"] = island
.label
1842 list_item
["abbreviation"], list_item
["auto_label"], list_item
["auto_abbrev"] = attributes
.get(
1844 (island
.abbreviation
, True, True))
1845 island_item_changed(list_item
, context
)
1846 mesh
.paper_island_index
= -1
1849 bpy
.ops
.object.mode_set(mode
=recall_mode
)
1853 class ClearAllSeams(bpy
.types
.Operator
):
1854 """Blender Operator: clear all seams of the active Mesh and all its unfold data"""
1856 bl_idname
= "mesh.clear_all_seams"
1857 bl_label
= "Clear All Seams"
1858 bl_description
= "Clear all the seams and unfolded islands of the active object"
1861 def poll(cls
, context
):
1862 return context
.active_object
and context
.active_object
.type == 'MESH'
1864 def execute(self
, context
):
1865 ob
= context
.active_object
1868 for edge
in mesh
.edges
:
1869 edge
.use_seam
= False
1870 mesh
.paper_island_list
.clear()
1875 def page_size_preset_changed(self
, context
):
1876 """Update the actual document size to correct values"""
1877 if hasattr(self
, "limit_by_page") and not self
.limit_by_page
:
1879 if self
.page_size_preset
== 'A4':
1880 self
.output_size_x
= 0.210
1881 self
.output_size_y
= 0.297
1882 elif self
.page_size_preset
== 'A3':
1883 self
.output_size_x
= 0.297
1884 self
.output_size_y
= 0.420
1885 elif self
.page_size_preset
== 'US_LETTER':
1886 self
.output_size_x
= 0.216
1887 self
.output_size_y
= 0.279
1888 elif self
.page_size_preset
== 'US_LEGAL':
1889 self
.output_size_x
= 0.216
1890 self
.output_size_y
= 0.356
1893 class PaperModelStyle(bpy
.types
.PropertyGroup
):
1895 ('SOLID', "Solid (----)", "Solid line"),
1896 ('DOT', "Dots (. . .)", "Dotted line"),
1897 ('DASH', "Short Dashes (- - -)", "Solid line"),
1898 ('LONGDASH', "Long Dashes (-- --)", "Solid line"),
1899 ('DASHDOT', "Dash-dotted (-- .)", "Solid line")
1901 outer_color
: bpy
.props
.FloatVectorProperty(
1902 name
="Outer Lines", description
="Color of net outline",
1903 default
=(0.0, 0.0, 0.0, 1.0), min=0, max=1, subtype
='COLOR', size
=4)
1904 outer_style
: bpy
.props
.EnumProperty(
1905 name
="Outer Lines Drawing Style", description
="Drawing style of net outline",
1906 default
='SOLID', items
=line_styles
)
1907 line_width
: bpy
.props
.FloatProperty(
1908 name
="Base Lines Thickness", description
="Base thickness of net lines, each actual value is a multiple of this length",
1909 default
=1e-4, min=0, soft_max
=5e-3, precision
=5, step
=1e-2, subtype
="UNSIGNED", unit
="LENGTH")
1910 outer_width
: bpy
.props
.FloatProperty(
1911 name
="Outer Lines Thickness", description
="Relative thickness of net outline",
1912 default
=3, min=0, soft_max
=10, precision
=1, step
=10, subtype
='FACTOR')
1913 use_outbg
: bpy
.props
.BoolProperty(
1914 name
="Highlight Outer Lines", description
="Add another line below every line to improve contrast",
1916 outbg_color
: bpy
.props
.FloatVectorProperty(
1917 name
="Outer Highlight", description
="Color of the highlight for outer lines",
1918 default
=(1.0, 1.0, 1.0, 1.0), min=0, max=1, subtype
='COLOR', size
=4)
1919 outbg_width
: bpy
.props
.FloatProperty(
1920 name
="Outer Highlight Thickness", description
="Relative thickness of the highlighting lines",
1921 default
=5, min=0, soft_max
=10, precision
=1, step
=10, subtype
='FACTOR')
1923 convex_color
: bpy
.props
.FloatVectorProperty(
1924 name
="Inner Convex Lines", description
="Color of lines to be folded to a convex angle",
1925 default
=(0.0, 0.0, 0.0, 1.0), min=0, max=1, subtype
='COLOR', size
=4)
1926 convex_style
: bpy
.props
.EnumProperty(
1927 name
="Convex Lines Drawing Style", description
="Drawing style of lines to be folded to a convex angle",
1928 default
='DASH', items
=line_styles
)
1929 convex_width
: bpy
.props
.FloatProperty(
1930 name
="Convex Lines Thickness", description
="Relative thickness of concave lines",
1931 default
=2, min=0, soft_max
=10, precision
=1, step
=10, subtype
='FACTOR')
1932 concave_color
: bpy
.props
.FloatVectorProperty(
1933 name
="Inner Concave Lines", description
="Color of lines to be folded to a concave angle",
1934 default
=(0.0, 0.0, 0.0, 1.0), min=0, max=1, subtype
='COLOR', size
=4)
1935 concave_style
: bpy
.props
.EnumProperty(
1936 name
="Concave Lines Drawing Style", description
="Drawing style of lines to be folded to a concave angle",
1937 default
='DASHDOT', items
=line_styles
)
1938 concave_width
: bpy
.props
.FloatProperty(
1939 name
="Concave Lines Thickness", description
="Relative thickness of concave lines",
1940 default
=2, min=0, soft_max
=10, precision
=1, step
=10, subtype
='FACTOR')
1941 freestyle_color
: bpy
.props
.FloatVectorProperty(
1942 name
="Freestyle Edges", description
="Color of lines marked as Freestyle Edge",
1943 default
=(0.0, 0.0, 0.0, 1.0), min=0, max=1, subtype
='COLOR', size
=4)
1944 freestyle_style
: bpy
.props
.EnumProperty(
1945 name
="Freestyle Edges Drawing Style", description
="Drawing style of Freestyle Edges",
1946 default
='SOLID', items
=line_styles
)
1947 freestyle_width
: bpy
.props
.FloatProperty(
1948 name
="Freestyle Edges Thickness", description
="Relative thickness of Freestyle edges",
1949 default
=2, min=0, soft_max
=10, precision
=1, step
=10, subtype
='FACTOR')
1950 use_inbg
: bpy
.props
.BoolProperty(
1951 name
="Highlight Inner Lines", description
="Add another line below every line to improve contrast",
1953 inbg_color
: bpy
.props
.FloatVectorProperty(
1954 name
="Inner Highlight", description
="Color of the highlight for inner lines",
1955 default
=(1.0, 1.0, 1.0, 1.0), min=0, max=1, subtype
='COLOR', size
=4)
1956 inbg_width
: bpy
.props
.FloatProperty(
1957 name
="Inner Highlight Thickness", description
="Relative thickness of the highlighting lines",
1958 default
=2, min=0, soft_max
=10, precision
=1, step
=10, subtype
='FACTOR')
1960 sticker_fill
: bpy
.props
.FloatVectorProperty(
1961 name
="Tabs Fill", description
="Fill color of sticking tabs",
1962 default
=(0.9, 0.9, 0.9, 1.0), min=0, max=1, subtype
='COLOR', size
=4)
1963 text_color
: bpy
.props
.FloatVectorProperty(
1964 name
="Text Color", description
="Color of all text used in the document",
1965 default
=(0.0, 0.0, 0.0, 1.0), min=0, max=1, subtype
='COLOR', size
=4)
1966 bpy
.utils
.register_class(PaperModelStyle
)
1969 class ExportPaperModel(bpy
.types
.Operator
):
1970 """Blender Operator: save the selected object's net and optionally bake its texture"""
1972 bl_idname
= "export_mesh.paper_model"
1973 bl_label
= "Export Paper Model"
1974 bl_description
= "Export the selected object's net and optionally bake its texture"
1975 filepath
: bpy
.props
.StringProperty(
1976 name
="File Path", description
="Target file to save the SVG", options
={'SKIP_SAVE'})
1977 filename
: bpy
.props
.StringProperty(
1978 name
="File Name", description
="Name of the file", options
={'SKIP_SAVE'})
1979 directory
: bpy
.props
.StringProperty(
1980 name
="Directory", description
="Directory of the file", options
={'SKIP_SAVE'})
1981 page_size_preset
: bpy
.props
.EnumProperty(
1982 name
="Page Size", description
="Size of the exported document",
1983 default
='A4', update
=page_size_preset_changed
, items
=global_paper_sizes
)
1984 output_size_x
: bpy
.props
.FloatProperty(
1985 name
="Page Width", description
="Width of the exported document",
1986 default
=0.210, soft_min
=0.105, soft_max
=0.841, subtype
="UNSIGNED", unit
="LENGTH")
1987 output_size_y
: bpy
.props
.FloatProperty(
1988 name
="Page Height", description
="Height of the exported document",
1989 default
=0.297, soft_min
=0.148, soft_max
=1.189, subtype
="UNSIGNED", unit
="LENGTH")
1990 output_margin
: bpy
.props
.FloatProperty(
1991 name
="Page Margin", description
="Distance from page borders to the printable area",
1992 default
=0.005, min=0, soft_max
=0.1, step
=0.1, subtype
="UNSIGNED", unit
="LENGTH")
1993 output_type
: bpy
.props
.EnumProperty(
1994 name
="Textures", description
="Source of a texture for the model",
1995 default
='NONE', items
=[
1996 ('NONE', "No Texture", "Export the net only"),
1997 ('TEXTURE', "From Materials", "Render the diffuse color and all painted textures"),
1998 ('AMBIENT_OCCLUSION', "Ambient Occlusion", "Render the Ambient Occlusion pass"),
1999 ('RENDER', "Full Render", "Render the material in actual scene illumination"),
2000 ('SELECTED_TO_ACTIVE', "Selected to Active", "Render all selected surrounding objects as a texture")
2002 do_create_stickers
: bpy
.props
.BoolProperty(
2003 name
="Create Tabs", description
="Create gluing tabs around the net (useful for paper)",
2005 do_create_numbers
: bpy
.props
.BoolProperty(
2006 name
="Create Numbers", description
="Enumerate edges to make it clear which edges should be sticked together",
2008 sticker_width
: bpy
.props
.FloatProperty(
2009 name
="Tabs and Text Size", description
="Width of gluing tabs and their numbers",
2010 default
=0.005, soft_min
=0, soft_max
=0.05, step
=0.1, subtype
="UNSIGNED", unit
="LENGTH")
2011 angle_epsilon
: bpy
.props
.FloatProperty(
2012 name
="Hidden Edge Angle", description
="Folds with angle below this limit will not be drawn",
2013 default
=pi
/360, min=0, soft_max
=pi
/4, step
=0.01, subtype
="ANGLE", unit
="ROTATION")
2014 output_dpi
: bpy
.props
.FloatProperty(
2015 name
="Resolution (DPI)", description
="Resolution of images in pixels per inch",
2016 default
=90, min=1, soft_min
=30, soft_max
=600, subtype
="UNSIGNED")
2017 bake_samples
: bpy
.props
.IntProperty(
2018 name
="Samples", description
="Number of samples to render for each pixel",
2019 default
=64, min=1, subtype
="UNSIGNED")
2020 file_format
: bpy
.props
.EnumProperty(
2021 name
="Document Format", description
="File format of the exported net",
2022 default
='PDF', items
=[
2023 ('PDF', "PDF", "Adobe Portable Document Format 1.4"),
2024 ('SVG', "SVG", "W3C Scalable Vector Graphics"),
2026 image_packing
: bpy
.props
.EnumProperty(
2027 name
="Image Packing Method", description
="Method of attaching baked image(s) to the SVG",
2028 default
='ISLAND_EMBED', items
=[
2029 ('PAGE_LINK', "Single Linked", "Bake one image per page of output and save it separately"),
2030 ('ISLAND_LINK', "Linked", "Bake images separately for each island and save them in a directory"),
2031 ('ISLAND_EMBED', "Embedded", "Bake images separately for each island and embed them into the SVG")
2033 scale
: bpy
.props
.FloatProperty(
2034 name
="Scale", description
="Divisor of all dimensions when exporting",
2035 default
=1, soft_min
=1.0, soft_max
=100.0, subtype
='FACTOR', precision
=1)
2036 do_create_uvmap
: bpy
.props
.BoolProperty(
2037 name
="Create UVMap", description
="Create a new UV Map showing the islands and page layout",
2038 default
=False, options
={'SKIP_SAVE'})
2039 ui_expanded_document
: bpy
.props
.BoolProperty(
2040 name
="Show Document Settings Expanded", description
="Shows the box 'Document Settings' expanded in user interface",
2041 default
=True, options
={'SKIP_SAVE'})
2042 ui_expanded_style
: bpy
.props
.BoolProperty(
2043 name
="Show Style Settings Expanded", description
="Shows the box 'Colors and Style' expanded in user interface",
2044 default
=False, options
={'SKIP_SAVE'})
2045 style
: bpy
.props
.PointerProperty(type=PaperModelStyle
)
2050 def poll(cls
, context
):
2051 return context
.active_object
and context
.active_object
.type == 'MESH'
2053 def prepare(self
, context
):
2055 self
.recall_mode
= context
.object.mode
2056 bpy
.ops
.object.mode_set(mode
='EDIT')
2058 self
.object = context
.active_object
2059 self
.unfolder
= Unfolder(self
.object)
2060 cage_size
= M
.Vector((sce
.paper_model
.output_size_x
, sce
.paper_model
.output_size_y
))
2061 self
.unfolder
.prepare(cage_size
, scale
=sce
.unit_settings
.scale_length
/self
.scale
, limit_by_page
=sce
.paper_model
.limit_by_page
)
2063 self
.scale
= ceil(self
.get_scale_ratio(sce
))
2068 bpy
.ops
.object.mode_set(mode
=self
.recall_mode
)
2070 def invoke(self
, context
, event
):
2071 self
.scale
= context
.scene
.paper_model
.scale
2073 self
.prepare(context
)
2074 except UnfoldError
as error
:
2075 self
.report(type={'ERROR_INVALID_INPUT'}, message
=error
.args
[0])
2078 return {'CANCELLED'}
2079 wm
= context
.window_manager
2080 wm
.fileselect_add(self
)
2081 return {'RUNNING_MODAL'}
2083 def execute(self
, context
):
2084 if not self
.unfolder
:
2085 self
.prepare(context
)
2086 self
.unfolder
.do_create_uvmap
= self
.do_create_uvmap
2088 if self
.object.data
.paper_island_list
:
2089 self
.unfolder
.copy_island_names(self
.object.data
.paper_island_list
)
2090 self
.unfolder
.save(self
.properties
)
2091 self
.report({'INFO'}, "Saved a {}-page document".format(len(self
.unfolder
.mesh
.pages
)))
2093 except UnfoldError
as error
:
2094 self
.report(type={'ERROR_INVALID_INPUT'}, message
=error
.args
[0])
2095 return {'CANCELLED'}
2099 def get_scale_ratio(self
, sce
):
2100 margin
= self
.output_margin
+ self
.sticker_width
2101 if min(self
.output_size_x
, self
.output_size_y
) <= 2 * margin
:
2103 output_inner_size
= M
.Vector((self
.output_size_x
- 2*margin
, self
.output_size_y
- 2*margin
))
2104 ratio
= self
.unfolder
.mesh
.largest_island_ratio(output_inner_size
)
2105 return ratio
* sce
.unit_settings
.scale_length
/ self
.scale
2107 def draw(self
, context
):
2108 layout
= self
.layout
2110 layout
.prop(self
.properties
, "do_create_uvmap")
2112 row
= layout
.row(align
=True)
2113 row
.menu("VIEW3D_MT_paper_model_presets", text
=bpy
.types
.VIEW3D_MT_paper_model_presets
.bl_label
)
2114 row
.operator("export_mesh.paper_model_preset_add", text
="", icon
='ADD')
2115 row
.operator("export_mesh.paper_model_preset_add", text
="", icon
='REMOVE').remove_active
= True
2117 layout
.prop(self
.properties
, "scale", text
="Scale: 1/")
2118 scale_ratio
= self
.get_scale_ratio(context
.scene
)
2121 text
="An island is roughly {:.1f}x bigger than page".format(scale_ratio
),
2123 elif scale_ratio
> 0:
2124 layout
.label(text
="Largest island is roughly 1/{:.1f} of page".format(1 / scale_ratio
))
2126 if context
.scene
.unit_settings
.scale_length
!= 1:
2128 text
="Unit scale {:.1f} makes page size etc. not display correctly".format(
2129 context
.scene
.unit_settings
.scale_length
), icon
="ERROR")
2131 row
= box
.row(align
=True)
2133 self
.properties
, "ui_expanded_document", text
="",
2134 icon
=('TRIA_DOWN' if self
.ui_expanded_document
else 'TRIA_RIGHT'), emboss
=False)
2135 row
.label(text
="Document Settings")
2137 if self
.ui_expanded_document
:
2138 box
.prop(self
.properties
, "file_format", text
="Format")
2139 box
.prop(self
.properties
, "page_size_preset")
2140 col
= box
.column(align
=True)
2141 col
.active
= self
.page_size_preset
== 'USER'
2142 col
.prop(self
.properties
, "output_size_x")
2143 col
.prop(self
.properties
, "output_size_y")
2144 box
.prop(self
.properties
, "output_margin")
2146 col
.prop(self
.properties
, "do_create_stickers")
2147 col
.prop(self
.properties
, "do_create_numbers")
2149 col
.active
= self
.do_create_stickers
or self
.do_create_numbers
2150 col
.prop(self
.properties
, "sticker_width")
2151 box
.prop(self
.properties
, "angle_epsilon")
2153 box
.prop(self
.properties
, "output_type")
2155 col
.active
= (self
.output_type
!= 'NONE')
2156 if len(self
.object.data
.uv_layers
) == 8:
2157 col
.label(text
="No UV slots left, No Texture is the only option.", icon
='ERROR')
2158 elif context
.scene
.render
.engine
!= 'CYCLES' and self
.output_type
!= 'NONE':
2159 col
.label(text
="Cycles will be used for texture baking.", icon
='ERROR')
2161 row
.active
= self
.output_type
in ('AMBIENT_OCCLUSION', 'RENDER', 'SELECTED_TO_ACTIVE')
2162 row
.prop(self
.properties
, "bake_samples")
2163 col
.prop(self
.properties
, "output_dpi")
2165 row
.active
= self
.file_format
== 'SVG'
2166 row
.prop(self
.properties
, "image_packing", text
="Images")
2169 row
= box
.row(align
=True)
2171 self
.properties
, "ui_expanded_style", text
="",
2172 icon
=('TRIA_DOWN' if self
.ui_expanded_style
else 'TRIA_RIGHT'), emboss
=False)
2173 row
.label(text
="Colors and Style")
2175 if self
.ui_expanded_style
:
2176 box
.prop(self
.style
, "line_width", text
="Default line width")
2178 col
.prop(self
.style
, "outer_color")
2179 col
.prop(self
.style
, "outer_width", text
="Relative width")
2180 col
.prop(self
.style
, "outer_style", text
="Style")
2182 col
.active
= self
.output_type
!= 'NONE'
2183 col
.prop(self
.style
, "use_outbg", text
="Outer Lines Highlight:")
2185 sub
.active
= self
.output_type
!= 'NONE' and self
.style
.use_outbg
2186 sub
.prop(self
.style
, "outbg_color", text
="")
2187 sub
.prop(self
.style
, "outbg_width", text
="Relative width")
2189 col
.prop(self
.style
, "convex_color")
2190 col
.prop(self
.style
, "convex_width", text
="Relative width")
2191 col
.prop(self
.style
, "convex_style", text
="Style")
2193 col
.prop(self
.style
, "concave_color")
2194 col
.prop(self
.style
, "concave_width", text
="Relative width")
2195 col
.prop(self
.style
, "concave_style", text
="Style")
2197 col
.prop(self
.style
, "freestyle_color")
2198 col
.prop(self
.style
, "freestyle_width", text
="Relative width")
2199 col
.prop(self
.style
, "freestyle_style", text
="Style")
2201 col
.active
= self
.output_type
!= 'NONE'
2202 col
.prop(self
.style
, "use_inbg", text
="Inner Lines Highlight:")
2204 sub
.active
= self
.output_type
!= 'NONE' and self
.style
.use_inbg
2205 sub
.prop(self
.style
, "inbg_color", text
="")
2206 sub
.prop(self
.style
, "inbg_width", text
="Relative width")
2208 col
.active
= self
.do_create_stickers
2209 col
.prop(self
.style
, "sticker_fill")
2210 box
.prop(self
.style
, "text_color")
2213 def menu_func_export(self
, context
):
2214 self
.layout
.operator("export_mesh.paper_model", text
="Paper Model (.pdf/.svg)")
2217 def menu_func_unfold(self
, context
):
2218 self
.layout
.operator("mesh.unfold", text
="Unfold")
2221 class SelectIsland(bpy
.types
.Operator
):
2222 """Blender Operator: select all faces of the active island"""
2224 bl_idname
= "mesh.select_paper_island"
2225 bl_label
= "Select Island"
2226 bl_description
= "Select an island of the paper model net"
2228 operation
: bpy
.props
.EnumProperty(
2229 name
="Operation", description
="Operation with the current selection",
2230 default
='ADD', items
=[
2231 ('ADD', "Add", "Add to current selection"),
2232 ('REMOVE', "Remove", "Remove from selection"),
2233 ('REPLACE', "Replace", "Select only the ")
2237 def poll(cls
, context
):
2238 return context
.active_object
and context
.active_object
.type == 'MESH' and context
.mode
== 'EDIT_MESH'
2240 def execute(self
, context
):
2241 ob
= context
.active_object
2243 bm
= bmesh
.from_edit_mesh(me
)
2244 island
= me
.paper_island_list
[me
.paper_island_index
]
2245 faces
= {face
.id for face
in island
.faces
}
2248 if self
.operation
== 'REPLACE':
2249 for face
in bm
.faces
:
2250 selected
= face
.index
in faces
2251 face
.select
= selected
2253 edges
.update(face
.edges
)
2254 verts
.update(face
.verts
)
2255 for edge
in bm
.edges
:
2256 edge
.select
= edge
in edges
2257 for vert
in bm
.verts
:
2258 vert
.select
= vert
in verts
2260 selected
= (self
.operation
== 'ADD')
2262 face
= bm
.faces
[index
]
2263 face
.select
= selected
2264 edges
.update(face
.edges
)
2265 verts
.update(face
.verts
)
2267 edge
.select
= any(face
.select
for face
in edge
.link_faces
)
2269 vert
.select
= any(edge
.select
for edge
in vert
.link_edges
)
2270 bmesh
.update_edit_mesh(me
, False, False)
2274 class VIEW3D_MT_paper_model_presets(bpy
.types
.Menu
):
2275 bl_label
= "Paper Model Presets"
2276 preset_subdir
= "export_mesh"
2277 preset_operator
= "script.execute_preset"
2278 draw
= bpy
.types
.Menu
.draw_preset
2281 class AddPresetPaperModel(bl_operators
.presets
.AddPresetBase
, bpy
.types
.Operator
):
2282 """Add or remove a Paper Model Preset"""
2283 bl_idname
= "export_mesh.paper_model_preset_add"
2284 bl_label
= "Add Paper Model Preset"
2285 preset_menu
= "VIEW3D_MT_paper_model_presets"
2286 preset_subdir
= "export_mesh"
2287 preset_defines
= ["op = bpy.context.active_operator"]
2290 def preset_values(self
):
2291 op
= bpy
.ops
.export_mesh
.paper_model
2292 properties
= op
.get_rna().bl_rna
.properties
.items()
2293 blacklist
= bpy
.types
.Operator
.bl_rna
.properties
.keys()
2295 "op.{}".format(prop_id
) for (prop_id
, prop
) in properties
2296 if not (prop
.is_hidden
or prop
.is_skip_save
or prop_id
in blacklist
)]
2299 class VIEW3D_PT_paper_model_tools(bpy
.types
.Panel
):
2300 bl_space_type
= 'VIEW_3D'
2301 bl_region_type
= 'UI'
2302 bl_category
= 'Paper'
2305 def draw(self
, context
):
2306 layout
= self
.layout
2308 obj
= context
.active_object
2309 mesh
= obj
.data
if obj
and obj
.type == 'MESH' else None
2311 layout
.operator("mesh.unfold")
2313 if context
.mode
== 'EDIT_MESH':
2314 row
= layout
.row(align
=True)
2315 row
.operator("mesh.mark_seam", text
="Mark Seam").clear
= False
2316 row
.operator("mesh.mark_seam", text
="Clear Seam").clear
= True
2318 layout
.operator("mesh.clear_all_seams")
2321 class VIEW3D_PT_paper_model_settings(bpy
.types
.Panel
):
2322 bl_space_type
= 'VIEW_3D'
2323 bl_region_type
= 'UI'
2324 bl_category
= 'Paper'
2327 def draw(self
, context
):
2328 layout
= self
.layout
2330 obj
= context
.active_object
2331 mesh
= obj
.data
if obj
and obj
.type == 'MESH' else None
2333 layout
.operator("export_mesh.paper_model")
2334 props
= sce
.paper_model
2335 layout
.prop(props
, "scale", text
="Model Scale: 1/")
2337 layout
.prop(props
, "limit_by_page")
2338 col
= layout
.column()
2339 col
.active
= props
.limit_by_page
2340 col
.prop(props
, "page_size_preset")
2341 sub
= col
.column(align
=True)
2342 sub
.active
= props
.page_size_preset
== 'USER'
2343 sub
.prop(props
, "output_size_x")
2344 sub
.prop(props
, "output_size_y")
2347 class DATA_PT_paper_model_islands(bpy
.types
.Panel
):
2348 bl_space_type
= 'PROPERTIES'
2349 bl_region_type
= 'WINDOW'
2351 bl_label
= "Paper Model Islands"
2352 COMPAT_ENGINES
= {'BLENDER_RENDER', 'BLENDER_EEVEE', 'BLENDER_WORKBENCH'}
2354 def draw(self
, context
):
2355 layout
= self
.layout
2357 obj
= context
.active_object
2358 mesh
= obj
.data
if obj
and obj
.type == 'MESH' else None
2360 layout
.operator("mesh.unfold", icon
='FILE_REFRESH')
2361 if mesh
and mesh
.paper_island_list
:
2363 text
="1 island:" if len(mesh
.paper_island_list
) == 1 else
2364 "{} islands:".format(len(mesh
.paper_island_list
)))
2365 layout
.template_list(
2366 'UI_UL_list', 'paper_model_island_list', mesh
,
2367 'paper_island_list', mesh
, 'paper_island_index', rows
=1, maxrows
=5)
2368 sub
= layout
.split(align
=True)
2369 sub
.operator("mesh.select_paper_island", text
="Select").operation
= 'ADD'
2370 sub
.operator("mesh.select_paper_island", text
="Deselect").operation
= 'REMOVE'
2371 sub
.prop(sce
.paper_model
, "sync_island", icon
='UV_SYNC_SELECT', toggle
=True)
2372 if mesh
.paper_island_index
>= 0:
2373 list_item
= mesh
.paper_island_list
[mesh
.paper_island_index
]
2374 sub
= layout
.column(align
=True)
2375 sub
.prop(list_item
, "auto_label")
2376 sub
.prop(list_item
, "label")
2377 sub
.prop(list_item
, "auto_abbrev")
2379 row
.active
= not list_item
.auto_abbrev
2380 row
.prop(list_item
, "abbreviation")
2382 layout
.box().label(text
="Not unfolded")
2385 def label_changed(self
, context
):
2386 """The label of an island was changed"""
2387 # accessing properties via [..] to avoid a recursive call after the update
2388 self
["auto_label"] = not self
.label
or self
.label
.isspace()
2389 island_item_changed(self
, context
)
2392 def island_item_changed(self
, context
):
2393 """The labelling of an island was changed"""
2394 def increment(abbrev
, collisions
):
2395 letters
= "ABCDEFGHIJKLMNPQRSTUVWXYZ123456789"
2396 while abbrev
in collisions
:
2397 abbrev
= abbrev
.rstrip(letters
[-1])
2398 abbrev
= abbrev
[:2] + letters
[letters
.find(abbrev
[-1]) + 1 if len(abbrev
) == 3 else 0]
2401 # accessing properties via [..] to avoid a recursive call after the update
2402 island_list
= context
.active_object
.data
.paper_island_list
2404 self
["label"] = "" # avoid self-conflict
2406 while any(item
.label
== "Island {}".format(number
) for item
in island_list
):
2408 self
["label"] = "Island {}".format(number
)
2409 if self
.auto_abbrev
:
2410 self
["abbreviation"] = "" # avoid self-conflict
2411 abbrev
= "".join(first_letters(self
.label
))[:3].upper()
2412 self
["abbreviation"] = increment(abbrev
, {item
.abbreviation
for item
in island_list
})
2413 elif len(self
.abbreviation
) > 3:
2414 self
["abbreviation"] = self
.abbreviation
[:3]
2415 self
.name
= "[{}] {} ({} {})".format(
2416 self
.abbreviation
, self
.label
, len(self
.faces
), "faces" if len(self
.faces
) > 1 else "face")
2419 def island_index_changed(self
, context
):
2420 """The active island was changed"""
2421 if context
.scene
.paper_model
.sync_island
and SelectIsland
.poll(context
):
2422 bpy
.ops
.mesh
.select_paper_island(operation
='REPLACE')
2425 class FaceList(bpy
.types
.PropertyGroup
):
2426 id: bpy
.props
.IntProperty(name
="Face ID")
2429 class IslandList(bpy
.types
.PropertyGroup
):
2430 faces
: bpy
.props
.CollectionProperty(
2431 name
="Faces", description
="Faces belonging to this island", type=FaceList
)
2432 label
: bpy
.props
.StringProperty(
2433 name
="Label", description
="Label on this island",
2434 default
="", update
=label_changed
)
2435 abbreviation
: bpy
.props
.StringProperty(
2436 name
="Abbreviation", description
="Three-letter label to use when there is not enough space",
2437 default
="", update
=island_item_changed
)
2438 auto_label
: bpy
.props
.BoolProperty(
2439 name
="Auto Label", description
="Generate the label automatically",
2440 default
=True, update
=island_item_changed
)
2441 auto_abbrev
: bpy
.props
.BoolProperty(
2442 name
="Auto Abbreviation", description
="Generate the abbreviation automatically",
2443 default
=True, update
=island_item_changed
)
2446 class PaperModelSettings(bpy
.types
.PropertyGroup
):
2447 sync_island
: bpy
.props
.BoolProperty(
2448 name
="Sync", description
="Keep faces of the active island selected",
2449 default
=False, update
=island_index_changed
)
2450 limit_by_page
: bpy
.props
.BoolProperty(
2451 name
="Limit Island Size", description
="Do not create islands larger than given dimensions",
2452 default
=False, update
=page_size_preset_changed
)
2453 page_size_preset
: bpy
.props
.EnumProperty(
2454 name
="Page Size", description
="Maximal size of an island",
2455 default
='A4', update
=page_size_preset_changed
, items
=global_paper_sizes
)
2456 output_size_x
: bpy
.props
.FloatProperty(
2457 name
="Width", description
="Maximal width of an island",
2458 default
=0.2, soft_min
=0.105, soft_max
=0.841, subtype
="UNSIGNED", unit
="LENGTH")
2459 output_size_y
: bpy
.props
.FloatProperty(
2460 name
="Height", description
="Maximal height of an island",
2461 default
=0.29, soft_min
=0.148, soft_max
=1.189, subtype
="UNSIGNED", unit
="LENGTH")
2462 scale
: bpy
.props
.FloatProperty(
2463 name
="Scale", description
="Divisor of all dimensions when exporting",
2464 default
=1, soft_min
=1.0, soft_max
=100.0, subtype
='FACTOR', precision
=1)
2472 AddPresetPaperModel
,
2476 VIEW3D_MT_paper_model_presets
,
2477 DATA_PT_paper_model_islands
,
2478 VIEW3D_PT_paper_model_tools
,
2479 VIEW3D_PT_paper_model_settings
,
2484 for cls
in module_classes
:
2485 bpy
.utils
.register_class(cls
)
2486 bpy
.types
.Scene
.paper_model
= bpy
.props
.PointerProperty(
2487 name
="Paper Model", description
="Settings of the Export Paper Model script",
2488 type=PaperModelSettings
, options
={'SKIP_SAVE'})
2489 bpy
.types
.Mesh
.paper_island_list
= bpy
.props
.CollectionProperty(
2490 name
="Island List", type=IslandList
)
2491 bpy
.types
.Mesh
.paper_island_index
= bpy
.props
.IntProperty(
2492 name
="Island List Index",
2493 default
=-1, min=-1, max=100, options
={'SKIP_SAVE'}, update
=island_index_changed
)
2494 bpy
.types
.TOPBAR_MT_file_export
.append(menu_func_export
)
2495 bpy
.types
.VIEW3D_MT_edit_mesh
.prepend(menu_func_unfold
)
2499 bpy
.types
.TOPBAR_MT_file_export
.remove(menu_func_export
)
2500 bpy
.types
.VIEW3D_MT_edit_mesh
.remove(menu_func_unfold
)
2501 for cls
in reversed(module_classes
):
2502 bpy
.utils
.unregister_class(cls
)
2505 if __name__
== "__main__":