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": "http://wiki.blender.org/index.php/Extensions:2.6/Py/"
22 "Scripts/Import-Export/Paper_Model"
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
.copy_freestyle_marks(ob
.data
)
188 self
.mesh
.check_correct()
191 if not self
.do_create_uvmap
:
192 self
.mesh
.delete_uvmap()
194 def prepare(self
, cage_size
=None, priority_effect
=default_priority_effect
, scale
=1, limit_by_page
=False):
195 """Create the islands of the net"""
196 self
.mesh
.generate_cuts(cage_size
/ scale
if limit_by_page
and cage_size
else None, priority_effect
)
197 self
.mesh
.finalize_islands(cage_size
or M
.Vector((1, 1)))
198 self
.mesh
.enumerate_islands()
201 def copy_island_names(self
, island_list
):
202 """Copy island label and abbreviation from the best matching island in the list"""
203 orig_islands
= [{face
.id for face
in item
.faces
} for item
in island_list
]
205 for i
, island
in enumerate(self
.mesh
.islands
):
206 islfaces
= {face
.index
for face
in island
.faces
}
207 matching
.extend((len(islfaces
.intersection(item
)), i
, j
) for j
, item
in enumerate(orig_islands
))
208 matching
.sort(reverse
=True)
209 available_new
= [True for island
in self
.mesh
.islands
]
210 available_orig
= [True for item
in island_list
]
211 for face_count
, i
, j
in matching
:
212 if available_new
[i
] and available_orig
[j
]:
213 available_new
[i
] = available_orig
[j
] = False
214 self
.mesh
.islands
[i
].label
= island_list
[j
].label
215 self
.mesh
.islands
[i
].abbreviation
= island_list
[j
].abbreviation
217 def save(self
, properties
):
218 """Export the document"""
219 # Note about scale: input is direcly in blender length
220 # Mesh.scale_islands multiplies everything by a user-defined ratio
221 # exporters (SVG or PDF) multiply everything by 1000 (output in millimeters)
222 Exporter
= SVG
if properties
.file_format
== 'SVG' else PDF
223 filepath
= properties
.filepath
224 extension
= properties
.file_format
.lower()
225 filepath
= bpy
.path
.ensure_ext(filepath
, "." + extension
)
226 # page size in meters
227 page_size
= M
.Vector((properties
.output_size_x
, properties
.output_size_y
))
228 # printable area size in meters
229 printable_size
= page_size
- 2 * properties
.output_margin
* M
.Vector((1, 1))
230 unit_scale
= bpy
.context
.scene
.unit_settings
.scale_length
231 ppm
= properties
.output_dpi
* 100 / 2.54 # pixels per meter
233 # after this call, all dimensions will be in meters
234 self
.mesh
.scale_islands(unit_scale
/properties
.scale
)
235 if properties
.do_create_stickers
:
236 self
.mesh
.generate_stickers(properties
.sticker_width
, properties
.do_create_numbers
)
237 elif properties
.do_create_numbers
:
238 self
.mesh
.generate_numbers_alone(properties
.sticker_width
)
240 text_height
= properties
.sticker_width
if (properties
.do_create_numbers
and len(self
.mesh
.islands
) > 1) else 0
241 # title height must be somewhat larger that text size, glyphs go below the baseline
242 self
.mesh
.finalize_islands(printable_size
, title_height
=text_height
* 1.2)
243 self
.mesh
.fit_islands(printable_size
)
245 if properties
.output_type
!= 'NONE':
246 # bake an image and save it as a PNG to disk or into memory
247 image_packing
= properties
.image_packing
if properties
.file_format
== 'SVG' else 'ISLAND_EMBED'
248 use_separate_images
= image_packing
in ('ISLAND_LINK', 'ISLAND_EMBED')
249 self
.mesh
.save_uv(cage_size
=printable_size
, separate_image
=use_separate_images
)
251 sce
= bpy
.context
.scene
254 # TODO: do we really need all this recollection?
255 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
257 recall_pass
= {p
: getattr(bk
, f
"use_pass_{p}") for p
in ('ambient_occlusion', 'color', 'diffuse', 'direct', 'emit', 'glossy', 'indirect', 'subsurface', 'transmission')}
258 for p
in recall_pass
:
259 setattr(bk
, f
"use_pass_{p}", (properties
.output_type
!= 'TEXTURE'))
260 lookup
= {'TEXTURE': 'DIFFUSE', 'AMBIENT_OCCLUSION': 'AO', 'RENDER': 'COMBINED', 'SELECTED_TO_ACTIVE': 'COMBINED'}
261 sce
.cycles
.bake_type
= lookup
[properties
.output_type
]
262 bk
.use_selected_to_active
= (properties
.output_type
== 'SELECTED_TO_ACTIVE')
263 bk
.margin
, bk
.cage_extrusion
, bk
.use_cage
, bk
.use_clear
= 1, 10, False, False
264 if properties
.output_type
== 'TEXTURE':
265 bk
.use_pass_direct
, bk
.use_pass_indirect
, bk
.use_pass_color
= False, False, True
266 sce
.cycles
.samples
= 1
268 sce
.cycles
.samples
= properties
.bake_samples
269 if sce
.cycles
.bake_type
== 'COMBINED':
270 bk
.use_pass_direct
, bk
.use_pass_indirect
= True, True
271 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
273 if image_packing
== 'PAGE_LINK':
274 self
.mesh
.save_image(printable_size
* ppm
, filepath
)
275 elif image_packing
== 'ISLAND_LINK':
276 image_dir
= filepath
[:filepath
.rfind(".")]
277 self
.mesh
.save_separate_images(ppm
, image_dir
)
278 elif image_packing
== 'ISLAND_EMBED':
279 self
.mesh
.save_separate_images(ppm
, filepath
, embed
=Exporter
.encode_image
)
281 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
282 for p
, v
in recall_pass
.items():
283 setattr(bk
, f
"use_pass_{p}", v
)
285 exporter
= Exporter(page_size
, properties
.style
, properties
.output_margin
, (properties
.output_type
== 'NONE'), properties
.angle_epsilon
)
286 exporter
.do_create_stickers
= properties
.do_create_stickers
287 exporter
.text_size
= properties
.sticker_width
288 exporter
.write(self
.mesh
, filepath
)
292 """Wrapper for Bpy Mesh"""
294 def __init__(self
, bmesh
, matrix
):
296 self
.matrix
= matrix
.to_3x3()
297 self
.looptex
= bmesh
.loops
.layers
.uv
.new("Unfolded")
298 self
.edges
= {bmedge
: Edge(bmedge
) for bmedge
in bmesh
.edges
}
299 self
.islands
= list()
301 for edge
in self
.edges
.values():
302 edge
.choose_main_faces()
304 edge
.calculate_angle()
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
, mesh
):
310 for bmedge
, edge
in self
.edges
.items():
311 edge
.freestyle
= mesh
.edges
[bmedge
.index
].use_freestyle_mark
314 for bmedge
, edge
in self
.edges
.items():
315 if edge
.is_main_cut
and not bmedge
.is_boundary
:
318 def check_correct(self
, epsilon
=1e-6):
319 """Check for invalid geometry"""
320 def is_twisted(face
):
321 if len(face
.verts
) > 3:
322 center
= sum((vertex
.co
for vertex
in face
.verts
), M
.Vector((0, 0, 0))) / len(face
.verts
)
323 plane_d
= center
.dot(face
.normal
)
324 diameter
= max((center
- vertex
.co
).length
for vertex
in face
.verts
)
325 for vertex
in face
.verts
:
327 if abs(vertex
.co
.dot(face
.normal
) - plane_d
) > diameter
* 0.01:
331 null_edges
= {e
for e
in self
.edges
.keys() if e
.calc_length() < epsilon
and e
.link_faces
}
332 null_faces
= {f
for f
in self
.data
.faces
if f
.calc_area() < epsilon
}
333 twisted_faces
= {f
for f
in self
.data
.faces
if is_twisted(f
)}
334 if not (null_edges
or null_faces
or twisted_faces
):
336 disease
= [("Remove Doubles", null_edges
or null_faces
), ("Triangulate", twisted_faces
)]
337 cure
= " and ".join(s
for s
, k
in disease
if k
)
339 "The model contains:\n" +
340 (" {} zero-length edge(s)\n".format(len(null_edges
)) if null_edges
else "") +
341 (" {} zero-area face(s)\n".format(len(null_faces
)) if null_faces
else "") +
342 (" {} twisted polygon(s)\n".format(len(twisted_faces
)) if twisted_faces
else "") +
343 "The offenders are selected and you can use {} to fix them. Export failed.".format(cure
),
344 {"verts": set(), "edges": null_edges
, "faces": null_faces | twisted_faces
}, self
.data
)
346 def generate_cuts(self
, page_size
, priority_effect
):
347 """Cut the mesh so that it can be unfolded to a flat net."""
348 normal_matrix
= self
.matrix
.inverted().transposed()
349 islands
= {Island(self
, face
, self
.matrix
, normal_matrix
) for face
in self
.data
.faces
}
350 uvfaces
= {face
: uvface
for island
in islands
for face
, uvface
in island
.faces
.items()}
351 uvedges
= {loop
: uvedge
for island
in islands
for loop
, uvedge
in island
.edges
.items()}
352 for loop
, uvedge
in uvedges
.items():
353 self
.edges
[loop
.edge
].uvedges
.append(uvedge
)
354 # check for edges that are cut permanently
355 edges
= [edge
for edge
in self
.edges
.values() if not edge
.force_cut
and edge
.main_faces
]
358 average_length
= sum(edge
.vector
.length
for edge
in edges
) / len(edges
)
360 edge
.generate_priority(priority_effect
, average_length
)
361 edges
.sort(reverse
=False, key
=lambda edge
: edge
.priority
)
365 edge_a
, edge_b
= (uvedges
[l
] for l
in edge
.main_faces
)
366 old_island
= join(edge_a
, edge_b
, size_limit
=page_size
)
368 islands
.remove(old_island
)
370 self
.islands
= sorted(islands
, reverse
=True, key
=lambda island
: len(island
.faces
))
372 for edge
in self
.edges
.values():
373 # some edges did not know until now whether their angle is convex or concave
374 if edge
.main_faces
and (uvfaces
[edge
.main_faces
[0].face
].flipped
or uvfaces
[edge
.main_faces
[1].face
].flipped
):
375 edge
.calculate_angle()
376 # ensure that the order of faces corresponds to the order of uvedges
378 reordered
= [None, None]
379 for uvedge
in edge
.uvedges
:
381 index
= edge
.main_faces
.index(uvedge
.loop
)
382 reordered
[index
] = uvedge
384 reordered
.append(uvedge
)
385 edge
.uvedges
= reordered
387 for island
in self
.islands
:
388 # if the normals are ambiguous, flip them so that there are more convex edges than concave ones
389 if any(uvface
.flipped
for uvface
in island
.faces
.values()):
390 island_edges
= {self
.edges
[uvedge
.edge
] for uvedge
in island
.edges
}
391 balance
= sum((+1 if edge
.angle
> 0 else -1) for edge
in island_edges
if not edge
.is_cut(uvedge
.uvface
.face
))
393 island
.is_inside_out
= True
395 # construct a linked list from each island's boundary
396 # uvedge.neighbor_right is clockwise = forward = via uvedge.vb if not uvface.flipped
397 neighbor_lookup
, conflicts
= dict(), dict()
398 for uvedge
in island
.boundary
:
399 uvvertex
= uvedge
.va
if uvedge
.uvface
.flipped
else uvedge
.vb
400 if uvvertex
not in neighbor_lookup
:
401 neighbor_lookup
[uvvertex
] = uvedge
403 if uvvertex
not in conflicts
:
404 conflicts
[uvvertex
] = [neighbor_lookup
[uvvertex
], uvedge
]
406 conflicts
[uvvertex
].append(uvedge
)
408 for uvedge
in island
.boundary
:
409 uvvertex
= uvedge
.vb
if uvedge
.uvface
.flipped
else uvedge
.va
410 if uvvertex
not in conflicts
:
411 # using the 'get' method so as to handle single-connected vertices properly
412 uvedge
.neighbor_right
= neighbor_lookup
.get(uvvertex
, uvedge
)
413 uvedge
.neighbor_right
.neighbor_left
= uvedge
415 conflicts
[uvvertex
].append(uvedge
)
417 # resolve merged vertices with more boundaries crossing
418 def direction_to_float(vector
):
419 return (1 - vector
.x
/vector
.length
) if vector
.y
> 0 else (vector
.x
/vector
.length
- 1)
420 for uvvertex
, uvedges
in conflicts
.items():
421 def is_inwards(uvedge
):
422 return uvedge
.uvface
.flipped
== (uvedge
.va
is uvvertex
)
424 def uvedge_sortkey(uvedge
):
425 if is_inwards(uvedge
):
426 return direction_to_float(uvedge
.va
.co
- uvedge
.vb
.co
)
428 return direction_to_float(uvedge
.vb
.co
- uvedge
.va
.co
)
430 uvedges
.sort(key
=uvedge_sortkey
)
432 zip(uvedges
[:-1:2], uvedges
[1::2]) if is_inwards(uvedges
[0])
433 else zip([uvedges
[-1]] + uvedges
[1::2], uvedges
[:-1:2])):
434 left
.neighbor_right
= right
435 right
.neighbor_left
= left
438 def generate_stickers(self
, default_width
, do_create_numbers
=True):
439 """Add sticker faces where they are needed."""
440 def uvedge_priority(uvedge
):
441 """Retuns whether it is a good idea to stick something on this edge's face"""
442 # TODO: it should take into account overlaps with faces and with other stickers
443 face
= uvedge
.uvface
.face
444 return face
.calc_area() / face
.calc_perimeter()
446 def add_sticker(uvedge
, index
, target_uvedge
):
447 uvedge
.sticker
= Sticker(uvedge
, default_width
, index
, target_uvedge
)
448 uvedge
.uvface
.island
.add_marker(uvedge
.sticker
)
450 def is_index_obvious(uvedge
, target
):
451 if uvedge
in (target
.neighbor_left
, target
.neighbor_right
):
453 if uvedge
.neighbor_left
.loop
.edge
is target
.neighbor_right
.loop
.edge
and uvedge
.neighbor_right
.loop
.edge
is target
.neighbor_left
.loop
.edge
:
457 for edge
in self
.edges
.values():
459 if edge
.is_main_cut
and len(edge
.uvedges
) >= 2 and edge
.vector
.length_squared
> 0:
460 target
, source
= edge
.uvedges
[:2]
461 if uvedge_priority(target
) < uvedge_priority(source
):
462 target
, source
= source
, target
463 target_island
= target
.uvface
.island
464 if do_create_numbers
:
465 for uvedge
in [source
] + edge
.uvedges
[2:]:
466 if not is_index_obvious(uvedge
, target
):
467 # it will not be clear to see that these uvedges should be sticked together
468 # So, create an arrow and put the index on all stickers
469 target_island
.sticker_numbering
+= 1
470 index
= str(target_island
.sticker_numbering
)
471 if is_upsidedown_wrong(index
):
473 target_island
.add_marker(Arrow(target
, default_width
, index
))
475 add_sticker(source
, index
, target
)
476 elif len(edge
.uvedges
) > 2:
477 target
= edge
.uvedges
[0]
478 if len(edge
.uvedges
) > 2:
479 for source
in edge
.uvedges
[2:]:
480 add_sticker(source
, index
, target
)
482 def generate_numbers_alone(self
, size
):
484 for edge
in self
.edges
.values():
485 if edge
.is_main_cut
and len(edge
.uvedges
) >= 2:
486 global_numbering
+= 1
487 index
= str(global_numbering
)
488 if is_upsidedown_wrong(index
):
490 for uvedge
in edge
.uvedges
:
491 uvedge
.uvface
.island
.add_marker(NumberAlone(uvedge
, index
, size
))
493 def enumerate_islands(self
):
494 for num
, island
in enumerate(self
.islands
, 1):
496 island
.generate_label()
498 def scale_islands(self
, scale
):
499 for island
in self
.islands
:
500 vertices
= set(island
.vertices
.values())
501 for point
in chain((vertex
.co
for vertex
in vertices
), island
.fake_vertices
):
504 def finalize_islands(self
, cage_size
, title_height
=0):
505 for island
in self
.islands
:
507 island
.title
= "[{}] {}".format(island
.abbreviation
, island
.label
)
508 points
= [vertex
.co
for vertex
in set(island
.vertices
.values())] + island
.fake_vertices
509 angle
, _
= cage_fit(points
, (cage_size
.y
- title_height
) / cage_size
.x
)
510 rot
= M
.Matrix
.Rotation(angle
, 2)
512 # note: we need an in-place operation, and Vector.rotate() seems to work for 3d vectors only
513 point
[:] = rot
@ point
514 for marker
in island
.markers
:
515 marker
.rot
= rot
@ marker
.rot
516 bottom_left
= M
.Vector((min(v
.x
for v
in points
), min(v
.y
for v
in points
) - title_height
))
518 top_right
= M
.Vector((max(v
.x
for v
in points
), max(v
.y
for v
in points
) - title_height
))
519 #print(f"fitted aspect: {(top_right.y - bottom_left.y) / (top_right.x - bottom_left.x)}")
522 island
.bounding_box
= M
.Vector((max(v
.x
for v
in points
), max(v
.y
for v
in points
)))
524 def largest_island_ratio(self
, cage_size
):
525 return max(i
/ p
for island
in self
.islands
for (i
, p
) in zip(island
.bounding_box
, cage_size
))
527 def fit_islands(self
, cage_size
):
528 """Move islands so that they fit onto pages, based on their bounding boxes"""
530 def try_emplace(island
, page_islands
, stops_x
, stops_y
, occupied_cache
):
531 """Tries to put island to each pair from stops_x, stops_y
532 and checks if it overlaps with any islands present on the page.
533 Returns True and positions the given island on success."""
534 bbox_x
, bbox_y
= island
.bounding_box
.xy
536 if x
+ bbox_x
> cage_size
.x
:
539 if y
+ bbox_y
> cage_size
.y
or (x
, y
) in occupied_cache
:
541 for i
, obstacle
in enumerate(page_islands
):
542 # if this obstacle overlaps with the island, try another stop
543 if (x
+ bbox_x
> obstacle
.pos
.x
and
544 obstacle
.pos
.x
+ obstacle
.bounding_box
.x
> x
and
545 y
+ bbox_y
> obstacle
.pos
.y
and
546 obstacle
.pos
.y
+ obstacle
.bounding_box
.y
> y
):
547 if x
>= obstacle
.pos
.x
and y
>= obstacle
.pos
.y
:
548 occupied_cache
.add((x
, y
))
549 # just a stupid heuristic to make subsequent searches faster
551 page_islands
[1:i
+1] = page_islands
[:i
]
552 page_islands
[0] = obstacle
555 # if no obstacle called break, this position is okay
557 page_islands
.append(island
)
558 stops_x
.append(x
+ bbox_x
)
559 stops_y
.append(y
+ bbox_y
)
563 def drop_portion(stops
, border
, divisor
):
565 # distance from left neighbor to the right one, excluding the first stop
566 distances
= [right
- left
for left
, right
in zip(stops
, chain(stops
[2:], [border
]))]
567 quantile
= sorted(distances
)[len(distances
) // divisor
]
568 return [stop
for stop
, distance
in zip(stops
, chain([quantile
], distances
)) if distance
>= quantile
]
570 if any(island
.bounding_box
.x
> cage_size
.x
or island
.bounding_box
.y
> cage_size
.y
for island
in self
.islands
):
572 "An island is too big to fit onto page of the given size. "
573 "Either downscale the model or find and split that island manually.\n"
574 "Export failed, sorry.")
575 # sort islands by their diagonal... just a guess
576 remaining_islands
= sorted(self
.islands
, reverse
=True, key
=lambda island
: island
.bounding_box
.length_squared
)
577 page_num
= 1 # TODO delete me
579 while remaining_islands
:
580 # create a new page and try to fit as many islands onto it as possible
581 page
= Page(page_num
)
583 occupied_cache
= set()
584 stops_x
, stops_y
= [0], [0]
585 for island
in remaining_islands
:
586 try_emplace(island
, page
.islands
, stops_x
, stops_y
, occupied_cache
)
587 # if overwhelmed with stops, drop a quarter of them
588 if len(stops_x
)**2 > 4 * len(self
.islands
) + 100:
589 stops_x
= drop_portion(stops_x
, cage_size
.x
, 4)
590 stops_y
= drop_portion(stops_y
, cage_size
.y
, 4)
591 remaining_islands
= [island
for island
in remaining_islands
if island
not in page
.islands
]
592 self
.pages
.append(page
)
594 def save_uv(self
, cage_size
=M
.Vector((1, 1)), separate_image
=False):
596 for island
in self
.islands
:
597 island
.save_uv_separate(self
.looptex
)
599 for island
in self
.islands
:
600 island
.save_uv(self
.looptex
, cage_size
)
602 def save_image(self
, page_size_pixels
: M
.Vector
, filename
):
603 for page
in self
.pages
:
604 image
= create_blank_image("Page {}".format(page
.name
), page_size_pixels
, alpha
=1)
605 image
.filepath_raw
= page
.image_path
= "{}_{}.png".format(filename
, page
.name
)
606 faces
= [face
for island
in page
.islands
for face
in island
.faces
]
607 self
.bake(faces
, image
)
610 bpy
.data
.images
.remove(image
)
612 def save_separate_images(self
, scale
, filepath
, embed
=None):
613 for i
, island
in enumerate(self
.islands
):
614 image_name
= "Island {}".format(i
)
615 image
= create_blank_image(image_name
, island
.bounding_box
* scale
, alpha
=0)
616 self
.bake(island
.faces
.keys(), image
)
618 island
.embedded_image
= embed(image
)
620 from os
import makedirs
622 makedirs(image_dir
, exist_ok
=True)
623 image_path
= os_path
.join(image_dir
, "island{}.png".format(i
))
624 image
.filepath_raw
= image_path
626 island
.image_path
= image_path
628 bpy
.data
.images
.remove(image
)
630 def bake(self
, faces
, image
):
632 raise UnfoldError("The mesh has no UV Map slots left. Either delete a UV Map or export the net without textures.")
633 ob
= bpy
.context
.active_object
635 # in Cycles, the image for baking is defined by the active Image Node
637 for mat
in me
.materials
:
639 img
= mat
.node_tree
.nodes
.new('ShaderNodeTexImage')
641 temp_nodes
[mat
] = img
642 mat
.node_tree
.nodes
.active
= img
643 # move all excess faces to negative numbers (that is the only way to disable them)
644 ignored_uvs
= [loop
[self
.looptex
].uv
for f
in self
.data
.faces
if f
not in faces
for loop
in f
.loops
]
645 for uv
in ignored_uvs
:
647 bake_type
= bpy
.context
.scene
.cycles
.bake_type
648 sta
= bpy
.context
.scene
.render
.bake
.use_selected_to_active
650 ob
.update_from_editmode()
651 me
.uv_layers
.active
= me
.uv_layers
[self
.looptex
.name
]
652 bpy
.ops
.object.bake(type=bake_type
, margin
=1, use_selected_to_active
=sta
, cage_extrusion
=100, use_clear
=False)
653 except RuntimeError as e
:
654 raise UnfoldError(*e
.args
)
656 for mat
, node
in temp_nodes
.items():
657 mat
.node_tree
.nodes
.remove(node
)
658 for uv
in ignored_uvs
:
663 """Wrapper for BPy Edge"""
664 __slots__
= ('data', 'va', 'vb', 'main_faces', 'uvedges',
666 'is_main_cut', 'force_cut', 'priority', 'freestyle')
668 def __init__(self
, edge
):
670 self
.va
, self
.vb
= edge
.verts
671 self
.vector
= self
.vb
.co
- self
.va
.co
672 # if self.main_faces is set, then self.uvedges[:2] must correspond to self.main_faces, in their order
673 # this constraint is assured at the time of finishing mesh.generate_cuts
674 self
.uvedges
= list()
676 self
.force_cut
= edge
.seam
# such edges will always be cut
677 self
.main_faces
= None # two faces that may be connected in the island
678 # is_main_cut defines whether the two main faces are connected
679 # all the others will be assumed to be cut
680 self
.is_main_cut
= True
683 self
.freestyle
= False
685 def choose_main_faces(self
):
686 """Choose two main faces that might get connected in an island"""
687 from itertools
import combinations
688 loops
= self
.data
.link_loops
690 return abs(pair
[0].face
.normal
.dot(pair
[1].face
.normal
))
692 self
.main_faces
= list(loops
)
694 # find (with brute force) the pair of indices whose loops have the most similar normals
695 self
.main_faces
= max(combinations(loops
, 2), key
=score
)
696 if self
.main_faces
and self
.main_faces
[1].vert
== self
.va
:
697 self
.main_faces
= self
.main_faces
[::-1]
699 def calculate_angle(self
):
700 """Calculate the angle between the main faces"""
701 loop_a
, loop_b
= self
.main_faces
702 normal_a
, normal_b
= (l
.face
.normal
for l
in self
.main_faces
)
703 if not normal_a
or not normal_b
:
704 self
.angle
= -3 # just a very sharp angle
706 self
.angle
= asin(normal_a
.cross(normal_b
).dot(self
.vector
.normalized()))
707 if loop_a
.link_loop_next
.vert
!= loop_b
.vert
or loop_b
.link_loop_next
.vert
!= loop_a
.vert
:
708 self
.angle
= abs(self
.angle
)
710 def generate_priority(self
, priority_effect
, average_length
):
711 """Calculate the priority value for cutting"""
714 self
.priority
= priority_effect
['CONVEX'] * angle
/ pi
716 self
.priority
= priority_effect
['CONCAVE'] * (-angle
) / pi
717 self
.priority
+= (self
.vector
.length
/ average_length
) * priority_effect
['LENGTH']
719 def is_cut(self
, face
):
720 """Return False if this edge will the given face to another one in the resulting net
721 (useful for edges with more than two faces connected)"""
722 # Return whether there is a cut between the two main faces
723 if self
.main_faces
and face
in {loop
.face
for loop
in self
.main_faces
}:
724 return self
.is_main_cut
725 # All other faces (third and more) are automatically treated as cut
729 def other_uvedge(self
, this
):
730 """Get an uvedge of this edge that is not the given one
731 causes an IndexError if case of less than two adjacent edges"""
732 return self
.uvedges
[1] if this
is self
.uvedges
[0] else self
.uvedges
[0]
736 """Part of the net to be exported"""
737 __slots__
= ('mesh', 'faces', 'edges', 'vertices', 'fake_vertices', 'boundary', 'markers',
738 'pos', 'bounding_box',
739 'image_path', 'embedded_image',
740 'number', 'label', 'abbreviation', 'title',
741 'has_safe_geometry', 'is_inside_out',
744 def __init__(self
, mesh
, face
, matrix
, normal_matrix
):
745 """Create an Island from a single Face"""
747 self
.faces
= dict() # face -> uvface
748 self
.edges
= dict() # loop -> uvedge
749 self
.vertices
= dict() # loop -> uvvertex
750 self
.fake_vertices
= list()
751 self
.markers
= list()
753 self
.abbreviation
= None
755 self
.pos
= M
.Vector((0, 0))
756 self
.image_path
= None
757 self
.embedded_image
= None
758 self
.is_inside_out
= False # swaps concave <-> convex edges
759 self
.has_safe_geometry
= True
760 self
.sticker_numbering
= 0
762 uvface
= UVFace(face
, self
, matrix
, normal_matrix
)
763 self
.vertices
.update(uvface
.vertices
)
764 self
.edges
.update(uvface
.edges
)
765 self
.faces
[face
] = uvface
766 # UVEdges on the boundary
767 self
.boundary
= list(self
.edges
.values())
769 def add_marker(self
, marker
):
770 self
.fake_vertices
.extend(marker
.bounds
)
771 self
.markers
.append(marker
)
773 def generate_label(self
, label
=None, abbreviation
=None):
774 """Assign a name to this island automatically"""
775 abbr
= abbreviation
or self
.abbreviation
or str(self
.number
)
776 # TODO: dots should be added in the last instant when outputting any text
777 if is_upsidedown_wrong(abbr
):
779 self
.label
= label
or self
.label
or "Island {}".format(self
.number
)
780 self
.abbreviation
= abbr
782 def save_uv(self
, tex
, cage_size
):
783 """Save UV Coordinates of all UVFaces to a given UV texture
784 tex: UV Texture layer to use (BMLayerItem)
785 page_size: size of the page in pixels (vector)"""
786 scale_x
, scale_y
= 1 / cage_size
.x
, 1 / cage_size
.y
787 for loop
, uvvertex
in self
.vertices
.items():
788 uv
= uvvertex
.co
+ self
.pos
789 loop
[tex
].uv
= uv
.x
* scale_x
, uv
.y
* scale_y
791 def save_uv_separate(self
, tex
):
792 """Save UV Coordinates of all UVFaces to a given UV texture, spanning from 0 to 1
793 tex: UV Texture layer to use (BMLayerItem)
794 page_size: size of the page in pixels (vector)"""
795 scale_x
, scale_y
= 1 / self
.bounding_box
.x
, 1 / self
.bounding_box
.y
796 for loop
, uvvertex
in self
.vertices
.items():
797 loop
[tex
].uv
= uvvertex
.co
.x
* scale_x
, uvvertex
.co
.y
* scale_y
799 def join(uvedge_a
, uvedge_b
, size_limit
=None, epsilon
=1e-6):
801 Try to join other island on given edge
802 Returns False if they would overlap
805 class Intersection(Exception):
808 class GeometryError(Exception):
811 def is_below(self
, other
, correct_geometry
=True):
814 if self
.top
< other
.bottom
:
816 if other
.top
< self
.bottom
:
818 if self
.max.tup
<= other
.min.tup
:
820 if other
.max.tup
<= self
.min.tup
:
822 self_vector
= self
.max.co
- self
.min.co
823 min_to_min
= other
.min.co
- self
.min.co
824 cross_b1
= self_vector
.cross(min_to_min
)
825 cross_b2
= self_vector
.cross(other
.max.co
- self
.min.co
)
826 if cross_b2
< cross_b1
:
827 cross_b1
, cross_b2
= cross_b2
, cross_b1
828 if cross_b2
> 0 and (cross_b1
> 0 or (cross_b1
== 0 and not self
.is_uvface_upwards())):
830 if cross_b1
< 0 and (cross_b2
< 0 or (cross_b2
== 0 and self
.is_uvface_upwards())):
832 other_vector
= other
.max.co
- other
.min.co
833 cross_a1
= other_vector
.cross(-min_to_min
)
834 cross_a2
= other_vector
.cross(self
.max.co
- other
.min.co
)
835 if cross_a2
< cross_a1
:
836 cross_a1
, cross_a2
= cross_a2
, cross_a1
837 if cross_a2
> 0 and (cross_a1
> 0 or (cross_a1
== 0 and not other
.is_uvface_upwards())):
839 if cross_a1
< 0 and (cross_a2
< 0 or (cross_a2
== 0 and other
.is_uvface_upwards())):
841 if cross_a1
== cross_b1
== cross_a2
== cross_b2
== 0:
844 elif self
.is_uvface_upwards() == other
.is_uvface_upwards():
847 if self
.min.tup
== other
.min.tup
or self
.max.tup
== other
.max.tup
:
848 return cross_a2
> cross_b2
851 class QuickSweepline
:
852 """Efficient sweepline based on binary search, checking neighbors only"""
854 self
.children
= list()
856 def add(self
, item
, cmp=is_below
):
857 low
, high
= 0, len(self
.children
)
859 mid
= (low
+ high
) // 2
860 if cmp(self
.children
[mid
], item
):
864 self
.children
.insert(low
, item
)
866 def remove(self
, item
, cmp=is_below
):
867 index
= self
.children
.index(item
)
868 self
.children
.pop(index
)
869 if index
> 0 and index
< len(self
.children
):
870 # check for intersection
871 if cmp(self
.children
[index
], self
.children
[index
-1]):
874 class BruteSweepline
:
875 """Safe sweepline which checks all its members pairwise"""
877 self
.children
= set()
879 def add(self
, item
, cmp=is_below
):
880 for child
in self
.children
:
881 if child
.min is not item
.min and child
.max is not item
.max:
882 cmp(item
, child
, False)
883 self
.children
.add(item
)
885 def remove(self
, item
):
886 self
.children
.remove(item
)
888 def sweep(sweepline
, segments
):
889 """Sweep across the segments and raise an exception if necessary"""
890 # careful, 'segments' may be a use-once iterator
891 events_add
= sorted(segments
, reverse
=True, key
=lambda uvedge
: uvedge
.min.tup
)
892 events_remove
= sorted(events_add
, reverse
=True, key
=lambda uvedge
: uvedge
.max.tup
)
894 while events_add
and events_add
[-1].min.tup
<= events_remove
[-1].max.tup
:
895 sweepline
.add(events_add
.pop())
896 sweepline
.remove(events_remove
.pop())
898 def root_find(value
, tree
):
899 """Find the root of a given value in a forest-like dictionary
900 also updates the dictionary using path compression"""
901 parent
, relink
= tree
.get(value
), list()
902 while parent
is not None:
904 value
, parent
= parent
, tree
.get(parent
)
905 tree
.update(dict.fromkeys(relink
, value
))
908 def slope_from(position
):
910 vec
= (uvedge
.vb
.co
- uvedge
.va
.co
) if uvedge
.va
.tup
== position
else (uvedge
.va
.co
- uvedge
.vb
.co
)
911 return (vec
.y
/ vec
.length
+ 1) if ((vec
.x
, vec
.y
) > (0, 0)) else (-1 - vec
.y
/ vec
.length
)
914 island_a
, island_b
= (e
.uvface
.island
for e
in (uvedge_a
, uvedge_b
))
915 if island_a
is island_b
:
917 elif len(island_b
.faces
) > len(island_a
.faces
):
918 uvedge_a
, uvedge_b
= uvedge_b
, uvedge_a
919 island_a
, island_b
= island_b
, island_a
920 # check if vertices and normals are aligned correctly
921 verts_flipped
= uvedge_b
.loop
.vert
is uvedge_a
.loop
.vert
922 flipped
= verts_flipped ^ uvedge_a
.uvface
.flipped ^ uvedge_b
.uvface
.flipped
924 # NOTE: if the edges differ in length, the matrix will involve uniform scaling.
925 # Such situation may occur in the case of twisted n-gons
926 first_b
, second_b
= (uvedge_b
.va
, uvedge_b
.vb
) if not verts_flipped
else (uvedge_b
.vb
, uvedge_b
.va
)
928 rot
= fitting_matrix(first_b
.co
- second_b
.co
, uvedge_a
.vb
.co
- uvedge_a
.va
.co
)
930 flip
= M
.Matrix(((-1, 0), (0, 1)))
931 rot
= fitting_matrix(flip
@ (first_b
.co
- second_b
.co
), uvedge_a
.vb
.co
- uvedge_a
.va
.co
) @ flip
932 trans
= uvedge_a
.vb
.co
- rot
@ first_b
.co
933 # preview of island_b's vertices after the join operation
934 phantoms
= {uvvertex
: UVVertex(rot
@ uvvertex
.co
+ trans
) for uvvertex
in island_b
.vertices
.values()}
936 # check the size of the resulting island
938 points
= [vert
.co
for vert
in chain(island_a
.vertices
.values(), phantoms
.values())]
939 left
, right
, bottom
, top
= (fn(co
[i
] for co
in points
) for i
in (0, 1) for fn
in (min, max))
940 bbox_width
= right
- left
941 bbox_height
= top
- bottom
942 if min(bbox_width
, bbox_height
)**2 > size_limit
.x
**2 + size_limit
.y
**2:
944 if (bbox_width
> size_limit
.x
or bbox_height
> size_limit
.y
) and (bbox_height
> size_limit
.x
or bbox_width
> size_limit
.y
):
945 _
, height
= cage_fit(points
, size_limit
.y
/ size_limit
.x
)
946 if height
> size_limit
.y
:
949 distance_limit
= uvedge_a
.loop
.edge
.calc_length() * epsilon
950 # try and merge UVVertices closer than sqrt(distance_limit)
951 merged_uvedges
= set()
952 merged_uvedge_pairs
= list()
954 # merge all uvvertices that are close enough using a union-find structure
955 # uvvertices will be merged only in cases island_b->island_a and island_a->island_a
956 # all resulting groups are merged together to a uvvertex of island_a
957 is_merged_mine
= False
958 shared_vertices
= {loop
.vert
for loop
in chain(island_a
.vertices
, island_b
.vertices
)}
959 for vertex
in shared_vertices
:
960 uvs_a
= {island_a
.vertices
.get(loop
) for loop
in vertex
.link_loops
} - {None}
961 uvs_b
= {island_b
.vertices
.get(loop
) for loop
in vertex
.link_loops
} - {None}
962 for a
, b
in product(uvs_a
, uvs_b
):
963 if (a
.co
- phantoms
[b
].co
).length_squared
< distance_limit
:
964 phantoms
[b
] = root_find(a
, phantoms
)
965 for a1
, a2
in combinations(uvs_a
, 2):
966 if (a1
.co
- a2
.co
).length_squared
< distance_limit
:
967 a1
, a2
= (root_find(a
, phantoms
) for a
in (a1
, a2
))
970 is_merged_mine
= True
971 for source
, target
in phantoms
.items():
972 target
= root_find(target
, phantoms
)
973 phantoms
[source
] = target
975 for uvedge
in (chain(island_a
.boundary
, island_b
.boundary
) if is_merged_mine
else island_b
.boundary
):
976 for loop
in uvedge
.loop
.link_loops
:
977 partner
= island_b
.edges
.get(loop
) or island_a
.edges
.get(loop
)
978 if partner
is not None and partner
is not uvedge
:
979 paired_a
, paired_b
= phantoms
.get(partner
.vb
, partner
.vb
), phantoms
.get(partner
.va
, partner
.va
)
980 if (partner
.uvface
.flipped ^ flipped
) != uvedge
.uvface
.flipped
:
981 paired_a
, paired_b
= paired_b
, paired_a
982 if phantoms
.get(uvedge
.va
, uvedge
.va
) is paired_a
and phantoms
.get(uvedge
.vb
, uvedge
.vb
) is paired_b
:
983 # if these two edges will get merged, add them both to the set
984 merged_uvedges
.update((uvedge
, partner
))
985 merged_uvedge_pairs
.append((uvedge
, partner
))
988 if uvedge_b
not in merged_uvedges
:
989 raise UnfoldError("Export failed. Please report this error, including the model if you can.")
992 PhantomUVEdge(phantoms
[uvedge
.va
], phantoms
[uvedge
.vb
], flipped ^ uvedge
.uvface
.flipped
)
993 for uvedge
in island_b
.boundary
if uvedge
not in merged_uvedges
]
994 # TODO: if is_merged_mine, it might make sense to create a similar list from island_a.boundary as well
996 incidence
= {vertex
.tup
for vertex
in phantoms
.values()}.intersection(vertex
.tup
for vertex
in island_a
.vertices
.values())
997 incidence
= {position
: list() for position
in incidence
} # from now on, 'incidence' is a dict
998 for uvedge
in chain(boundary_other
, island_a
.boundary
):
999 if uvedge
.va
.co
== uvedge
.vb
.co
:
1001 for vertex
in (uvedge
.va
, uvedge
.vb
):
1002 site
= incidence
.get(vertex
.tup
)
1003 if site
is not None:
1005 for position
, segments
in incidence
.items():
1006 if len(segments
) <= 2:
1008 segments
.sort(key
=slope_from(position
))
1009 for right
, left
in pairs(segments
):
1010 is_left_ccw
= left
.is_uvface_upwards() ^
(left
.max.tup
== position
)
1011 is_right_ccw
= right
.is_uvface_upwards() ^
(right
.max.tup
== position
)
1012 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
:
1014 if (not is_right_ccw
and right
not in merged_uvedges
) ^
(is_left_ccw
and left
not in merged_uvedges
):
1017 # check for self-intersections
1020 sweepline
= QuickSweepline() if island_a
.has_safe_geometry
and island_b
.has_safe_geometry
else BruteSweepline()
1021 sweep(sweepline
, (uvedge
for uvedge
in chain(boundary_other
, island_a
.boundary
)))
1022 island_a
.has_safe_geometry
&= island_b
.has_safe_geometry
1023 except GeometryError
:
1024 sweep(BruteSweepline(), (uvedge
for uvedge
in chain(boundary_other
, island_a
.boundary
)))
1025 island_a
.has_safe_geometry
= False
1026 except Intersection
:
1029 # mark all edges that connect the islands as not cut
1030 for uvedge
in merged_uvedges
:
1031 island_a
.mesh
.edges
[uvedge
.loop
.edge
].is_main_cut
= False
1033 # include all trasformed vertices as mine
1034 island_a
.vertices
.update({loop
: phantoms
[uvvertex
] for loop
, uvvertex
in island_b
.vertices
.items()})
1036 # re-link uvedges and uvfaces to their transformed locations
1037 for uvedge
in island_b
.edges
.values():
1038 uvedge
.va
= phantoms
[uvedge
.va
]
1039 uvedge
.vb
= phantoms
[uvedge
.vb
]
1042 for uvedge
in island_a
.edges
:
1043 uvedge
.va
= phantoms
.get(uvedge
.va
, uvedge
.va
)
1044 uvedge
.vb
= phantoms
.get(uvedge
.vb
, uvedge
.vb
)
1045 island_a
.edges
.update(island_b
.edges
)
1047 for uvface
in island_b
.faces
.values():
1048 uvface
.island
= island_a
1049 uvface
.vertices
= {loop
: phantoms
[uvvertex
] for loop
, uvvertex
in uvface
.vertices
.items()}
1050 uvface
.flipped ^
= flipped
1052 # there may be own uvvertices that need to be replaced by phantoms
1053 for uvface
in island_a
.faces
.values():
1054 if any(uvvertex
in phantoms
for uvvertex
in uvface
.vertices
):
1055 uvface
.vertices
= {loop
: phantoms
.get(uvvertex
, uvvertex
) for loop
, uvvertex
in uvface
.vertices
.items()}
1056 island_a
.faces
.update(island_b
.faces
)
1058 island_a
.boundary
= [
1059 uvedge
for uvedge
in chain(island_a
.boundary
, island_b
.boundary
)
1060 if uvedge
not in merged_uvedges
]
1062 for uvedge
, partner
in merged_uvedge_pairs
:
1063 # make sure that main faces are the ones actually merged (this changes nothing in most cases)
1064 edge
= island_a
.mesh
.edges
[uvedge
.loop
.edge
]
1065 edge
.main_faces
= uvedge
.loop
, partner
.loop
1067 # everything seems to be OK
1072 """Container for several Islands"""
1073 __slots__
= ('islands', 'name', 'image_path')
1075 def __init__(self
, num
=1):
1076 self
.islands
= list()
1077 self
.name
= "page{}".format(num
) # TODO delete me
1078 self
.image_path
= None
1083 __slots__
= ('co', 'tup')
1085 def __init__(self
, vector
):
1087 self
.tup
= tuple(self
.co
)
1092 # Every UVEdge is attached to only one UVFace
1093 # UVEdges are doubled as needed because they both have to point clockwise around their faces
1094 __slots__
= ('va', 'vb', 'uvface', 'loop',
1095 'min', 'max', 'bottom', 'top',
1096 'neighbor_left', 'neighbor_right', 'sticker')
1098 def __init__(self
, vertex1
: UVVertex
, vertex2
: UVVertex
, uvface
, loop
):
1102 self
.uvface
= uvface
1107 """Update data if UVVertices have moved"""
1108 self
.min, self
.max = (self
.va
, self
.vb
) if (self
.va
.tup
< self
.vb
.tup
) else (self
.vb
, self
.va
)
1109 y1
, y2
= self
.va
.co
.y
, self
.vb
.co
.y
1110 self
.bottom
, self
.top
= (y1
, y2
) if y1
< y2
else (y2
, y1
)
1112 def is_uvface_upwards(self
):
1113 return (self
.va
.tup
< self
.vb
.tup
) ^ self
.uvface
.flipped
1116 return "({0.va} - {0.vb})".format(self
)
1119 class PhantomUVEdge
:
1120 """Temporary 2D Segment for calculations"""
1121 __slots__
= ('va', 'vb', 'min', 'max', 'bottom', 'top')
1123 def __init__(self
, vertex1
: UVVertex
, vertex2
: UVVertex
, flip
):
1124 self
.va
, self
.vb
= (vertex2
, vertex1
) if flip
else (vertex1
, vertex2
)
1125 self
.min, self
.max = (self
.va
, self
.vb
) if (self
.va
.tup
< self
.vb
.tup
) else (self
.vb
, self
.va
)
1126 y1
, y2
= self
.va
.co
.y
, self
.vb
.co
.y
1127 self
.bottom
, self
.top
= (y1
, y2
) if y1
< y2
else (y2
, y1
)
1129 def is_uvface_upwards(self
):
1130 return self
.va
.tup
< self
.vb
.tup
1133 return "[{0.va} - {0.vb}]".format(self
)
1138 __slots__
= ('vertices', 'edges', 'face', 'island', 'flipped')
1140 def __init__(self
, face
: bmesh
.types
.BMFace
, island
: Island
, matrix
=1, normal_matrix
=1):
1142 self
.island
= island
1143 self
.flipped
= False # a flipped UVFace has edges clockwise
1145 flatten
= z_up_matrix(normal_matrix
@ face
.normal
) @ matrix
1146 self
.vertices
= {loop
: UVVertex(flatten
@ loop
.vert
.co
) for loop
in face
.loops
}
1147 self
.edges
= {loop
: UVEdge(self
.vertices
[loop
], self
.vertices
[loop
.link_loop_next
], self
, loop
) for loop
in face
.loops
}
1151 """Mark in the document: an arrow denoting the number of the edge it points to"""
1152 __slots__
= ('bounds', 'center', 'rot', 'text', 'size')
1154 def __init__(self
, uvedge
, size
, index
):
1155 self
.text
= str(index
)
1156 edge
= (uvedge
.vb
.co
- uvedge
.va
.co
) if not uvedge
.uvface
.flipped
else (uvedge
.va
.co
- uvedge
.vb
.co
)
1157 self
.center
= (uvedge
.va
.co
+ uvedge
.vb
.co
) / 2
1159 tangent
= edge
.normalized()
1161 self
.rot
= M
.Matrix(((cos
, -sin
), (sin
, cos
)))
1162 normal
= M
.Vector((sin
, -cos
))
1163 self
.bounds
= [self
.center
, self
.center
+ (1.2 * normal
+ tangent
) * size
, self
.center
+ (1.2 * normal
- tangent
) * size
]
1167 """Mark in the document: sticker tab"""
1168 __slots__
= ('bounds', 'center', 'rot', 'text', 'width', 'vertices')
1170 def __init__(self
, uvedge
, default_width
, index
, other
: UVEdge
):
1171 """Sticker is directly attached to the given UVEdge"""
1172 first_vertex
, second_vertex
= (uvedge
.va
, uvedge
.vb
) if not uvedge
.uvface
.flipped
else (uvedge
.vb
, uvedge
.va
)
1173 edge
= first_vertex
.co
- second_vertex
.co
1174 sticker_width
= min(default_width
, edge
.length
/ 2)
1175 other_first
, other_second
= (other
.va
, other
.vb
) if not other
.uvface
.flipped
else (other
.vb
, other
.va
)
1176 other_edge
= other_second
.co
- other_first
.co
1178 # angle a is at vertex uvedge.va, b is at uvedge.vb
1180 sin_a
= sin_b
= 0.75**0.5
1181 # len_a is length of the side adjacent to vertex a, len_b likewise
1182 len_a
= len_b
= sticker_width
/ sin_a
1184 # fix overlaps with the most often neighbour - its sticking target
1185 if first_vertex
== other_second
:
1186 cos_a
= max(cos_a
, edge
.dot(other_edge
) / (edge
.length_squared
)) # angles between pi/3 and 0
1187 elif second_vertex
== other_first
:
1188 cos_b
= max(cos_b
, edge
.dot(other_edge
) / (edge
.length_squared
)) # angles between pi/3 and 0
1190 # Fix tabs for sticking targets with small angles
1192 other_face_neighbor_left
= other
.neighbor_left
1193 other_face_neighbor_right
= other
.neighbor_right
1194 other_edge_neighbor_a
= other_face_neighbor_left
.vb
.co
- other
.vb
.co
1195 other_edge_neighbor_b
= other_face_neighbor_right
.va
.co
- other
.va
.co
1196 # Adjacent angles in the face
1197 cos_a
= max(cos_a
, -other_edge
.dot(other_edge_neighbor_a
) / (other_edge
.length
*other_edge_neighbor_a
.length
))
1198 cos_b
= max(cos_b
, other_edge
.dot(other_edge_neighbor_b
) / (other_edge
.length
*other_edge_neighbor_b
.length
))
1199 except AttributeError: # neighbor data may be missing for edges with 3+ faces
1201 except ZeroDivisionError:
1204 # Calculate the lengths of the glue tab edges using the possibly smaller angles
1205 sin_a
= abs(1 - cos_a
**2)**0.5
1206 len_b
= min(len_a
, (edge
.length
* sin_a
) / (sin_a
* cos_b
+ sin_b
* cos_a
))
1207 len_a
= 0 if sin_a
== 0 else min(sticker_width
/ sin_a
, (edge
.length
- len_b
*cos_b
) / cos_a
)
1209 sin_b
= abs(1 - cos_b
**2)**0.5
1210 len_a
= min(len_a
, (edge
.length
* sin_b
) / (sin_a
* cos_b
+ sin_b
* cos_a
))
1211 len_b
= 0 if sin_b
== 0 else min(sticker_width
/ sin_b
, (edge
.length
- len_a
* cos_a
) / cos_b
)
1213 v3
= UVVertex(second_vertex
.co
+ M
.Matrix(((cos_b
, -sin_b
), (sin_b
, cos_b
))) @ edge
* len_b
/ edge
.length
)
1214 v4
= UVVertex(first_vertex
.co
+ M
.Matrix(((-cos_a
, -sin_a
), (sin_a
, -cos_a
))) @ edge
* len_a
/ edge
.length
)
1216 self
.vertices
= [second_vertex
, v3
, v4
, first_vertex
]
1218 self
.vertices
= [second_vertex
, v3
, first_vertex
]
1220 sin
, cos
= edge
.y
/ edge
.length
, edge
.x
/ edge
.length
1221 self
.rot
= M
.Matrix(((cos
, -sin
), (sin
, cos
)))
1222 self
.width
= sticker_width
* 0.9
1223 if index
and uvedge
.uvface
.island
is not other
.uvface
.island
:
1224 self
.text
= "{}:{}".format(other
.uvface
.island
.abbreviation
, index
)
1227 self
.center
= (uvedge
.va
.co
+ uvedge
.vb
.co
) / 2 + self
.rot
@ M
.Vector((0, self
.width
* 0.2))
1228 self
.bounds
= [v3
.co
, v4
.co
, self
.center
] if v3
.co
!= v4
.co
else [v3
.co
, self
.center
]
1232 """Mark in the document: numbering inside the island denoting edges to be sticked"""
1233 __slots__
= ('bounds', 'center', 'rot', 'text', 'size')
1235 def __init__(self
, uvedge
, index
, default_size
=0.005):
1236 """Sticker is directly attached to the given UVEdge"""
1237 edge
= (uvedge
.va
.co
- uvedge
.vb
.co
) if not uvedge
.uvface
.flipped
else (uvedge
.vb
.co
- uvedge
.va
.co
)
1239 self
.size
= default_size
1240 sin
, cos
= edge
.y
/ edge
.length
, edge
.x
/ edge
.length
1241 self
.rot
= M
.Matrix(((cos
, -sin
), (sin
, cos
)))
1243 self
.center
= (uvedge
.va
.co
+ uvedge
.vb
.co
) / 2 - self
.rot
@ M
.Vector((0, self
.size
* 1.2))
1244 self
.bounds
= [self
.center
]
1248 """Simple SVG exporter"""
1250 def __init__(self
, page_size
: M
.Vector
, style
, margin
, pure_net
=True, angle_epsilon
=0.01):
1251 """Initialize document settings.
1252 page_size: document dimensions in meters
1253 pure_net: if True, do not use image"""
1254 self
.page_size
= page_size
1255 self
.pure_net
= pure_net
1257 self
.margin
= margin
1259 self
.angle_epsilon
= angle_epsilon
1262 def encode_image(cls
, bpy_image
):
1265 with tempfile
.TemporaryDirectory() as directory
:
1266 filename
= directory
+ "/i.png"
1267 bpy_image
.filepath_raw
= filename
1269 return base64
.encodebytes(open(filename
, "rb").read()).decode('ascii')
1271 def format_vertex(self
, vector
, pos
=M
.Vector((0, 0))):
1272 """Return a string with both coordinates of the given vertex."""
1274 return "{:.6f} {:.6f}".format((x
+ self
.margin
) * 1000, (self
.page_size
.y
- y
- self
.margin
) * 1000)
1276 def write(self
, mesh
, filename
):
1277 """Write data to a file given by its name."""
1278 line_through
= " L ".join
# used for formatting of SVG path data
1281 dl
= ["{:.2f}".format(length
* self
.style
.line_width
* 1000) for length
in (2, 5, 10)]
1283 'SOLID': "none", 'DOT': "{0},{1}".format(*dl
), 'DASH': "{1},{2}".format(*dl
),
1284 'LONGDASH': "{2},{1}".format(*dl
), 'DASHDOT': "{2},{1},{0},{1}".format(*dl
)}
1286 def format_color(vec
):
1287 return "#{:02x}{:02x}{:02x}".format(round(vec
[0] * 255), round(vec
[1] * 255), round(vec
[2] * 255))
1289 def format_matrix(matrix
):
1290 return " ".join("{:.6f}".format(cell
) for column
in matrix
for cell
in column
)
1292 def path_convert(string
, relto
=os_path
.dirname(filename
)):
1293 assert(os_path
) # check the module was imported
1294 string
= os_path
.relpath(string
, relto
)
1295 if os_path
.sep
!= '/':
1296 string
= string
.replace(os_path
.sep
, '/')
1300 name
: format_color(getattr(self
.style
, name
)) for name
in (
1301 "outer_color", "outbg_color", "convex_color", "concave_color", "freestyle_color",
1302 "inbg_color", "sticker_fill", "text_color")}
1304 name
: format_style
[getattr(self
.style
, name
)] for name
in
1305 ("outer_style", "convex_style", "concave_style", "freestyle_style")})
1307 name
: getattr(self
.style
, attr
)[3] for name
, attr
in (
1308 ("outer_alpha", "outer_color"), ("outbg_alpha", "outbg_color"),
1309 ("convex_alpha", "convex_color"), ("concave_alpha", "concave_color"),
1310 ("freestyle_alpha", "freestyle_color"),
1311 ("inbg_alpha", "inbg_color"), ("sticker_alpha", "sticker_fill"),
1312 ("text_alpha", "text_color"))})
1314 name
: getattr(self
.style
, name
) * self
.style
.line_width
* 1000 for name
in
1315 ("outer_width", "convex_width", "concave_width", "freestyle_width", "outbg_width", "inbg_width")})
1316 for num
, page
in enumerate(mesh
.pages
):
1317 page_filename
= "{}_{}.svg".format(filename
[:filename
.rfind(".svg")], page
.name
) if len(mesh
.pages
) > 1 else filename
1318 with
open(page_filename
, 'w') as f
:
1319 print(self
.svg_base
.format(width
=self
.page_size
.x
*1000, height
=self
.page_size
.y
*1000), file=f
)
1320 print(self
.css_base
.format(**styleargs
), file=f
)
1323 self
.image_linked_tag
.format(
1324 pos
="{0:.6f} {0:.6f}".format(self
.margin
*1000),
1325 width
=(self
.page_size
.x
- 2 * self
.margin
)*1000,
1326 height
=(self
.page_size
.y
- 2 * self
.margin
)*1000,
1327 path
=path_convert(page
.image_path
)),
1329 if len(page
.islands
) > 1:
1330 print("<g>", file=f
)
1332 for island
in page
.islands
:
1333 print("<g>", file=f
)
1334 if island
.image_path
:
1336 self
.image_linked_tag
.format(
1337 pos
=self
.format_vertex(island
.pos
+ M
.Vector((0, island
.bounding_box
.y
))),
1338 width
=island
.bounding_box
.x
*1000,
1339 height
=island
.bounding_box
.y
*1000,
1340 path
=path_convert(island
.image_path
)),
1342 elif island
.embedded_image
:
1344 self
.image_embedded_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
=island
.image_path
),
1349 island
.embedded_image
, "'/>",
1353 self
.text_tag
.format(
1354 size
=1000 * self
.text_size
,
1355 x
=1000 * (island
.bounding_box
.x
*0.5 + island
.pos
.x
+ self
.margin
),
1356 y
=1000 * (self
.page_size
.y
- island
.pos
.y
- self
.margin
- 0.2 * self
.text_size
),
1357 label
=island
.title
),
1360 data_markers
, data_stickerfill
, data_outer
, data_convex
, data_concave
, data_freestyle
= (list() for i
in range(6))
1361 for marker
in island
.markers
:
1362 if isinstance(marker
, Sticker
):
1363 data_stickerfill
.append("M {} Z".format(
1364 line_through(self
.format_vertex(vertex
.co
, island
.pos
) for vertex
in marker
.vertices
)))
1366 data_markers
.append(self
.text_transformed_tag
.format(
1368 pos
=self
.format_vertex(marker
.center
, island
.pos
),
1369 mat
=format_matrix(marker
.rot
),
1370 size
=marker
.width
* 1000))
1371 elif isinstance(marker
, Arrow
):
1372 size
= marker
.size
* 1000
1373 position
= marker
.center
+ marker
.size
* marker
.rot
@ M
.Vector((0, -0.9))
1374 data_markers
.append(self
.arrow_marker_tag
.format(
1376 arrow_pos
=self
.format_vertex(marker
.center
, island
.pos
),
1378 pos
=self
.format_vertex(position
, island
.pos
- marker
.size
*M
.Vector((0, 0.4))),
1379 mat
=format_matrix(size
* marker
.rot
)))
1380 elif isinstance(marker
, NumberAlone
):
1381 data_markers
.append(self
.text_transformed_tag
.format(
1383 pos
=self
.format_vertex(marker
.center
, island
.pos
),
1384 mat
=format_matrix(marker
.rot
),
1385 size
=marker
.size
* 1000))
1386 if data_stickerfill
and self
.style
.sticker_fill
[3] > 0:
1387 print("<path class='sticker' d='", rows(data_stickerfill
), "'/>", file=f
)
1389 outer_edges
= set(island
.boundary
)
1392 uvedge
= outer_edges
.pop()
1395 data_loop
.extend(self
.format_vertex(vertex
.co
, island
.pos
) for vertex
in uvedge
.sticker
.vertices
[1:])
1397 vertex
= uvedge
.vb
if uvedge
.uvface
.flipped
else uvedge
.va
1398 data_loop
.append(self
.format_vertex(vertex
.co
, island
.pos
))
1399 uvedge
= uvedge
.neighbor_right
1401 outer_edges
.remove(uvedge
)
1404 data_outer
.append("M {} Z".format(line_through(data_loop
)))
1406 visited_edges
= set()
1407 for loop
, uvedge
in island
.edges
.items():
1408 edge
= mesh
.edges
[loop
.edge
]
1409 if edge
.is_cut(uvedge
.uvface
.face
) and not uvedge
.sticker
:
1411 data_uvedge
= "M {}".format(
1412 line_through(self
.format_vertex(vertex
.co
, island
.pos
) for vertex
in (uvedge
.va
, uvedge
.vb
)))
1414 data_freestyle
.append(data_uvedge
)
1415 # each uvedge is in two opposite-oriented variants; we want to add each only once
1416 vertex_pair
= frozenset((uvedge
.va
, uvedge
.vb
))
1417 if vertex_pair
not in visited_edges
:
1418 visited_edges
.add(vertex_pair
)
1419 if edge
.angle
> self
.angle_epsilon
:
1420 data_convex
.append(data_uvedge
)
1421 elif edge
.angle
< -self
.angle_epsilon
:
1422 data_concave
.append(data_uvedge
)
1423 if island
.is_inside_out
:
1424 data_convex
, data_concave
= data_concave
, data_convex
1427 print("<path class='freestyle' d='", rows(data_freestyle
), "'/>", file=f
)
1428 if (data_convex
or data_concave
) and not self
.pure_net
and self
.style
.use_inbg
:
1429 print("<path class='inner_background' d='", rows(data_convex
+ data_concave
), "'/>", file=f
)
1431 print("<path class='convex' d='", rows(data_convex
), "'/>", file=f
)
1433 print("<path class='concave' d='", rows(data_concave
), "'/>", file=f
)
1435 if not self
.pure_net
and self
.style
.use_outbg
:
1436 print("<path class='outer_background' d='", rows(data_outer
), "'/>", file=f
)
1437 print("<path class='outer' d='", rows(data_outer
), "'/>", file=f
)
1439 print(rows(data_markers
), file=f
)
1440 print("</g>", file=f
)
1442 if len(page
.islands
) > 1:
1443 print("</g>", file=f
)
1444 print("</svg>", file=f
)
1446 image_linked_tag
= "<image transform='translate({pos})' width='{width:.6f}' height='{height:.6f}' xlink:href='{path}'/>"
1447 image_embedded_tag
= "<image transform='translate({pos})' width='{width:.6f}' height='{height:.6f}' xlink:href='data:image/png;base64,"
1448 text_tag
= "<text transform='translate({x} {y})' style='font-size:{size:.2f}'><tspan>{label}</tspan></text>"
1449 text_transformed_tag
= "<text transform='matrix({mat} {pos})' style='font-size:{size:.2f}'><tspan>{label}</tspan></text>"
1450 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'/>" \
1451 "<text transform='translate({pos})' style='font-size:{scale:.2f}'><tspan>{index}</tspan></text></g>"
1453 svg_base
= """<?xml version='1.0' encoding='UTF-8' standalone='no'?>
1454 <svg xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' version='1.1'
1455 width='{width:.2f}mm' height='{height:.2f}mm' viewBox='0 0 {width:.2f} {height:.2f}'>"""
1457 css_base
= """<style type="text/css">
1460 stroke-linecap: butt;
1461 stroke-linejoin: bevel;
1462 stroke-dasharray: none;
1465 stroke: {outer_color};
1466 stroke-dasharray: {outer_style};
1467 stroke-dashoffset: 0;
1468 stroke-width: {outer_width:.2};
1469 stroke-opacity: {outer_alpha:.2};
1472 stroke: {convex_color};
1473 stroke-dasharray: {convex_style};
1474 stroke-dashoffset:0;
1475 stroke-width:{convex_width:.2};
1476 stroke-opacity: {convex_alpha:.2}
1479 stroke: {concave_color};
1480 stroke-dasharray: {concave_style};
1481 stroke-dashoffset: 0;
1482 stroke-width: {concave_width:.2};
1483 stroke-opacity: {concave_alpha:.2}
1486 stroke: {freestyle_color};
1487 stroke-dasharray: {freestyle_style};
1488 stroke-dashoffset: 0;
1489 stroke-width: {freestyle_width:.2};
1490 stroke-opacity: {freestyle_alpha:.2}
1492 path.outer_background {{
1493 stroke: {outbg_color};
1494 stroke-opacity: {outbg_alpha};
1495 stroke-width: {outbg_width:.2}
1497 path.inner_background {{
1498 stroke: {inbg_color};
1499 stroke-opacity: {inbg_alpha};
1500 stroke-width: {inbg_width:.2}
1503 fill: {sticker_fill};
1505 fill-opacity: {sticker_alpha:.2};
1513 fill-opacity: {text_alpha:.2};
1523 """Simple PDF exporter"""
1525 mm_to_pt
= 72 / 25.4
1526 character_width_packed
= {
1527 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·ÌÍÎÏìíîï',
1528 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¿ßø',
1529 667: '&ABEKPSVXY\x8a\x9fÀÁÂÃÄÅÈÉÊËÝÞ', 722: 'CDHNRUwÇÐÑÙÚÛÜ', 737: '©®', 778: 'GOQÒÓÔÕÖØ', 833: 'Mm¼½¾', 889: '%æ', 944: 'W\x9c', 1000: '\x85\x89\x8c\x97\x99Æ', 1015: '@', }
1530 character_width
= {c
: value
for (value
, chars
) in character_width_packed
.items() for c
in chars
}
1532 def __init__(self
, page_size
: M
.Vector
, style
, margin
, pure_net
=True, angle_epsilon
=0.01):
1533 self
.page_size
= page_size
1535 self
.margin
= M
.Vector((margin
, margin
))
1536 self
.pure_net
= pure_net
1537 self
.angle_epsilon
= angle_epsilon
1539 def text_width(self
, text
, scale
=None):
1540 return (scale
or self
.text_size
) * sum(self
.character_width
.get(c
, 556) for c
in text
) / 1000
1543 def encode_image(cls
, bpy_image
):
1544 data
= bytes(int(255 * px
) for (i
, px
) in enumerate(bpy_image
.pixels
) if i
% 4 != 3)
1546 "Type": "XObject", "Subtype": "Image", "Width": bpy_image
.size
[0], "Height": bpy_image
.size
[1],
1547 "ColorSpace": "DeviceRGB", "BitsPerComponent": 8, "Interpolate": True,
1548 "Filter": ["ASCII85Decode", "FlateDecode"], "stream": data
}
1551 def write(self
, mesh
, filename
):
1552 def format_dict(obj
, refs
=tuple()):
1553 return "<< " + "".join("/{} {}\n".format(key
, format_value(value
, refs
)) for (key
, value
) in obj
.items()) + ">>"
1555 def line_through(seq
):
1556 return "".join("{0.x:.6f} {0.y:.6f} {1} ".format(1000*v
.co
, c
) for (v
, c
) in zip(seq
, chain("m", repeat("l"))))
1558 def format_value(value
, refs
=tuple()):
1560 return "{} 0 R".format(refs
.index(value
) + 1)
1561 elif type(value
) is dict:
1562 return format_dict(value
, refs
)
1563 elif type(value
) in (list, tuple):
1564 return "[ " + " ".join(format_value(item
, refs
) for item
in value
) + " ]"
1565 elif type(value
) is int:
1567 elif type(value
) is float:
1568 return "{:.6f}".format(value
)
1569 elif type(value
) is bool:
1570 return "true" if value
else "false"
1572 return "/{}".format(value
) # this script can output only PDF names, no strings
1574 def write_object(index
, obj
, refs
, f
, stream
=None):
1575 byte_count
= f
.write("{} 0 obj\n".format(index
))
1576 if type(obj
) is not dict:
1577 stream
, obj
= obj
, dict()
1578 elif "stream" in obj
:
1579 stream
= obj
.pop("stream")
1581 if True or type(stream
) is bytes
:
1582 obj
["Filter"] = ["ASCII85Decode", "FlateDecode"]
1583 stream
= encode(stream
)
1584 obj
["Length"] = len(stream
)
1585 byte_count
+= f
.write(format_dict(obj
, refs
))
1587 byte_count
+= f
.write("\nstream\n")
1588 byte_count
+= f
.write(stream
)
1589 byte_count
+= f
.write("\nendstream")
1590 return byte_count
+ f
.write("\nendobj\n")
1593 from base64
import a85encode
1594 from zlib
import compress
1595 if hasattr(data
, "encode"):
1596 data
= data
.encode()
1597 return a85encode(compress(data
), adobe
=True, wrapcol
=250)[2:].decode()
1599 page_size_pt
= 1000 * self
.mm_to_pt
* self
.page_size
1600 root
= {"Type": "Pages", "MediaBox": [0, 0, page_size_pt
.x
, page_size_pt
.y
], "Kids": list()}
1601 catalog
= {"Type": "Catalog", "Pages": root
}
1603 "Type": "Font", "Subtype": "Type1", "Name": "F1",
1604 "BaseFont": "Helvetica", "Encoding": "MacRomanEncoding"}
1606 dl
= [length
* self
.style
.line_width
* 1000 for length
in (1, 4, 9)]
1608 'SOLID': list(), 'DOT': [dl
[0], dl
[1]], 'DASH': [dl
[1], dl
[2]],
1609 'LONGDASH': [dl
[2], dl
[1]], 'DASHDOT': [dl
[2], dl
[1], dl
[0], dl
[1]]}
1611 "Gtext": {"ca": self
.style
.text_color
[3], "Font": [font
, 1000 * self
.text_size
]},
1612 "Gsticker": {"ca": self
.style
.sticker_fill
[3]}}
1613 for name
in ("outer", "convex", "concave", "freestyle"):
1615 "LW": self
.style
.line_width
* 1000 * getattr(self
.style
, name
+ "_width"),
1616 "CA": getattr(self
.style
, name
+ "_color")[3],
1617 "D": [format_style
[getattr(self
.style
, name
+ "_style")], 0]}
1618 styles
["G" + name
] = gs
1619 for name
in ("outbg", "inbg"):
1621 "LW": self
.style
.line_width
* 1000 * getattr(self
.style
, name
+ "_width"),
1622 "CA": getattr(self
.style
, name
+ "_color")[3],
1623 "D": [format_style
['SOLID'], 0]}
1624 styles
["G" + name
] = gs
1626 objects
= [root
, catalog
, font
]
1627 objects
.extend(styles
.values())
1629 for page
in mesh
.pages
:
1630 commands
= ["{0:.6f} 0 0 {0:.6f} 0 0 cm".format(self
.mm_to_pt
)]
1631 resources
= {"Font": {"F1": font
}, "ExtGState": styles
, "XObject": dict()}
1632 for island
in page
.islands
:
1633 commands
.append("q 1 0 0 1 {0.x:.6f} {0.y:.6f} cm".format(1000*(self
.margin
+ island
.pos
)))
1634 if island
.embedded_image
:
1635 identifier
= "Im{}".format(len(resources
["XObject"]) + 1)
1636 commands
.append(self
.command_image
.format(1000 * island
.bounding_box
, identifier
))
1637 objects
.append(island
.embedded_image
)
1638 resources
["XObject"][identifier
] = island
.embedded_image
1641 commands
.append(self
.command_label
.format(
1642 size
=1000*self
.text_size
,
1643 x
=500 * (island
.bounding_box
.x
- self
.text_width(island
.title
)),
1644 y
=1000 * 0.2 * self
.text_size
,
1645 label
=island
.title
))
1647 data_markers
, data_stickerfill
, data_outer
, data_convex
, data_concave
, data_freestyle
= (list() for i
in range(6))
1648 for marker
in island
.markers
:
1649 if isinstance(marker
, Sticker
):
1650 data_stickerfill
.append(line_through(marker
.vertices
) + "f")
1652 data_markers
.append(self
.command_sticker
.format(
1654 pos
=1000*marker
.center
,
1656 align
=-500 * self
.text_width(marker
.text
, marker
.width
),
1657 size
=1000*marker
.width
))
1658 elif isinstance(marker
, Arrow
):
1659 size
= 1000 * marker
.size
1660 position
= 1000 * (marker
.center
+ marker
.size
* marker
.rot
@ M
.Vector((0, -0.9)))
1661 data_markers
.append(self
.command_arrow
.format(
1663 arrow_pos
=1000 * marker
.center
,
1664 pos
=position
- 1000 * M
.Vector((0.5 * self
.text_width(marker
.text
), 0.4 * self
.text_size
)),
1665 mat
=size
* marker
.rot
,
1667 elif isinstance(marker
, NumberAlone
):
1668 data_markers
.append(self
.command_number
.format(
1670 pos
=1000*marker
.center
,
1672 size
=1000*marker
.size
))
1674 outer_edges
= set(island
.boundary
)
1677 uvedge
= outer_edges
.pop()
1680 data_loop
.extend(uvedge
.sticker
.vertices
[1:])
1682 vertex
= uvedge
.vb
if uvedge
.uvface
.flipped
else uvedge
.va
1683 data_loop
.append(vertex
)
1684 uvedge
= uvedge
.neighbor_right
1686 outer_edges
.remove(uvedge
)
1689 data_outer
.append(line_through(data_loop
) + "s")
1691 for loop
, uvedge
in island
.edges
.items():
1692 edge
= mesh
.edges
[loop
.edge
]
1693 if edge
.is_cut(uvedge
.uvface
.face
) and not uvedge
.sticker
:
1695 data_uvedge
= line_through((uvedge
.va
, uvedge
.vb
)) + "S"
1697 data_freestyle
.append(data_uvedge
)
1698 # each uvedge exists in two opposite-oriented variants; we want to add each only once
1699 if uvedge
.sticker
or uvedge
.uvface
.flipped
!= (id(uvedge
.va
) > id(uvedge
.vb
)):
1700 if edge
.angle
> self
.angle_epsilon
:
1701 data_convex
.append(data_uvedge
)
1702 elif edge
.angle
< -self
.angle_epsilon
:
1703 data_concave
.append(data_uvedge
)
1704 if island
.is_inside_out
:
1705 data_convex
, data_concave
= data_concave
, data_convex
1707 if data_stickerfill
and self
.style
.sticker_fill
[3] > 0:
1708 commands
.append("/Gsticker gs {0[0]:.3f} {0[1]:.3f} {0[2]:.3f} rg".format(self
.style
.sticker_fill
))
1709 commands
.extend(data_stickerfill
)
1711 commands
.append("/Gfreestyle gs {0[0]:.3f} {0[1]:.3f} {0[2]:.3f} RG".format(self
.style
.freestyle_color
))
1712 commands
.extend(data_freestyle
)
1713 if (data_convex
or data_concave
) and not self
.pure_net
and self
.style
.use_inbg
:
1714 commands
.append("/Ginbg gs {0[0]:.3f} {0[1]:.3f} {0[2]:.3f} RG".format(self
.style
.inbg_color
))
1715 commands
.extend(chain(data_convex
, data_concave
))
1717 commands
.append("/Gconvex gs {0[0]:.3f} {0[1]:.3f} {0[2]:.3f} RG".format(self
.style
.convex_color
))
1718 commands
.extend(data_convex
)
1720 commands
.append("/Gconcave gs {0[0]:.3f} {0[1]:.3f} {0[2]:.3f} RG".format(self
.style
.concave_color
))
1721 commands
.extend(data_concave
)
1723 if not self
.pure_net
and self
.style
.use_outbg
:
1724 commands
.append("/Goutbg gs {0[0]:.3f} {0[1]:.3f} {0[2]:.3f} RG".format(self
.style
.outbg_color
))
1725 commands
.extend(data_outer
)
1726 commands
.append("/Gouter gs {0[0]:.3f} {0[1]:.3f} {0[2]:.3f} RG".format(self
.style
.outer_color
))
1727 commands
.extend(data_outer
)
1728 commands
.append("/Gtext gs {0[0]:.3f} {0[1]:.3f} {0[2]:.3f} rg".format(self
.style
.text_color
))
1729 commands
.extend(data_markers
)
1730 commands
.append("Q")
1731 content
= "\n".join(commands
)
1732 page
= {"Type": "Page", "Parent": root
, "Contents": content
, "Resources": resources
}
1733 root
["Kids"].append(page
)
1734 objects
.extend((page
, content
))
1736 root
["Count"] = len(root
["Kids"])
1737 with
open(filename
, "w+") as f
:
1739 position
= f
.write("%PDF-1.4\n")
1740 for index
, obj
in enumerate(objects
, 1):
1741 xref_table
.append(position
)
1742 position
+= write_object(index
, obj
, objects
, f
)
1744 f
.write("xref_table\n0 {}\n".format(len(xref_table
) + 1))
1745 f
.write("{:010} {:05} f\n".format(0, 65536))
1746 for position
in xref_table
:
1747 f
.write("{:010} {:05} n\n".format(position
, 0))
1748 f
.write("trailer\n")
1749 f
.write(format_dict({"Size": len(xref_table
), "Root": catalog
}, objects
))
1750 f
.write("\nstartxref\n{}\n%%EOF\n".format(xref_pos
))
1752 command_label
= "/Gtext gs BT {x:.6f} {y:.6f} Td ({label}) Tj ET"
1753 command_image
= "q {0.x:.6f} 0 0 {0.y:.6f} 0 0 cm 1 0 0 -1 0 1 cm /{1} Do Q"
1754 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"
1755 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"
1756 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"
1759 class Unfold(bpy
.types
.Operator
):
1760 """Blender Operator: unfold the selected object."""
1762 bl_idname
= "mesh.unfold"
1764 bl_description
= "Mark seams so that the mesh can be exported as a paper model"
1765 bl_options
= {'REGISTER', 'UNDO'}
1766 edit
: bpy
.props
.BoolProperty(default
=False, options
={'HIDDEN'})
1767 priority_effect_convex
: bpy
.props
.FloatProperty(
1768 name
="Priority Convex", description
="Priority effect for edges in convex angles",
1769 default
=default_priority_effect
['CONVEX'], soft_min
=-1, soft_max
=10, subtype
='FACTOR')
1770 priority_effect_concave
: bpy
.props
.FloatProperty(
1771 name
="Priority Concave", description
="Priority effect for edges in concave angles",
1772 default
=default_priority_effect
['CONCAVE'], soft_min
=-1, soft_max
=10, subtype
='FACTOR')
1773 priority_effect_length
: bpy
.props
.FloatProperty(
1774 name
="Priority Length", description
="Priority effect of edge length",
1775 default
=default_priority_effect
['LENGTH'], soft_min
=-10, soft_max
=1, subtype
='FACTOR')
1776 do_create_uvmap
: bpy
.props
.BoolProperty(
1777 name
="Create UVMap", description
="Create a new UV Map showing the islands and page layout", default
=False)
1781 def poll(cls
, context
):
1782 return context
.active_object
and context
.active_object
.type == "MESH"
1784 def draw(self
, context
):
1785 layout
= self
.layout
1786 col
= layout
.column()
1787 col
.active
= not self
.object or len(self
.object.data
.uv_layers
) < 8
1788 col
.prop(self
.properties
, "do_create_uvmap")
1789 layout
.label(text
="Edge Cutting Factors:")
1790 col
= layout
.column(align
=True)
1791 col
.label(text
="Face Angle:")
1792 col
.prop(self
.properties
, "priority_effect_convex", text
="Convex")
1793 col
.prop(self
.properties
, "priority_effect_concave", text
="Concave")
1794 layout
.prop(self
.properties
, "priority_effect_length", text
="Edge Length")
1796 def execute(self
, context
):
1797 sce
= bpy
.context
.scene
1798 settings
= sce
.paper_model
1799 recall_mode
= context
.object.mode
1800 bpy
.ops
.object.mode_set(mode
='EDIT')
1802 self
.object = context
.object
1804 cage_size
= M
.Vector((settings
.output_size_x
, settings
.output_size_y
))
1806 'CONVEX': self
.priority_effect_convex
,
1807 'CONCAVE': self
.priority_effect_concave
,
1808 'LENGTH': self
.priority_effect_length
}
1810 unfolder
= Unfolder(self
.object)
1811 unfolder
.do_create_uvmap
= self
.do_create_uvmap
1812 scale
= sce
.unit_settings
.scale_length
/ settings
.scale
1813 unfolder
.prepare(cage_size
, priority_effect
, scale
, settings
.limit_by_page
)
1814 unfolder
.mesh
.mark_cuts()
1815 except UnfoldError
as error
:
1816 self
.report(type={'ERROR_INVALID_INPUT'}, message
=error
.args
[0])
1818 bpy
.ops
.object.mode_set(mode
=recall_mode
)
1819 return {'CANCELLED'}
1820 mesh
= self
.object.data
1822 if mesh
.paper_island_list
:
1823 unfolder
.copy_island_names(mesh
.paper_island_list
)
1824 island_list
= mesh
.paper_island_list
1825 attributes
= {item
.label
: (item
.abbreviation
, item
.auto_label
, item
.auto_abbrev
) for item
in island_list
}
1826 island_list
.clear() # remove previously defined islands
1827 for island
in unfolder
.mesh
.islands
:
1828 # add islands to UI list and set default descriptions
1829 list_item
= island_list
.add()
1830 # add faces' IDs to the island
1831 for face
in island
.faces
:
1832 lface
= list_item
.faces
.add()
1833 lface
.id = face
.index
1834 list_item
["label"] = island
.label
1835 list_item
["abbreviation"], list_item
["auto_label"], list_item
["auto_abbrev"] = attributes
.get(
1837 (island
.abbreviation
, True, True))
1838 island_item_changed(list_item
, context
)
1839 mesh
.paper_island_index
= -1
1842 bpy
.ops
.object.mode_set(mode
=recall_mode
)
1846 class ClearAllSeams(bpy
.types
.Operator
):
1847 """Blender Operator: clear all seams of the active Mesh and all its unfold data"""
1849 bl_idname
= "mesh.clear_all_seams"
1850 bl_label
= "Clear All Seams"
1851 bl_description
= "Clear all the seams and unfolded islands of the active object"
1854 def poll(cls
, context
):
1855 return context
.active_object
and context
.active_object
.type == 'MESH'
1857 def execute(self
, context
):
1858 ob
= context
.active_object
1861 for edge
in mesh
.edges
:
1862 edge
.use_seam
= False
1863 mesh
.paper_island_list
.clear()
1868 def page_size_preset_changed(self
, context
):
1869 """Update the actual document size to correct values"""
1870 if hasattr(self
, "limit_by_page") and not self
.limit_by_page
:
1872 if self
.page_size_preset
== 'A4':
1873 self
.output_size_x
= 0.210
1874 self
.output_size_y
= 0.297
1875 elif self
.page_size_preset
== 'A3':
1876 self
.output_size_x
= 0.297
1877 self
.output_size_y
= 0.420
1878 elif self
.page_size_preset
== 'US_LETTER':
1879 self
.output_size_x
= 0.216
1880 self
.output_size_y
= 0.279
1881 elif self
.page_size_preset
== 'US_LEGAL':
1882 self
.output_size_x
= 0.216
1883 self
.output_size_y
= 0.356
1886 class PaperModelStyle(bpy
.types
.PropertyGroup
):
1888 ('SOLID', "Solid (----)", "Solid line"),
1889 ('DOT', "Dots (. . .)", "Dotted line"),
1890 ('DASH', "Short Dashes (- - -)", "Solid line"),
1891 ('LONGDASH', "Long Dashes (-- --)", "Solid line"),
1892 ('DASHDOT', "Dash-dotted (-- .)", "Solid line")
1894 outer_color
: bpy
.props
.FloatVectorProperty(
1895 name
="Outer Lines", description
="Color of net outline",
1896 default
=(0.0, 0.0, 0.0, 1.0), min=0, max=1, subtype
='COLOR', size
=4)
1897 outer_style
: bpy
.props
.EnumProperty(
1898 name
="Outer Lines Drawing Style", description
="Drawing style of net outline",
1899 default
='SOLID', items
=line_styles
)
1900 line_width
: bpy
.props
.FloatProperty(
1901 name
="Base Lines Thickness", description
="Base thickness of net lines, each actual value is a multiple of this length",
1902 default
=1e-4, min=0, soft_max
=5e-3, precision
=5, step
=1e-2, subtype
="UNSIGNED", unit
="LENGTH")
1903 outer_width
: bpy
.props
.FloatProperty(
1904 name
="Outer Lines Thickness", description
="Relative thickness of net outline",
1905 default
=3, min=0, soft_max
=10, precision
=1, step
=10, subtype
='FACTOR')
1906 use_outbg
: bpy
.props
.BoolProperty(
1907 name
="Highlight Outer Lines", description
="Add another line below every line to improve contrast",
1909 outbg_color
: bpy
.props
.FloatVectorProperty(
1910 name
="Outer Highlight", description
="Color of the highlight for outer lines",
1911 default
=(1.0, 1.0, 1.0, 1.0), min=0, max=1, subtype
='COLOR', size
=4)
1912 outbg_width
: bpy
.props
.FloatProperty(
1913 name
="Outer Highlight Thickness", description
="Relative thickness of the highlighting lines",
1914 default
=5, min=0, soft_max
=10, precision
=1, step
=10, subtype
='FACTOR')
1916 convex_color
: bpy
.props
.FloatVectorProperty(
1917 name
="Inner Convex Lines", description
="Color of lines to be folded to a convex angle",
1918 default
=(0.0, 0.0, 0.0, 1.0), min=0, max=1, subtype
='COLOR', size
=4)
1919 convex_style
: bpy
.props
.EnumProperty(
1920 name
="Convex Lines Drawing Style", description
="Drawing style of lines to be folded to a convex angle",
1921 default
='DASH', items
=line_styles
)
1922 convex_width
: bpy
.props
.FloatProperty(
1923 name
="Convex Lines Thickness", description
="Relative thickness of concave lines",
1924 default
=2, min=0, soft_max
=10, precision
=1, step
=10, subtype
='FACTOR')
1925 concave_color
: bpy
.props
.FloatVectorProperty(
1926 name
="Inner Concave Lines", description
="Color of lines to be folded to a concave angle",
1927 default
=(0.0, 0.0, 0.0, 1.0), min=0, max=1, subtype
='COLOR', size
=4)
1928 concave_style
: bpy
.props
.EnumProperty(
1929 name
="Concave Lines Drawing Style", description
="Drawing style of lines to be folded to a concave angle",
1930 default
='DASHDOT', items
=line_styles
)
1931 concave_width
: bpy
.props
.FloatProperty(
1932 name
="Concave Lines Thickness", description
="Relative thickness of concave lines",
1933 default
=2, min=0, soft_max
=10, precision
=1, step
=10, subtype
='FACTOR')
1934 freestyle_color
: bpy
.props
.FloatVectorProperty(
1935 name
="Freestyle Edges", description
="Color of lines marked as Freestyle Edge",
1936 default
=(0.0, 0.0, 0.0, 1.0), min=0, max=1, subtype
='COLOR', size
=4)
1937 freestyle_style
: bpy
.props
.EnumProperty(
1938 name
="Freestyle Edges Drawing Style", description
="Drawing style of Freestyle Edges",
1939 default
='SOLID', items
=line_styles
)
1940 freestyle_width
: bpy
.props
.FloatProperty(
1941 name
="Freestyle Edges Thickness", description
="Relative thickness of Freestyle edges",
1942 default
=2, min=0, soft_max
=10, precision
=1, step
=10, subtype
='FACTOR')
1943 use_inbg
: bpy
.props
.BoolProperty(
1944 name
="Highlight Inner Lines", description
="Add another line below every line to improve contrast",
1946 inbg_color
: bpy
.props
.FloatVectorProperty(
1947 name
="Inner Highlight", description
="Color of the highlight for inner lines",
1948 default
=(1.0, 1.0, 1.0, 1.0), min=0, max=1, subtype
='COLOR', size
=4)
1949 inbg_width
: bpy
.props
.FloatProperty(
1950 name
="Inner Highlight Thickness", description
="Relative thickness of the highlighting lines",
1951 default
=2, min=0, soft_max
=10, precision
=1, step
=10, subtype
='FACTOR')
1953 sticker_fill
: bpy
.props
.FloatVectorProperty(
1954 name
="Tabs Fill", description
="Fill color of sticking tabs",
1955 default
=(0.9, 0.9, 0.9, 1.0), min=0, max=1, subtype
='COLOR', size
=4)
1956 text_color
: bpy
.props
.FloatVectorProperty(
1957 name
="Text Color", description
="Color of all text used in the document",
1958 default
=(0.0, 0.0, 0.0, 1.0), min=0, max=1, subtype
='COLOR', size
=4)
1959 bpy
.utils
.register_class(PaperModelStyle
)
1962 class ExportPaperModel(bpy
.types
.Operator
):
1963 """Blender Operator: save the selected object's net and optionally bake its texture"""
1965 bl_idname
= "export_mesh.paper_model"
1966 bl_label
= "Export Paper Model"
1967 bl_description
= "Export the selected object's net and optionally bake its texture"
1968 filepath
: bpy
.props
.StringProperty(
1969 name
="File Path", description
="Target file to save the SVG", options
={'SKIP_SAVE'})
1970 filename
: bpy
.props
.StringProperty(
1971 name
="File Name", description
="Name of the file", options
={'SKIP_SAVE'})
1972 directory
: bpy
.props
.StringProperty(
1973 name
="Directory", description
="Directory of the file", options
={'SKIP_SAVE'})
1974 page_size_preset
: bpy
.props
.EnumProperty(
1975 name
="Page Size", description
="Size of the exported document",
1976 default
='A4', update
=page_size_preset_changed
, items
=global_paper_sizes
)
1977 output_size_x
: bpy
.props
.FloatProperty(
1978 name
="Page Width", description
="Width of the exported document",
1979 default
=0.210, soft_min
=0.105, soft_max
=0.841, subtype
="UNSIGNED", unit
="LENGTH")
1980 output_size_y
: bpy
.props
.FloatProperty(
1981 name
="Page Height", description
="Height of the exported document",
1982 default
=0.297, soft_min
=0.148, soft_max
=1.189, subtype
="UNSIGNED", unit
="LENGTH")
1983 output_margin
: bpy
.props
.FloatProperty(
1984 name
="Page Margin", description
="Distance from page borders to the printable area",
1985 default
=0.005, min=0, soft_max
=0.1, step
=0.1, subtype
="UNSIGNED", unit
="LENGTH")
1986 output_type
: bpy
.props
.EnumProperty(
1987 name
="Textures", description
="Source of a texture for the model",
1988 default
='NONE', items
=[
1989 ('NONE', "No Texture", "Export the net only"),
1990 ('TEXTURE', "From Materials", "Render the diffuse color and all painted textures"),
1991 ('AMBIENT_OCCLUSION', "Ambient Occlusion", "Render the Ambient Occlusion pass"),
1992 ('RENDER', "Full Render", "Render the material in actual scene illumination"),
1993 ('SELECTED_TO_ACTIVE', "Selected to Active", "Render all selected surrounding objects as a texture")
1995 do_create_stickers
: bpy
.props
.BoolProperty(
1996 name
="Create Tabs", description
="Create gluing tabs around the net (useful for paper)",
1998 do_create_numbers
: bpy
.props
.BoolProperty(
1999 name
="Create Numbers", description
="Enumerate edges to make it clear which edges should be sticked together",
2001 sticker_width
: bpy
.props
.FloatProperty(
2002 name
="Tabs and Text Size", description
="Width of gluing tabs and their numbers",
2003 default
=0.005, soft_min
=0, soft_max
=0.05, step
=0.1, subtype
="UNSIGNED", unit
="LENGTH")
2004 angle_epsilon
: bpy
.props
.FloatProperty(
2005 name
="Hidden Edge Angle", description
="Folds with angle below this limit will not be drawn",
2006 default
=pi
/360, min=0, soft_max
=pi
/4, step
=0.01, subtype
="ANGLE", unit
="ROTATION")
2007 output_dpi
: bpy
.props
.FloatProperty(
2008 name
="Resolution (DPI)", description
="Resolution of images in pixels per inch",
2009 default
=90, min=1, soft_min
=30, soft_max
=600, subtype
="UNSIGNED")
2010 bake_samples
: bpy
.props
.IntProperty(
2011 name
="Samples", description
="Number of samples to render for each pixel",
2012 default
=64, min=1, subtype
="UNSIGNED")
2013 file_format
: bpy
.props
.EnumProperty(
2014 name
="Document Format", description
="File format of the exported net",
2015 default
='PDF', items
=[
2016 ('PDF', "PDF", "Adobe Portable Document Format 1.4"),
2017 ('SVG', "SVG", "W3C Scalable Vector Graphics"),
2019 image_packing
: bpy
.props
.EnumProperty(
2020 name
="Image Packing Method", description
="Method of attaching baked image(s) to the SVG",
2021 default
='ISLAND_EMBED', items
=[
2022 ('PAGE_LINK', "Single Linked", "Bake one image per page of output and save it separately"),
2023 ('ISLAND_LINK', "Linked", "Bake images separately for each island and save them in a directory"),
2024 ('ISLAND_EMBED', "Embedded", "Bake images separately for each island and embed them into the SVG")
2026 scale
: bpy
.props
.FloatProperty(
2027 name
="Scale", description
="Divisor of all dimensions when exporting",
2028 default
=1, soft_min
=1.0, soft_max
=10000.0, step
=100, subtype
='UNSIGNED', precision
=1)
2029 do_create_uvmap
: bpy
.props
.BoolProperty(
2030 name
="Create UVMap", description
="Create a new UV Map showing the islands and page layout",
2031 default
=False, options
={'SKIP_SAVE'})
2032 ui_expanded_document
: bpy
.props
.BoolProperty(
2033 name
="Show Document Settings Expanded", description
="Shows the box 'Document Settings' expanded in user interface",
2034 default
=True, options
={'SKIP_SAVE'})
2035 ui_expanded_style
: bpy
.props
.BoolProperty(
2036 name
="Show Style Settings Expanded", description
="Shows the box 'Colors and Style' expanded in user interface",
2037 default
=False, options
={'SKIP_SAVE'})
2038 style
: bpy
.props
.PointerProperty(type=PaperModelStyle
)
2043 def poll(cls
, context
):
2044 return context
.active_object
and context
.active_object
.type == 'MESH'
2046 def prepare(self
, context
):
2048 self
.recall_mode
= context
.object.mode
2049 bpy
.ops
.object.mode_set(mode
='EDIT')
2051 self
.object = context
.active_object
2052 self
.unfolder
= Unfolder(self
.object)
2053 cage_size
= M
.Vector((sce
.paper_model
.output_size_x
, sce
.paper_model
.output_size_y
))
2054 self
.unfolder
.prepare(cage_size
, scale
=sce
.unit_settings
.scale_length
/self
.scale
, limit_by_page
=sce
.paper_model
.limit_by_page
)
2056 self
.scale
= ceil(self
.get_scale_ratio(sce
))
2061 bpy
.ops
.object.mode_set(mode
=self
.recall_mode
)
2063 def invoke(self
, context
, event
):
2064 self
.scale
= context
.scene
.paper_model
.scale
2066 self
.prepare(context
)
2067 except UnfoldError
as error
:
2068 self
.report(type={'ERROR_INVALID_INPUT'}, message
=error
.args
[0])
2071 return {'CANCELLED'}
2072 wm
= context
.window_manager
2073 wm
.fileselect_add(self
)
2074 return {'RUNNING_MODAL'}
2076 def execute(self
, context
):
2077 if not self
.unfolder
:
2078 self
.prepare(context
)
2079 self
.unfolder
.do_create_uvmap
= self
.do_create_uvmap
2081 if self
.object.data
.paper_island_list
:
2082 self
.unfolder
.copy_island_names(self
.object.data
.paper_island_list
)
2083 self
.unfolder
.save(self
.properties
)
2084 self
.report({'INFO'}, "Saved a {}-page document".format(len(self
.unfolder
.mesh
.pages
)))
2086 except UnfoldError
as error
:
2087 self
.report(type={'ERROR_INVALID_INPUT'}, message
=error
.args
[0])
2088 return {'CANCELLED'}
2092 def get_scale_ratio(self
, sce
):
2093 margin
= self
.output_margin
+ self
.sticker_width
2094 if min(self
.output_size_x
, self
.output_size_y
) <= 2 * margin
:
2096 output_inner_size
= M
.Vector((self
.output_size_x
- 2*margin
, self
.output_size_y
- 2*margin
))
2097 ratio
= self
.unfolder
.mesh
.largest_island_ratio(output_inner_size
)
2098 return ratio
* sce
.unit_settings
.scale_length
/ self
.scale
2100 def draw(self
, context
):
2101 layout
= self
.layout
2103 layout
.prop(self
.properties
, "do_create_uvmap")
2105 row
= layout
.row(align
=True)
2106 row
.menu("VIEW3D_MT_paper_model_presets", text
=bpy
.types
.VIEW3D_MT_paper_model_presets
.bl_label
)
2107 row
.operator("export_mesh.paper_model_preset_add", text
="", icon
='ADD')
2108 row
.operator("export_mesh.paper_model_preset_add", text
="", icon
='REMOVE').remove_active
= True
2110 # a little hack: this prints out something like "Scale: 1: 72"
2111 layout
.prop(self
.properties
, "scale", text
="Scale: 1")
2112 scale_ratio
= self
.get_scale_ratio(context
.scene
)
2115 text
="An island is roughly {:.1f}x bigger than page".format(scale_ratio
),
2117 elif scale_ratio
> 0:
2118 layout
.label(text
="Largest island is roughly 1/{:.1f} of page".format(1 / scale_ratio
))
2120 if context
.scene
.unit_settings
.scale_length
!= 1:
2122 text
="Unit scale {:.1f} makes page size etc. not display correctly".format(
2123 context
.scene
.unit_settings
.scale_length
), icon
="ERROR")
2125 row
= box
.row(align
=True)
2127 self
.properties
, "ui_expanded_document", text
="",
2128 icon
=('TRIA_DOWN' if self
.ui_expanded_document
else 'TRIA_RIGHT'), emboss
=False)
2129 row
.label(text
="Document Settings")
2131 if self
.ui_expanded_document
:
2132 box
.prop(self
.properties
, "file_format", text
="Format")
2133 box
.prop(self
.properties
, "page_size_preset")
2134 col
= box
.column(align
=True)
2135 col
.active
= self
.page_size_preset
== 'USER'
2136 col
.prop(self
.properties
, "output_size_x")
2137 col
.prop(self
.properties
, "output_size_y")
2138 box
.prop(self
.properties
, "output_margin")
2140 col
.prop(self
.properties
, "do_create_stickers")
2141 col
.prop(self
.properties
, "do_create_numbers")
2143 col
.active
= self
.do_create_stickers
or self
.do_create_numbers
2144 col
.prop(self
.properties
, "sticker_width")
2145 box
.prop(self
.properties
, "angle_epsilon")
2147 box
.prop(self
.properties
, "output_type")
2149 col
.active
= (self
.output_type
!= 'NONE')
2150 if len(self
.object.data
.uv_layers
) == 8:
2151 col
.label(text
="No UV slots left, No Texture is the only option.", icon
='ERROR')
2152 elif context
.scene
.render
.engine
!= 'CYCLES' and self
.output_type
!= 'NONE':
2153 col
.label(text
="Cycles will be used for texture baking.", icon
='ERROR')
2155 row
.active
= self
.output_type
in ('AMBIENT_OCCLUSION', 'RENDER', 'SELECTED_TO_ACTIVE')
2156 row
.prop(self
.properties
, "bake_samples")
2157 col
.prop(self
.properties
, "output_dpi")
2159 row
.active
= self
.file_format
== 'SVG'
2160 row
.prop(self
.properties
, "image_packing", text
="Images")
2163 row
= box
.row(align
=True)
2165 self
.properties
, "ui_expanded_style", text
="",
2166 icon
=('TRIA_DOWN' if self
.ui_expanded_style
else 'TRIA_RIGHT'), emboss
=False)
2167 row
.label(text
="Colors and Style")
2169 if self
.ui_expanded_style
:
2170 box
.prop(self
.style
, "line_width", text
="Default line width")
2172 col
.prop(self
.style
, "outer_color")
2173 col
.prop(self
.style
, "outer_width", text
="Relative width")
2174 col
.prop(self
.style
, "outer_style", text
="Style")
2176 col
.active
= self
.output_type
!= 'NONE'
2177 col
.prop(self
.style
, "use_outbg", text
="Outer Lines Highlight:")
2179 sub
.active
= self
.output_type
!= 'NONE' and self
.style
.use_outbg
2180 sub
.prop(self
.style
, "outbg_color", text
="")
2181 sub
.prop(self
.style
, "outbg_width", text
="Relative width")
2183 col
.prop(self
.style
, "convex_color")
2184 col
.prop(self
.style
, "convex_width", text
="Relative width")
2185 col
.prop(self
.style
, "convex_style", text
="Style")
2187 col
.prop(self
.style
, "concave_color")
2188 col
.prop(self
.style
, "concave_width", text
="Relative width")
2189 col
.prop(self
.style
, "concave_style", text
="Style")
2191 col
.prop(self
.style
, "freestyle_color")
2192 col
.prop(self
.style
, "freestyle_width", text
="Relative width")
2193 col
.prop(self
.style
, "freestyle_style", text
="Style")
2195 col
.active
= self
.output_type
!= 'NONE'
2196 col
.prop(self
.style
, "use_inbg", text
="Inner Lines Highlight:")
2198 sub
.active
= self
.output_type
!= 'NONE' and self
.style
.use_inbg
2199 sub
.prop(self
.style
, "inbg_color", text
="")
2200 sub
.prop(self
.style
, "inbg_width", text
="Relative width")
2202 col
.active
= self
.do_create_stickers
2203 col
.prop(self
.style
, "sticker_fill")
2204 box
.prop(self
.style
, "text_color")
2207 def menu_func_export(self
, context
):
2208 self
.layout
.operator("export_mesh.paper_model", text
="Paper Model (.pdf/.svg)")
2211 def menu_func_unfold(self
, context
):
2212 self
.layout
.operator("mesh.unfold", text
="Unfold")
2215 class SelectIsland(bpy
.types
.Operator
):
2216 """Blender Operator: select all faces of the active island"""
2218 bl_idname
= "mesh.select_paper_island"
2219 bl_label
= "Select Island"
2220 bl_description
= "Select an island of the paper model net"
2222 operation
: bpy
.props
.EnumProperty(
2223 name
="Operation", description
="Operation with the current selection",
2224 default
='ADD', items
=[
2225 ('ADD', "Add", "Add to current selection"),
2226 ('REMOVE', "Remove", "Remove from selection"),
2227 ('REPLACE', "Replace", "Select only the ")
2231 def poll(cls
, context
):
2232 return context
.active_object
and context
.active_object
.type == 'MESH' and context
.mode
== 'EDIT_MESH'
2234 def execute(self
, context
):
2235 ob
= context
.active_object
2237 bm
= bmesh
.from_edit_mesh(me
)
2238 island
= me
.paper_island_list
[me
.paper_island_index
]
2239 faces
= {face
.id for face
in island
.faces
}
2242 if self
.operation
== 'REPLACE':
2243 for face
in bm
.faces
:
2244 selected
= face
.index
in faces
2245 face
.select
= selected
2247 edges
.update(face
.edges
)
2248 verts
.update(face
.verts
)
2249 for edge
in bm
.edges
:
2250 edge
.select
= edge
in edges
2251 for vert
in bm
.verts
:
2252 vert
.select
= vert
in verts
2254 selected
= (self
.operation
== 'ADD')
2256 face
= bm
.faces
[index
]
2257 face
.select
= selected
2258 edges
.update(face
.edges
)
2259 verts
.update(face
.verts
)
2261 edge
.select
= any(face
.select
for face
in edge
.link_faces
)
2263 vert
.select
= any(edge
.select
for edge
in vert
.link_edges
)
2264 bmesh
.update_edit_mesh(me
, False, False)
2268 class VIEW3D_MT_paper_model_presets(bpy
.types
.Menu
):
2269 bl_label
= "Paper Model Presets"
2270 preset_subdir
= "export_mesh"
2271 preset_operator
= "script.execute_preset"
2272 draw
= bpy
.types
.Menu
.draw_preset
2275 class AddPresetPaperModel(bl_operators
.presets
.AddPresetBase
, bpy
.types
.Operator
):
2276 """Add or remove a Paper Model Preset"""
2277 bl_idname
= "export_mesh.paper_model_preset_add"
2278 bl_label
= "Add Paper Model Preset"
2279 preset_menu
= "VIEW3D_MT_paper_model_presets"
2280 preset_subdir
= "export_mesh"
2281 preset_defines
= ["op = bpy.context.active_operator"]
2284 def preset_values(self
):
2285 op
= bpy
.ops
.export_mesh
.paper_model
2286 properties
= op
.get_rna().bl_rna
.properties
.items()
2287 blacklist
= bpy
.types
.Operator
.bl_rna
.properties
.keys()
2289 "op.{}".format(prop_id
) for (prop_id
, prop
) in properties
2290 if not (prop
.is_hidden
or prop
.is_skip_save
or prop_id
in blacklist
)]
2293 class VIEW3D_PT_paper_model_tools(bpy
.types
.Panel
):
2295 bl_space_type
= "VIEW_3D"
2296 bl_region_type
= "TOOLS"
2297 bl_category
= "Paper Model"
2299 def draw(self
, context
):
2300 layout
= self
.layout
2302 obj
= context
.active_object
2303 mesh
= obj
.data
if obj
and obj
.type == 'MESH' else None
2305 layout
.operator("export_mesh.paper_model")
2307 col
= layout
.column(align
=True)
2308 col
.label(text
="Customization:")
2309 col
.operator("mesh.unfold")
2311 if context
.mode
== 'EDIT_MESH':
2312 row
= layout
.row(align
=True)
2313 row
.operator("mesh.mark_seam", text
="Mark Seam").clear
= False
2314 row
.operator("mesh.mark_seam", text
="Clear Seam").clear
= True
2316 layout
.operator("mesh.clear_all_seams")
2318 props
= sce
.paper_model
2319 layout
.prop(props
, "scale", text
="Model Scale: 1")
2321 layout
.prop(props
, "limit_by_page")
2322 col
= layout
.column()
2323 col
.active
= props
.limit_by_page
2324 col
.prop(props
, "page_size_preset")
2325 sub
= col
.column(align
=True)
2326 sub
.active
= props
.page_size_preset
== 'USER'
2327 sub
.prop(props
, "output_size_x")
2328 sub
.prop(props
, "output_size_y")
2331 class DATA_PT_paper_model_islands(bpy
.types
.Panel
):
2332 bl_space_type
= 'PROPERTIES'
2333 bl_region_type
= 'WINDOW'
2335 bl_label
= "Paper Model Islands"
2336 COMPAT_ENGINES
= {'BLENDER_RENDER', 'BLENDER_EEVEE', 'BLENDER_WORKBENCH'}
2338 def draw(self
, context
):
2339 layout
= self
.layout
2341 obj
= context
.active_object
2342 mesh
= obj
.data
if obj
and obj
.type == 'MESH' else None
2344 layout
.operator("mesh.unfold", icon
='FILE_REFRESH')
2345 if mesh
and mesh
.paper_island_list
:
2347 text
="1 island:" if len(mesh
.paper_island_list
) == 1 else
2348 "{} islands:".format(len(mesh
.paper_island_list
)))
2349 layout
.template_list(
2350 'UI_UL_list', 'paper_model_island_list', mesh
,
2351 'paper_island_list', mesh
, 'paper_island_index', rows
=1, maxrows
=5)
2352 sub
= layout
.split(align
=True)
2353 sub
.operator("mesh.select_paper_island", text
="Select").operation
= 'ADD'
2354 sub
.operator("mesh.select_paper_island", text
="Deselect").operation
= 'REMOVE'
2355 sub
.prop(sce
.paper_model
, "sync_island", icon
='UV_SYNC_SELECT', toggle
=True)
2356 if mesh
.paper_island_index
>= 0:
2357 list_item
= mesh
.paper_island_list
[mesh
.paper_island_index
]
2358 sub
= layout
.column(align
=True)
2359 sub
.prop(list_item
, "auto_label")
2360 sub
.prop(list_item
, "label")
2361 sub
.prop(list_item
, "auto_abbrev")
2363 row
.active
= not list_item
.auto_abbrev
2364 row
.prop(list_item
, "abbreviation")
2366 layout
.box().label(text
="Not unfolded")
2369 def label_changed(self
, context
):
2370 """The label of an island was changed"""
2371 # accessing properties via [..] to avoid a recursive call after the update
2372 self
["auto_label"] = not self
.label
or self
.label
.isspace()
2373 island_item_changed(self
, context
)
2376 def island_item_changed(self
, context
):
2377 """The labelling of an island was changed"""
2378 def increment(abbrev
, collisions
):
2379 letters
= "ABCDEFGHIJKLMNPQRSTUVWXYZ123456789"
2380 while abbrev
in collisions
:
2381 abbrev
= abbrev
.rstrip(letters
[-1])
2382 abbrev
= abbrev
[:2] + letters
[letters
.find(abbrev
[-1]) + 1 if len(abbrev
) == 3 else 0]
2385 # accessing properties via [..] to avoid a recursive call after the update
2386 island_list
= context
.active_object
.data
.paper_island_list
2388 self
["label"] = "" # avoid self-conflict
2390 while any(item
.label
== "Island {}".format(number
) for item
in island_list
):
2392 self
["label"] = "Island {}".format(number
)
2393 if self
.auto_abbrev
:
2394 self
["abbreviation"] = "" # avoid self-conflict
2395 abbrev
= "".join(first_letters(self
.label
))[:3].upper()
2396 self
["abbreviation"] = increment(abbrev
, {item
.abbreviation
for item
in island_list
})
2397 elif len(self
.abbreviation
) > 3:
2398 self
["abbreviation"] = self
.abbreviation
[:3]
2399 self
.name
= "[{}] {} ({} {})".format(
2400 self
.abbreviation
, self
.label
, len(self
.faces
), "faces" if len(self
.faces
) > 1 else "face")
2403 def island_index_changed(self
, context
):
2404 """The active island was changed"""
2405 if context
.scene
.paper_model
.sync_island
and SelectIsland
.poll(context
):
2406 bpy
.ops
.mesh
.select_paper_island(operation
='REPLACE')
2409 class FaceList(bpy
.types
.PropertyGroup
):
2410 id: bpy
.props
.IntProperty(name
="Face ID")
2413 class IslandList(bpy
.types
.PropertyGroup
):
2414 faces
: bpy
.props
.CollectionProperty(
2415 name
="Faces", description
="Faces belonging to this island", type=FaceList
)
2416 label
: bpy
.props
.StringProperty(
2417 name
="Label", description
="Label on this island",
2418 default
="", update
=label_changed
)
2419 abbreviation
: bpy
.props
.StringProperty(
2420 name
="Abbreviation", description
="Three-letter label to use when there is not enough space",
2421 default
="", update
=island_item_changed
)
2422 auto_label
: bpy
.props
.BoolProperty(
2423 name
="Auto Label", description
="Generate the label automatically",
2424 default
=True, update
=island_item_changed
)
2425 auto_abbrev
: bpy
.props
.BoolProperty(
2426 name
="Auto Abbreviation", description
="Generate the abbreviation automatically",
2427 default
=True, update
=island_item_changed
)
2430 class PaperModelSettings(bpy
.types
.PropertyGroup
):
2431 sync_island
: bpy
.props
.BoolProperty(
2432 name
="Sync", description
="Keep faces of the active island selected",
2433 default
=False, update
=island_index_changed
)
2434 limit_by_page
: bpy
.props
.BoolProperty(
2435 name
="Limit Island Size", description
="Do not create islands larger than given dimensions",
2436 default
=False, update
=page_size_preset_changed
)
2437 page_size_preset
: bpy
.props
.EnumProperty(
2438 name
="Page Size", description
="Maximal size of an island",
2439 default
='A4', update
=page_size_preset_changed
, items
=global_paper_sizes
)
2440 output_size_x
: bpy
.props
.FloatProperty(
2441 name
="Width", description
="Maximal width of an island",
2442 default
=0.2, soft_min
=0.105, soft_max
=0.841, subtype
="UNSIGNED", unit
="LENGTH")
2443 output_size_y
: bpy
.props
.FloatProperty(
2444 name
="Height", description
="Maximal height of an island",
2445 default
=0.29, soft_min
=0.148, soft_max
=1.189, subtype
="UNSIGNED", unit
="LENGTH")
2446 scale
: bpy
.props
.FloatProperty(
2447 name
="Scale", description
="Divisor of all dimensions when exporting",
2448 default
=1, soft_min
=1.0, soft_max
=10000.0, step
=100, subtype
='UNSIGNED', precision
=1)
2456 AddPresetPaperModel
,
2460 VIEW3D_MT_paper_model_presets
,
2461 DATA_PT_paper_model_islands
,
2462 #VIEW3D_PT_paper_model_tools,
2467 for cls
in module_classes
:
2468 bpy
.utils
.register_class(cls
)
2469 bpy
.types
.Scene
.paper_model
= bpy
.props
.PointerProperty(
2470 name
="Paper Model", description
="Settings of the Export Paper Model script",
2471 type=PaperModelSettings
, options
={'SKIP_SAVE'})
2472 bpy
.types
.Mesh
.paper_island_list
= bpy
.props
.CollectionProperty(
2473 name
="Island List", type=IslandList
)
2474 bpy
.types
.Mesh
.paper_island_index
= bpy
.props
.IntProperty(
2475 name
="Island List Index",
2476 default
=-1, min=-1, max=100, options
={'SKIP_SAVE'}, update
=island_index_changed
)
2477 bpy
.types
.TOPBAR_MT_file_export
.append(menu_func_export
)
2478 bpy
.types
.VIEW3D_MT_edit_mesh
.prepend(menu_func_unfold
)
2482 bpy
.types
.TOPBAR_MT_file_export
.remove(menu_func_export
)
2483 bpy
.types
.VIEW3D_MT_edit_mesh
.remove(menu_func_unfold
)
2484 for cls
in reversed(module_classes
):
2485 bpy
.utils
.unregister_class(cls
)
2488 if __name__
== "__main__":