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