1 # SPDX-License-Identifier: GPL-2.0-or-later
3 # Copyright 2010-2021 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",
17 "location": "File > Export > Paper Model",
19 "description": "Export printable net of the active mesh",
20 "doc_url": "{BLENDER_MANUAL_URL}/addons/import_export/paper_model.html",
21 "category": "Import-Export",
24 # Task: split into four files (SVG and PDF separately)
25 # * does any portion of baking belong into the export module?
26 # * sketch out the code for GCODE and two-sided export
29 # QuickSweepline is very much broken -- it throws GeometryError for all nets > ~15 faces
30 # rotate islands to minimize area -- and change that only if necessary to fill the page size
32 # check conflicts in island naming and either:
33 # * append a number to the conflicting names or
34 # * enumerate faces uniquely within all islands of the same name (requires a check that both label and abbr. equals)
40 from re
import compile as re_compile
41 from itertools
import chain
, repeat
, product
, combinations
42 from math
import pi
, ceil
, asin
, atan2
43 import os
.path
as os_path
45 default_priority_effect
= {
51 global_paper_sizes
= [
52 ('USER', "User defined", "User defined paper size"),
53 ('A4', "A4", "International standard paper size"),
54 ('A3', "A3", "International standard paper size"),
55 ('US_LETTER', "Letter", "North American paper size"),
56 ('US_LEGAL', "Legal", "North American paper size")
60 def first_letters(text
):
61 """Iterator over the first letter of each word"""
62 for match
in first_letters
.pattern
.finditer(text
):
63 yield text
[match
.start()]
64 first_letters
.pattern
= re_compile(r
"((?<!\w)\w)|\d")
67 def is_upsidedown_wrong(name
):
68 """Tell if the string would get a different meaning if written upside down"""
70 mistakable
= set("69NZMWpbqd")
71 rotatable
= set("80oOxXIl").union(mistakable
)
72 return chars
.issubset(rotatable
) and not chars
.isdisjoint(mistakable
)
76 """Generate consecutive pairs throughout the given sequence; at last, it gives elements last, first."""
78 previous
= first
= next(i
)
85 def fitting_matrix(v1
, v2
):
86 """Get a matrix that rotates v1 to the same direction as v2"""
87 return (1 / v1
.length_squared
) * M
.Matrix((
88 (v1
.x
*v2
.x
+ v1
.y
*v2
.y
, v1
.y
*v2
.x
- v1
.x
*v2
.y
),
89 (v1
.x
*v2
.y
- v1
.y
*v2
.x
, v1
.x
*v2
.x
+ v1
.y
*v2
.y
)))
93 """Get a rotation matrix that aligns given vector upwards."""
98 (n
.x
*n
.z
/(b
*s
), n
.y
*n
.z
/(b
*s
), -b
/s
),
103 # no need for rotation
106 (0, (-1 if n
.z
< 0 else 1), 0),
111 def cage_fit(points
, aspect
):
112 """Find rotation for a minimum bounding box with a given aspect ratio
113 returns a tuple: rotation angle, box height"""
114 def guesses(polygon
):
115 """Yield all tentative extrema of the bounding box height wrt. polygon rotation"""
116 for a
, b
in pairs(polygon
):
119 direction
= (b
- a
).normalized()
120 sinx
, cosx
= -direction
.y
, direction
.x
121 rot
= M
.Matrix(((cosx
, -sinx
), (sinx
, cosx
)))
122 rot_polygon
= [rot
@ p
for p
in polygon
]
123 left
, right
= [fn(rot_polygon
, key
=lambda p
: p
.to_tuple()) for fn
in (min, max)]
124 bottom
, top
= [fn(rot_polygon
, key
=lambda p
: p
.yx
.to_tuple()) for fn
in (min, max)]
125 horz
, vert
= right
- left
, top
- bottom
126 # solve (rot * a).y == (rot * b).y
127 yield max(aspect
* horz
.x
, vert
.y
), sinx
, cosx
128 # solve (rot * a).x == (rot * b).x
129 yield max(horz
.x
, aspect
* vert
.y
), -cosx
, sinx
130 # solve aspect * (rot * (right - left)).x == (rot * (top - bottom)).y
131 # using substitution t = tan(rot / 2)
132 q
= aspect
* horz
.x
- vert
.y
133 r
= vert
.x
+ aspect
* horz
.y
134 t
= ((r
**2 + q
**2)**0.5 - r
) / q
if q
!= 0 else 0
135 t
= -1 / t
if abs(t
) > 1 else t
# pick the positive solution
136 siny
, cosy
= 2 * t
/ (1 + t
**2), (1 - t
**2) / (1 + t
**2)
137 rot
= M
.Matrix(((cosy
, -siny
), (siny
, cosy
)))
138 for p
in rot_polygon
:
139 p
[:] = rot
@ p
# note: this also modifies left, right, bottom, top
140 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
):
141 yield max(aspect
* (right
- left
).x
, (top
- bottom
).y
), sinx
*cosy
+ cosx
*siny
, cosx
*cosy
- sinx
*siny
142 polygon
= [points
[i
] for i
in M
.geometry
.convex_hull_2d(points
)]
143 height
, sinx
, cosx
= min(guesses(polygon
))
144 return atan2(sinx
, cosx
), height
147 def create_blank_image(image_name
, dimensions
, alpha
=1):
148 """Create a new image and assign white color to all its pixels"""
149 image_name
= image_name
[:64]
150 width
, height
= int(dimensions
.x
), int(dimensions
.y
)
151 image
= bpy
.data
.images
.new(image_name
, width
, height
, alpha
=True)
154 "There is something wrong with the material of the model. "
155 "Please report this on the BlenderArtists forum. Export failed.")
156 image
.pixels
= [1, 1, 1, alpha
] * (width
* height
)
157 image
.file_format
= 'PNG'
161 def store_rna_properties(*datablocks
):
162 return [{prop
.identifier
: getattr(data
, prop
.identifier
) for prop
in data
.rna_type
.properties
if not prop
.is_readonly
} for data
in datablocks
]
165 def apply_rna_properties(memory
, *datablocks
):
166 for recall
, data
in zip(memory
, datablocks
):
167 for key
, value
in recall
.items():
168 setattr(data
, key
, value
)
171 class UnfoldError(ValueError):
172 def mesh_select(self
):
173 if len(self
.args
) > 1:
174 elems
, bm
= self
.args
[1:3]
175 bpy
.context
.tool_settings
.mesh_select_mode
= [bool(elems
[key
]) for key
in ("verts", "edges", "faces")]
176 for elem
in chain(bm
.verts
, bm
.edges
, bm
.faces
):
178 for elem
in chain(*elems
.values()):
179 elem
.select_set(True)
180 bmesh
.update_edit_mesh(bpy
.context
.object.data
, loop_triangles
=False, destructive
=False)
184 def __init__(self
, ob
):
185 self
.do_create_uvmap
= False
186 bm
= bmesh
.from_edit_mesh(ob
.data
)
187 self
.mesh
= Mesh(bm
, ob
.matrix_world
)
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 directly 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 recall
= store_rna_properties(rd
, bk
, sce
.cycles
)
256 for p
in ('color', 'diffuse', 'direct', 'emit', 'glossy', 'indirect', 'transmission'):
257 setattr(bk
, f
"use_pass_{p}", (properties
.output_type
!= 'TEXTURE'))
258 lookup
= {'TEXTURE': 'DIFFUSE', 'AMBIENT_OCCLUSION': 'AO', 'RENDER': 'COMBINED', 'SELECTED_TO_ACTIVE': 'COMBINED'}
259 sce
.cycles
.bake_type
= lookup
[properties
.output_type
]
260 bk
.use_selected_to_active
= (properties
.output_type
== 'SELECTED_TO_ACTIVE')
261 bk
.margin
, bk
.cage_extrusion
, bk
.use_cage
, bk
.use_clear
= 1, 10, False, False
262 if properties
.output_type
== 'TEXTURE':
263 bk
.use_pass_direct
, bk
.use_pass_indirect
, bk
.use_pass_color
= False, False, True
264 sce
.cycles
.samples
= 1
266 sce
.cycles
.samples
= properties
.bake_samples
267 if sce
.cycles
.bake_type
== 'COMBINED':
268 bk
.use_pass_direct
, bk
.use_pass_indirect
= True, True
269 bk
.use_pass_diffuse
, bk
.use_pass_glossy
, bk
.use_pass_transmission
, bk
.use_pass_emit
= True, False, False, True
271 if image_packing
== 'PAGE_LINK':
272 self
.mesh
.save_image(printable_size
* ppm
, filepath
)
273 elif image_packing
== 'ISLAND_LINK':
274 image_dir
= filepath
[:filepath
.rfind(".")]
275 self
.mesh
.save_separate_images(ppm
, image_dir
)
276 elif image_packing
== 'ISLAND_EMBED':
277 self
.mesh
.save_separate_images(ppm
, filepath
, embed
=Exporter
.encode_image
)
279 apply_rna_properties(recall
, rd
, bk
, sce
.cycles
)
281 exporter
= Exporter(properties
)
282 exporter
.write(self
.mesh
, filepath
)
286 """Wrapper for Bpy Mesh"""
288 def __init__(self
, bmesh
, matrix
):
290 self
.matrix
= matrix
.to_3x3()
291 self
.looptex
= bmesh
.loops
.layers
.uv
.new("Unfolded")
292 self
.edges
= {bmedge
: Edge(bmedge
) for bmedge
in bmesh
.edges
}
293 self
.islands
= list()
295 for edge
in self
.edges
.values():
296 edge
.choose_main_faces()
298 edge
.calculate_angle()
299 self
.copy_freestyle_marks()
301 def delete_uvmap(self
):
302 self
.data
.loops
.layers
.uv
.remove(self
.looptex
) if self
.looptex
else None
304 def copy_freestyle_marks(self
):
305 # NOTE: this is a workaround for NotImplementedError on bmesh.edges.layers.freestyle
306 mesh
= bpy
.data
.meshes
.new("unfolder_temp")
307 self
.data
.to_mesh(mesh
)
308 for bmedge
, edge
in self
.edges
.items():
309 edge
.freestyle
= mesh
.edges
[bmedge
.index
].use_freestyle_mark
310 bpy
.data
.meshes
.remove(mesh
)
313 for bmedge
, edge
in self
.edges
.items():
314 if edge
.is_main_cut
and not bmedge
.is_boundary
:
317 def check_correct(self
, epsilon
=1e-6):
318 """Check for invalid geometry"""
319 def is_twisted(face
):
320 if len(face
.verts
) <= 3:
322 center
= face
.calc_center_median()
323 plane_d
= center
.dot(face
.normal
)
324 diameter
= max((center
- vertex
.co
).length
for vertex
in face
.verts
)
325 threshold
= 0.01 * diameter
326 return any(abs(v
.co
.dot(face
.normal
) - plane_d
) > threshold
for v
in face
.verts
)
328 null_edges
= {e
for e
in self
.edges
.keys() if e
.calc_length() < epsilon
and e
.link_faces
}
329 null_faces
= {f
for f
in self
.data
.faces
if f
.calc_area() < epsilon
}
330 twisted_faces
= {f
for f
in self
.data
.faces
if is_twisted(f
)}
331 inverted_scale
= self
.matrix
.determinant() <= 0
332 if not (null_edges
or null_faces
or twisted_faces
or inverted_scale
):
336 "The object is flipped inside-out.\n"
337 "You can use Object -> Apply -> Scale to fix it. Export failed.")
338 disease
= [("Remove Doubles", null_edges
or null_faces
), ("Triangulate", twisted_faces
)]
339 cure
= " and ".join(s
for s
, k
in disease
if k
)
341 "The model contains:\n" +
342 (" {} zero-length edge(s)\n".format(len(null_edges
)) if null_edges
else "") +
343 (" {} zero-area face(s)\n".format(len(null_faces
)) if null_faces
else "") +
344 (" {} twisted polygon(s)\n".format(len(twisted_faces
)) if twisted_faces
else "") +
345 "The offenders are selected and you can use {} to fix them. Export failed.".format(cure
),
346 {"verts": set(), "edges": null_edges
, "faces": null_faces | twisted_faces
}, self
.data
)
348 def generate_cuts(self
, page_size
, priority_effect
):
349 """Cut the mesh so that it can be unfolded to a flat net."""
350 normal_matrix
= self
.matrix
.inverted().transposed()
351 islands
= {Island(self
, face
, self
.matrix
, normal_matrix
) for face
in self
.data
.faces
}
352 uvfaces
= {face
: uvface
for island
in islands
for face
, uvface
in island
.faces
.items()}
353 uvedges
= {loop
: uvedge
for island
in islands
for loop
, uvedge
in island
.edges
.items()}
354 for loop
, uvedge
in uvedges
.items():
355 self
.edges
[loop
.edge
].uvedges
.append(uvedge
)
356 # check for edges that are cut permanently
357 edges
= [edge
for edge
in self
.edges
.values() if not edge
.force_cut
and edge
.main_faces
]
360 average_length
= sum(edge
.vector
.length
for edge
in edges
) / len(edges
)
362 edge
.generate_priority(priority_effect
, average_length
)
363 edges
.sort(reverse
=False, key
=lambda edge
: edge
.priority
)
367 edge_a
, edge_b
= (uvedges
[l
] for l
in edge
.main_faces
)
368 old_island
= join(edge_a
, edge_b
, size_limit
=page_size
)
370 islands
.remove(old_island
)
372 self
.islands
= sorted(islands
, reverse
=True, key
=lambda island
: len(island
.faces
))
374 for edge
in self
.edges
.values():
375 # some edges did not know until now whether their angle is convex or concave
376 if edge
.main_faces
and (uvfaces
[edge
.main_faces
[0].face
].flipped
or uvfaces
[edge
.main_faces
[1].face
].flipped
):
377 edge
.calculate_angle()
378 # ensure that the order of faces corresponds to the order of uvedges
380 reordered
= [None, None]
381 for uvedge
in edge
.uvedges
:
383 index
= edge
.main_faces
.index(uvedge
.loop
)
384 reordered
[index
] = uvedge
386 reordered
.append(uvedge
)
387 edge
.uvedges
= reordered
389 for island
in self
.islands
:
390 # if the normals are ambiguous, flip them so that there are more convex edges than concave ones
391 if any(uvface
.flipped
for uvface
in island
.faces
.values()):
392 island_edges
= {self
.edges
[uvedge
.edge
] for uvedge
in island
.edges
}
393 balance
= sum((+1 if edge
.angle
> 0 else -1) for edge
in island_edges
if not edge
.is_cut(uvedge
.uvface
.face
))
395 island
.is_inside_out
= True
397 # construct a linked list from each island's boundary
398 # uvedge.neighbor_right is clockwise = forward = via uvedge.vb if not uvface.flipped
399 neighbor_lookup
, conflicts
= dict(), dict()
400 for uvedge
in island
.boundary
:
401 uvvertex
= uvedge
.va
if uvedge
.uvface
.flipped
else uvedge
.vb
402 if uvvertex
not in neighbor_lookup
:
403 neighbor_lookup
[uvvertex
] = uvedge
405 if uvvertex
not in conflicts
:
406 conflicts
[uvvertex
] = [neighbor_lookup
[uvvertex
], uvedge
]
408 conflicts
[uvvertex
].append(uvedge
)
410 for uvedge
in island
.boundary
:
411 uvvertex
= uvedge
.vb
if uvedge
.uvface
.flipped
else uvedge
.va
412 if uvvertex
not in conflicts
:
413 # using the 'get' method so as to handle single-connected vertices properly
414 uvedge
.neighbor_right
= neighbor_lookup
.get(uvvertex
, uvedge
)
415 uvedge
.neighbor_right
.neighbor_left
= uvedge
417 conflicts
[uvvertex
].append(uvedge
)
419 # resolve merged vertices with more boundaries crossing
420 def direction_to_float(vector
):
421 return (1 - vector
.x
/vector
.length
) if vector
.y
> 0 else (vector
.x
/vector
.length
- 1)
422 for uvvertex
, uvedges
in conflicts
.items():
423 def is_inwards(uvedge
):
424 return uvedge
.uvface
.flipped
== (uvedge
.va
is uvvertex
)
426 def uvedge_sortkey(uvedge
):
427 if is_inwards(uvedge
):
428 return direction_to_float(uvedge
.va
.co
- uvedge
.vb
.co
)
430 return direction_to_float(uvedge
.vb
.co
- uvedge
.va
.co
)
432 uvedges
.sort(key
=uvedge_sortkey
)
434 zip(uvedges
[:-1:2], uvedges
[1::2]) if is_inwards(uvedges
[0])
435 else zip([uvedges
[-1]] + uvedges
[1::2], uvedges
[:-1:2])):
436 left
.neighbor_right
= right
437 right
.neighbor_left
= left
440 def generate_stickers(self
, default_width
, do_create_numbers
=True):
441 """Add sticker faces where they are needed."""
442 def uvedge_priority(uvedge
):
443 """Returns whether it is a good idea to stick something on this edge's face"""
444 # TODO: it should take into account overlaps with faces and with other stickers
445 face
= uvedge
.uvface
.face
446 return face
.calc_area() / face
.calc_perimeter()
448 def add_sticker(uvedge
, index
, target_uvedge
):
449 uvedge
.sticker
= Sticker(uvedge
, default_width
, index
, target_uvedge
)
450 uvedge
.uvface
.island
.add_marker(uvedge
.sticker
)
452 def is_index_obvious(uvedge
, target
):
453 if uvedge
in (target
.neighbor_left
, target
.neighbor_right
):
455 if uvedge
.neighbor_left
.loop
.edge
is target
.neighbor_right
.loop
.edge
and uvedge
.neighbor_right
.loop
.edge
is target
.neighbor_left
.loop
.edge
:
459 for edge
in self
.edges
.values():
461 if edge
.is_main_cut
and len(edge
.uvedges
) >= 2 and edge
.vector
.length_squared
> 0:
462 target
, source
= edge
.uvedges
[:2]
463 if uvedge_priority(target
) < uvedge_priority(source
):
464 target
, source
= source
, target
465 target_island
= target
.uvface
.island
466 if do_create_numbers
:
467 for uvedge
in [source
] + edge
.uvedges
[2:]:
468 if not is_index_obvious(uvedge
, target
):
469 # it will not be clear to see that these uvedges should be sticked together
470 # So, create an arrow and put the index on all stickers
471 target_island
.sticker_numbering
+= 1
472 index
= str(target_island
.sticker_numbering
)
473 if is_upsidedown_wrong(index
):
475 target_island
.add_marker(Arrow(target
, default_width
, index
))
477 add_sticker(source
, index
, target
)
478 elif len(edge
.uvedges
) > 2:
479 target
= edge
.uvedges
[0]
480 if len(edge
.uvedges
) > 2:
481 for source
in edge
.uvedges
[2:]:
482 add_sticker(source
, index
, target
)
484 def generate_numbers_alone(self
, size
):
486 for edge
in self
.edges
.values():
487 if edge
.is_main_cut
and len(edge
.uvedges
) >= 2:
488 global_numbering
+= 1
489 index
= str(global_numbering
)
490 if is_upsidedown_wrong(index
):
492 for uvedge
in edge
.uvedges
:
493 uvedge
.uvface
.island
.add_marker(NumberAlone(uvedge
, index
, size
))
495 def enumerate_islands(self
):
496 for num
, island
in enumerate(self
.islands
, 1):
498 island
.generate_label()
500 def scale_islands(self
, scale
):
501 for island
in self
.islands
:
502 vertices
= set(island
.vertices
.values())
503 for point
in chain((vertex
.co
for vertex
in vertices
), island
.fake_vertices
):
506 def finalize_islands(self
, cage_size
, title_height
=0):
507 for island
in self
.islands
:
509 island
.title
= "[{}] {}".format(island
.abbreviation
, island
.label
)
510 points
= [vertex
.co
for vertex
in set(island
.vertices
.values())] + island
.fake_vertices
511 angle
, _
= cage_fit(points
, (cage_size
.y
- title_height
) / cage_size
.x
)
512 rot
= M
.Matrix
.Rotation(angle
, 2)
515 for marker
in island
.markers
:
516 marker
.rot
= rot
@ marker
.rot
517 bottom_left
= M
.Vector((min(v
.x
for v
in points
), min(v
.y
for v
in points
) - title_height
))
519 # top_right = M.Vector((max(v.x for v in points), max(v.y for v in points) - title_height))
520 # print(f"fitted aspect: {(top_right.y - bottom_left.y) / (top_right.x - bottom_left.x)}")
523 island
.bounding_box
= M
.Vector((max(v
.x
for v
in points
), max(v
.y
for v
in points
)))
525 def largest_island_ratio(self
, cage_size
):
526 return max(i
/ p
for island
in self
.islands
for (i
, p
) in zip(island
.bounding_box
, cage_size
))
528 def fit_islands(self
, cage_size
):
529 """Move islands so that they fit onto pages, based on their bounding boxes"""
531 def try_emplace(island
, page_islands
, stops_x
, stops_y
, occupied_cache
):
532 """Tries to put island to each pair from stops_x, stops_y
533 and checks if it overlaps with any islands present on the page.
534 Returns True and positions the given island on success."""
535 bbox_x
, bbox_y
= island
.bounding_box
.xy
537 if x
+ bbox_x
> cage_size
.x
:
540 if y
+ bbox_y
> cage_size
.y
or (x
, y
) in occupied_cache
:
542 for i
, obstacle
in enumerate(page_islands
):
543 # if this obstacle overlaps with the island, try another stop
544 if (x
+ bbox_x
> obstacle
.pos
.x
and
545 obstacle
.pos
.x
+ obstacle
.bounding_box
.x
> x
and
546 y
+ bbox_y
> obstacle
.pos
.y
and
547 obstacle
.pos
.y
+ obstacle
.bounding_box
.y
> y
):
548 if x
>= obstacle
.pos
.x
and y
>= obstacle
.pos
.y
:
549 occupied_cache
.add((x
, y
))
550 # just a stupid heuristic to make subsequent searches faster
552 page_islands
[1:i
+1] = page_islands
[:i
]
553 page_islands
[0] = obstacle
556 # if no obstacle called break, this position is okay
558 page_islands
.append(island
)
559 stops_x
.append(x
+ bbox_x
)
560 stops_y
.append(y
+ bbox_y
)
564 def drop_portion(stops
, border
, divisor
):
566 # distance from left neighbor to the right one, excluding the first stop
567 distances
= [right
- left
for left
, right
in zip(stops
, chain(stops
[2:], [border
]))]
568 quantile
= sorted(distances
)[len(distances
) // divisor
]
569 return [stop
for stop
, distance
in zip(stops
, chain([quantile
], distances
)) if distance
>= quantile
]
571 if any(island
.bounding_box
.x
> cage_size
.x
or island
.bounding_box
.y
> cage_size
.y
for island
in self
.islands
):
573 "An island is too big to fit onto page of the given size. "
574 "Either downscale the model or find and split that island manually.\n"
575 "Export failed, sorry.")
576 # sort islands by their diagonal... just a guess
577 remaining_islands
= sorted(self
.islands
, reverse
=True, key
=lambda island
: island
.bounding_box
.length_squared
)
578 page_num
= 1 # TODO delete me
580 while remaining_islands
:
581 # create a new page and try to fit as many islands onto it as possible
582 page
= Page(page_num
)
584 occupied_cache
= set()
585 stops_x
, stops_y
= [0], [0]
586 for island
in remaining_islands
:
587 try_emplace(island
, page
.islands
, stops_x
, stops_y
, occupied_cache
)
588 # if overwhelmed with stops, drop a quarter of them
589 if len(stops_x
)**2 > 4 * len(self
.islands
) + 100:
590 stops_x
= drop_portion(stops_x
, cage_size
.x
, 4)
591 stops_y
= drop_portion(stops_y
, cage_size
.y
, 4)
592 remaining_islands
= [island
for island
in remaining_islands
if island
not in page
.islands
]
593 self
.pages
.append(page
)
595 def save_uv(self
, cage_size
=M
.Vector((1, 1)), separate_image
=False):
597 for island
in self
.islands
:
598 island
.save_uv_separate(self
.looptex
)
600 for island
in self
.islands
:
601 island
.save_uv(self
.looptex
, cage_size
)
603 def save_image(self
, page_size_pixels
: M
.Vector
, filename
):
604 for page
in self
.pages
:
605 image
= create_blank_image("Page {}".format(page
.name
), page_size_pixels
, alpha
=1)
606 image
.filepath_raw
= page
.image_path
= "{}_{}.png".format(filename
, page
.name
)
607 faces
= [face
for island
in page
.islands
for face
in island
.faces
]
608 self
.bake(faces
, image
)
611 bpy
.data
.images
.remove(image
)
613 def save_separate_images(self
, scale
, filepath
, embed
=None):
614 for i
, island
in enumerate(self
.islands
):
615 image_name
= "Island {}".format(i
)
616 image
= create_blank_image(image_name
, island
.bounding_box
* scale
, alpha
=0)
617 self
.bake(island
.faces
.keys(), image
)
619 island
.embedded_image
= embed(image
)
621 from os
import makedirs
623 makedirs(image_dir
, exist_ok
=True)
624 image_path
= os_path
.join(image_dir
, "island{}.png".format(i
))
625 image
.filepath_raw
= image_path
627 island
.image_path
= image_path
629 bpy
.data
.images
.remove(image
)
631 def bake(self
, faces
, image
):
633 raise UnfoldError("The mesh has no UV Map slots left. Either delete a UV Map or export the net without textures.")
634 ob
= bpy
.context
.active_object
636 # in Cycles, the image for baking is defined by the active Image Node
638 for mat
in me
.materials
:
640 img
= mat
.node_tree
.nodes
.new('ShaderNodeTexImage')
642 temp_nodes
[mat
] = img
643 mat
.node_tree
.nodes
.active
= img
644 # move all excess faces to negative numbers (that is the only way to disable them)
645 ignored_uvs
= [loop
[self
.looptex
].uv
for f
in self
.data
.faces
if f
not in faces
for loop
in f
.loops
]
646 for uv
in ignored_uvs
:
648 bake_type
= bpy
.context
.scene
.cycles
.bake_type
649 sta
= bpy
.context
.scene
.render
.bake
.use_selected_to_active
651 ob
.update_from_editmode()
652 me
.uv_layers
.active
= me
.uv_layers
[self
.looptex
.name
]
653 bpy
.ops
.object.bake(type=bake_type
, margin
=1, use_selected_to_active
=sta
, cage_extrusion
=100, use_clear
=False)
654 except RuntimeError as e
:
655 raise UnfoldError(*e
.args
)
657 for mat
, node
in temp_nodes
.items():
658 mat
.node_tree
.nodes
.remove(node
)
659 for uv
in ignored_uvs
:
664 """Wrapper for BPy Edge"""
666 'data', 'va', 'vb', 'main_faces', 'uvedges',
668 'is_main_cut', 'force_cut', 'priority', 'freestyle')
670 def __init__(self
, edge
):
672 self
.va
, self
.vb
= edge
.verts
673 self
.vector
= self
.vb
.co
- self
.va
.co
674 # if self.main_faces is set, then self.uvedges[:2] must correspond to self.main_faces, in their order
675 # this constraint is assured at the time of finishing mesh.generate_cuts
676 self
.uvedges
= list()
678 self
.force_cut
= edge
.seam
# such edges will always be cut
679 self
.main_faces
= None # two faces that may be connected in the island
680 # is_main_cut defines whether the two main faces are connected
681 # all the others will be assumed to be cut
682 self
.is_main_cut
= True
685 self
.freestyle
= False
687 def choose_main_faces(self
):
688 """Choose two main faces that might get connected in an island"""
691 return abs(pair
[0].face
.normal
.dot(pair
[1].face
.normal
))
693 loops
= self
.data
.link_loops
695 self
.main_faces
= list(loops
)
697 # find (with brute force) the pair of indices whose loops have the most similar normals
698 self
.main_faces
= max(combinations(loops
, 2), key
=score
)
699 if self
.main_faces
and self
.main_faces
[1].vert
== self
.va
:
700 self
.main_faces
= self
.main_faces
[::-1]
702 def calculate_angle(self
):
703 """Calculate the angle between the main faces"""
704 loop_a
, loop_b
= self
.main_faces
705 normal_a
, normal_b
= (l
.face
.normal
for l
in self
.main_faces
)
706 if not normal_a
or not normal_b
:
707 self
.angle
= -3 # just a very sharp angle
709 s
= normal_a
.cross(normal_b
).dot(self
.vector
.normalized())
710 s
= max(min(s
, 1.0), -1.0) # deal with rounding errors
712 if loop_a
.link_loop_next
.vert
!= loop_b
.vert
or loop_b
.link_loop_next
.vert
!= loop_a
.vert
:
713 self
.angle
= abs(self
.angle
)
715 def generate_priority(self
, priority_effect
, average_length
):
716 """Calculate the priority value for cutting"""
719 self
.priority
= priority_effect
['CONVEX'] * angle
/ pi
721 self
.priority
= priority_effect
['CONCAVE'] * (-angle
) / pi
722 self
.priority
+= (self
.vector
.length
/ average_length
) * priority_effect
['LENGTH']
724 def is_cut(self
, face
):
725 """Return False if this edge will the given face to another one in the resulting net
726 (useful for edges with more than two faces connected)"""
727 # Return whether there is a cut between the two main faces
728 if self
.main_faces
and face
in {loop
.face
for loop
in self
.main_faces
}:
729 return self
.is_main_cut
730 # All other faces (third and more) are automatically treated as cut
734 def other_uvedge(self
, this
):
735 """Get an uvedge of this edge that is not the given one
736 causes an IndexError if case of less than two adjacent edges"""
737 return self
.uvedges
[1] if this
is self
.uvedges
[0] else self
.uvedges
[0]
741 """Part of the net to be exported"""
743 'mesh', 'faces', 'edges', 'vertices', 'fake_vertices', 'boundary', 'markers',
744 'pos', 'bounding_box',
745 'image_path', 'embedded_image',
746 'number', 'label', 'abbreviation', 'title',
747 'has_safe_geometry', 'is_inside_out',
750 def __init__(self
, mesh
, face
, matrix
, normal_matrix
):
751 """Create an Island from a single Face"""
753 self
.faces
= dict() # face -> uvface
754 self
.edges
= dict() # loop -> uvedge
755 self
.vertices
= dict() # loop -> uvvertex
756 self
.fake_vertices
= list()
757 self
.markers
= list()
759 self
.abbreviation
= None
761 self
.pos
= M
.Vector((0, 0))
762 self
.image_path
= None
763 self
.embedded_image
= None
764 self
.is_inside_out
= False # swaps concave <-> convex edges
765 self
.has_safe_geometry
= True
766 self
.sticker_numbering
= 0
768 uvface
= UVFace(face
, self
, matrix
, normal_matrix
)
769 self
.vertices
.update(uvface
.vertices
)
770 self
.edges
.update(uvface
.edges
)
771 self
.faces
[face
] = uvface
772 # UVEdges on the boundary
773 self
.boundary
= list(self
.edges
.values())
775 def add_marker(self
, marker
):
776 self
.fake_vertices
.extend(marker
.bounds
)
777 self
.markers
.append(marker
)
779 def generate_label(self
, label
=None, abbreviation
=None):
780 """Assign a name to this island automatically"""
781 abbr
= abbreviation
or self
.abbreviation
or str(self
.number
)
782 # TODO: dots should be added in the last instant when outputting any text
783 if is_upsidedown_wrong(abbr
):
785 self
.label
= label
or self
.label
or "Island {}".format(self
.number
)
786 self
.abbreviation
= abbr
788 def save_uv(self
, tex
, cage_size
):
789 """Save UV Coordinates of all UVFaces to a given UV texture
790 tex: UV Texture layer to use (BMLayerItem)
791 page_size: size of the page in pixels (vector)"""
792 scale_x
, scale_y
= 1 / cage_size
.x
, 1 / cage_size
.y
793 for loop
, uvvertex
in self
.vertices
.items():
794 uv
= uvvertex
.co
+ self
.pos
795 loop
[tex
].uv
= uv
.x
* scale_x
, uv
.y
* scale_y
797 def save_uv_separate(self
, tex
):
798 """Save UV Coordinates of all UVFaces to a given UV texture, spanning from 0 to 1
799 tex: UV Texture layer to use (BMLayerItem)
800 page_size: size of the page in pixels (vector)"""
801 scale_x
, scale_y
= 1 / self
.bounding_box
.x
, 1 / self
.bounding_box
.y
802 for loop
, uvvertex
in self
.vertices
.items():
803 loop
[tex
].uv
= uvvertex
.co
.x
* scale_x
, uvvertex
.co
.y
* scale_y
806 def join(uvedge_a
, uvedge_b
, size_limit
=None, epsilon
=1e-6):
808 Try to join other island on given edge
809 Returns False if they would overlap
812 class Intersection(Exception):
815 class GeometryError(Exception):
818 def is_below(self
, other
, correct_geometry
=True):
821 if self
.top
< other
.bottom
:
823 if other
.top
< self
.bottom
:
825 if self
.max.tup
<= other
.min.tup
:
827 if other
.max.tup
<= self
.min.tup
:
829 self_vector
= self
.max.co
- self
.min.co
830 min_to_min
= other
.min.co
- self
.min.co
831 cross_b1
= self_vector
.cross(min_to_min
)
832 cross_b2
= self_vector
.cross(other
.max.co
- self
.min.co
)
833 if cross_b2
< cross_b1
:
834 cross_b1
, cross_b2
= cross_b2
, cross_b1
835 if cross_b2
> 0 and (cross_b1
> 0 or (cross_b1
== 0 and not self
.is_uvface_upwards())):
837 if cross_b1
< 0 and (cross_b2
< 0 or (cross_b2
== 0 and self
.is_uvface_upwards())):
839 other_vector
= other
.max.co
- other
.min.co
840 cross_a1
= other_vector
.cross(-min_to_min
)
841 cross_a2
= other_vector
.cross(self
.max.co
- other
.min.co
)
842 if cross_a2
< cross_a1
:
843 cross_a1
, cross_a2
= cross_a2
, cross_a1
844 if cross_a2
> 0 and (cross_a1
> 0 or (cross_a1
== 0 and not other
.is_uvface_upwards())):
846 if cross_a1
< 0 and (cross_a2
< 0 or (cross_a2
== 0 and other
.is_uvface_upwards())):
848 if cross_a1
== cross_b1
== cross_a2
== cross_b2
== 0:
851 elif self
.is_uvface_upwards() == other
.is_uvface_upwards():
854 if self
.min.tup
== other
.min.tup
or self
.max.tup
== other
.max.tup
:
855 return cross_a2
> cross_b2
858 class QuickSweepline
:
859 """Efficient sweepline based on binary search, checking neighbors only"""
861 self
.children
= list()
863 def add(self
, item
, cmp=is_below
):
864 low
, high
= 0, len(self
.children
)
866 mid
= (low
+ high
) // 2
867 if cmp(self
.children
[mid
], item
):
871 self
.children
.insert(low
, item
)
873 def remove(self
, item
, cmp=is_below
):
874 index
= self
.children
.index(item
)
875 self
.children
.pop(index
)
876 if index
> 0 and index
< len(self
.children
):
877 # check for intersection
878 if cmp(self
.children
[index
], self
.children
[index
-1]):
881 class BruteSweepline
:
882 """Safe sweepline which checks all its members pairwise"""
884 self
.children
= set()
886 def add(self
, item
, cmp=is_below
):
887 for child
in self
.children
:
888 if child
.min is not item
.min and child
.max is not item
.max:
889 cmp(item
, child
, False)
890 self
.children
.add(item
)
892 def remove(self
, item
):
893 self
.children
.remove(item
)
895 def sweep(sweepline
, segments
):
896 """Sweep across the segments and raise an exception if necessary"""
897 # careful, 'segments' may be a use-once iterator
898 events_add
= sorted(segments
, reverse
=True, key
=lambda uvedge
: uvedge
.min.tup
)
899 events_remove
= sorted(events_add
, reverse
=True, key
=lambda uvedge
: uvedge
.max.tup
)
901 while events_add
and events_add
[-1].min.tup
<= events_remove
[-1].max.tup
:
902 sweepline
.add(events_add
.pop())
903 sweepline
.remove(events_remove
.pop())
905 def root_find(value
, tree
):
906 """Find the root of a given value in a forest-like dictionary
907 also updates the dictionary using path compression"""
908 parent
, relink
= tree
.get(value
), list()
909 while parent
is not None:
911 value
, parent
= parent
, tree
.get(parent
)
912 tree
.update(dict.fromkeys(relink
, value
))
915 def slope_from(position
):
917 vec
= (uvedge
.vb
.co
- uvedge
.va
.co
) if uvedge
.va
.tup
== position
else (uvedge
.va
.co
- uvedge
.vb
.co
)
918 return (vec
.y
/ vec
.length
+ 1) if ((vec
.x
, vec
.y
) > (0, 0)) else (-1 - vec
.y
/ vec
.length
)
921 island_a
, island_b
= (e
.uvface
.island
for e
in (uvedge_a
, uvedge_b
))
922 if island_a
is island_b
:
924 elif len(island_b
.faces
) > len(island_a
.faces
):
925 uvedge_a
, uvedge_b
= uvedge_b
, uvedge_a
926 island_a
, island_b
= island_b
, island_a
927 # check if vertices and normals are aligned correctly
928 verts_flipped
= uvedge_b
.loop
.vert
is uvedge_a
.loop
.vert
929 flipped
= verts_flipped ^ uvedge_a
.uvface
.flipped ^ uvedge_b
.uvface
.flipped
931 # NOTE: if the edges differ in length, the matrix will involve uniform scaling.
932 # Such situation may occur in the case of twisted n-gons
933 first_b
, second_b
= (uvedge_b
.va
, uvedge_b
.vb
) if not verts_flipped
else (uvedge_b
.vb
, uvedge_b
.va
)
935 rot
= fitting_matrix(first_b
.co
- second_b
.co
, uvedge_a
.vb
.co
- uvedge_a
.va
.co
)
937 flip
= M
.Matrix(((-1, 0), (0, 1)))
938 rot
= fitting_matrix(flip
@ (first_b
.co
- second_b
.co
), uvedge_a
.vb
.co
- uvedge_a
.va
.co
) @ flip
939 trans
= uvedge_a
.vb
.co
- rot
@ first_b
.co
940 # preview of island_b's vertices after the join operation
941 phantoms
= {uvvertex
: UVVertex(rot
@ uvvertex
.co
+ trans
) for uvvertex
in island_b
.vertices
.values()}
943 # check the size of the resulting island
945 points
= [vert
.co
for vert
in chain(island_a
.vertices
.values(), phantoms
.values())]
946 left
, right
, bottom
, top
= (fn(co
[i
] for co
in points
) for i
in (0, 1) for fn
in (min, max))
947 bbox_width
= right
- left
948 bbox_height
= top
- bottom
949 if min(bbox_width
, bbox_height
)**2 > size_limit
.x
**2 + size_limit
.y
**2:
951 if (bbox_width
> size_limit
.x
or bbox_height
> size_limit
.y
) and (bbox_height
> size_limit
.x
or bbox_width
> size_limit
.y
):
952 _
, height
= cage_fit(points
, size_limit
.y
/ size_limit
.x
)
953 if height
> size_limit
.y
:
956 distance_limit
= uvedge_a
.loop
.edge
.calc_length() * epsilon
957 # try and merge UVVertices closer than sqrt(distance_limit)
958 merged_uvedges
= set()
959 merged_uvedge_pairs
= list()
961 # merge all uvvertices that are close enough using a union-find structure
962 # uvvertices will be merged only in cases island_b->island_a and island_a->island_a
963 # all resulting groups are merged together to a uvvertex of island_a
964 is_merged_mine
= False
965 shared_vertices
= {loop
.vert
for loop
in chain(island_a
.vertices
, island_b
.vertices
)}
966 for vertex
in shared_vertices
:
967 uvs_a
= {island_a
.vertices
.get(loop
) for loop
in vertex
.link_loops
} - {None}
968 uvs_b
= {island_b
.vertices
.get(loop
) for loop
in vertex
.link_loops
} - {None}
969 for a
, b
in product(uvs_a
, uvs_b
):
970 if (a
.co
- phantoms
[b
].co
).length_squared
< distance_limit
:
971 phantoms
[b
] = root_find(a
, phantoms
)
972 for a1
, a2
in combinations(uvs_a
, 2):
973 if (a1
.co
- a2
.co
).length_squared
< distance_limit
:
974 a1
, a2
= (root_find(a
, phantoms
) for a
in (a1
, a2
))
977 is_merged_mine
= True
978 for source
, target
in phantoms
.items():
979 target
= root_find(target
, phantoms
)
980 phantoms
[source
] = target
982 for uvedge
in (chain(island_a
.boundary
, island_b
.boundary
) if is_merged_mine
else island_b
.boundary
):
983 for loop
in uvedge
.loop
.link_loops
:
984 partner
= island_b
.edges
.get(loop
) or island_a
.edges
.get(loop
)
985 if partner
is not None and partner
is not uvedge
:
986 paired_a
, paired_b
= phantoms
.get(partner
.vb
, partner
.vb
), phantoms
.get(partner
.va
, partner
.va
)
987 if (partner
.uvface
.flipped ^ flipped
) != uvedge
.uvface
.flipped
:
988 paired_a
, paired_b
= paired_b
, paired_a
989 if phantoms
.get(uvedge
.va
, uvedge
.va
) is paired_a
and phantoms
.get(uvedge
.vb
, uvedge
.vb
) is paired_b
:
990 # if these two edges will get merged, add them both to the set
991 merged_uvedges
.update((uvedge
, partner
))
992 merged_uvedge_pairs
.append((uvedge
, partner
))
995 if uvedge_b
not in merged_uvedges
:
996 raise UnfoldError("Export failed. Please report this error, including the model if you can.")
999 PhantomUVEdge(phantoms
[uvedge
.va
], phantoms
[uvedge
.vb
], flipped ^ uvedge
.uvface
.flipped
)
1000 for uvedge
in island_b
.boundary
if uvedge
not in merged_uvedges
]
1001 # TODO: if is_merged_mine, it might make sense to create a similar list from island_a.boundary as well
1003 incidence
= {vertex
.tup
for vertex
in phantoms
.values()}.intersection(vertex
.tup
for vertex
in island_a
.vertices
.values())
1004 incidence
= {position
: list() for position
in incidence
} # from now on, 'incidence' is a dict
1005 for uvedge
in chain(boundary_other
, island_a
.boundary
):
1006 if uvedge
.va
.co
== uvedge
.vb
.co
:
1008 for vertex
in (uvedge
.va
, uvedge
.vb
):
1009 site
= incidence
.get(vertex
.tup
)
1010 if site
is not None:
1012 for position
, segments
in incidence
.items():
1013 if len(segments
) <= 2:
1015 segments
.sort(key
=slope_from(position
))
1016 for right
, left
in pairs(segments
):
1017 is_left_ccw
= left
.is_uvface_upwards() ^
(left
.max.tup
== position
)
1018 is_right_ccw
= right
.is_uvface_upwards() ^
(right
.max.tup
== position
)
1019 if is_right_ccw
and not is_left_ccw
and type(right
) is not type(left
) and right
not in merged_uvedges
and left
not in merged_uvedges
:
1021 if (not is_right_ccw
and right
not in merged_uvedges
) ^
(is_left_ccw
and left
not in merged_uvedges
):
1024 # check for self-intersections
1027 sweepline
= QuickSweepline() if island_a
.has_safe_geometry
and island_b
.has_safe_geometry
else BruteSweepline()
1028 sweep(sweepline
, (uvedge
for uvedge
in chain(boundary_other
, island_a
.boundary
)))
1029 island_a
.has_safe_geometry
&= island_b
.has_safe_geometry
1030 except GeometryError
:
1031 sweep(BruteSweepline(), (uvedge
for uvedge
in chain(boundary_other
, island_a
.boundary
)))
1032 island_a
.has_safe_geometry
= False
1033 except Intersection
:
1036 # mark all edges that connect the islands as not cut
1037 for uvedge
in merged_uvedges
:
1038 island_a
.mesh
.edges
[uvedge
.loop
.edge
].is_main_cut
= False
1040 # include all transformed vertices as mine
1041 island_a
.vertices
.update({loop
: phantoms
[uvvertex
] for loop
, uvvertex
in island_b
.vertices
.items()})
1043 # re-link uvedges and uvfaces to their transformed locations
1044 for uvedge
in island_b
.edges
.values():
1045 uvedge
.va
= phantoms
[uvedge
.va
]
1046 uvedge
.vb
= phantoms
[uvedge
.vb
]
1049 for uvedge
in island_a
.edges
.values():
1050 uvedge
.va
= phantoms
.get(uvedge
.va
, uvedge
.va
)
1051 uvedge
.vb
= phantoms
.get(uvedge
.vb
, uvedge
.vb
)
1052 island_a
.edges
.update(island_b
.edges
)
1054 for uvface
in island_b
.faces
.values():
1055 uvface
.island
= island_a
1056 uvface
.vertices
= {loop
: phantoms
[uvvertex
] for loop
, uvvertex
in uvface
.vertices
.items()}
1057 uvface
.flipped ^
= flipped
1059 # there may be own uvvertices that need to be replaced by phantoms
1060 for uvface
in island_a
.faces
.values():
1061 if any(uvvertex
in phantoms
for uvvertex
in uvface
.vertices
):
1062 uvface
.vertices
= {loop
: phantoms
.get(uvvertex
, uvvertex
) for loop
, uvvertex
in uvface
.vertices
.items()}
1063 island_a
.faces
.update(island_b
.faces
)
1065 island_a
.boundary
= [
1066 uvedge
for uvedge
in chain(island_a
.boundary
, island_b
.boundary
)
1067 if uvedge
not in merged_uvedges
]
1069 for uvedge
, partner
in merged_uvedge_pairs
:
1070 # make sure that main faces are the ones actually merged (this changes nothing in most cases)
1071 edge
= island_a
.mesh
.edges
[uvedge
.loop
.edge
]
1072 edge
.main_faces
= uvedge
.loop
, partner
.loop
1074 # everything seems to be OK
1079 """Container for several Islands"""
1080 __slots__
= ('islands', 'name', 'image_path')
1082 def __init__(self
, num
=1):
1083 self
.islands
= list()
1084 self
.name
= "page{}".format(num
) # note: this is only used in svg files naming
1085 self
.image_path
= None
1090 __slots__
= ('co', 'tup')
1092 def __init__(self
, vector
):
1094 self
.tup
= tuple(self
.co
)
1099 # Every UVEdge is attached to only one UVFace
1100 # UVEdges are doubled as needed because they both have to point clockwise around their faces
1102 'va', 'vb', 'uvface', 'loop',
1103 'min', 'max', 'bottom', 'top',
1104 'neighbor_left', 'neighbor_right', 'sticker')
1106 def __init__(self
, vertex1
: UVVertex
, vertex2
: UVVertex
, uvface
, loop
):
1110 self
.uvface
= uvface
1115 """Update data if UVVertices have moved"""
1116 self
.min, self
.max = (self
.va
, self
.vb
) if (self
.va
.tup
< self
.vb
.tup
) else (self
.vb
, self
.va
)
1117 y1
, y2
= self
.va
.co
.y
, self
.vb
.co
.y
1118 self
.bottom
, self
.top
= (y1
, y2
) if y1
< y2
else (y2
, y1
)
1120 def is_uvface_upwards(self
):
1121 return (self
.va
.tup
< self
.vb
.tup
) ^ self
.uvface
.flipped
1124 return "({0.va} - {0.vb})".format(self
)
1127 class PhantomUVEdge
:
1128 """Temporary 2D Segment for calculations"""
1129 __slots__
= ('va', 'vb', 'min', 'max', 'bottom', 'top')
1131 def __init__(self
, vertex1
: UVVertex
, vertex2
: UVVertex
, flip
):
1132 self
.va
, self
.vb
= (vertex2
, vertex1
) if flip
else (vertex1
, vertex2
)
1133 self
.min, self
.max = (self
.va
, self
.vb
) if (self
.va
.tup
< self
.vb
.tup
) else (self
.vb
, self
.va
)
1134 y1
, y2
= self
.va
.co
.y
, self
.vb
.co
.y
1135 self
.bottom
, self
.top
= (y1
, y2
) if y1
< y2
else (y2
, y1
)
1137 def is_uvface_upwards(self
):
1138 return self
.va
.tup
< self
.vb
.tup
1141 return "[{0.va} - {0.vb}]".format(self
)
1146 __slots__
= ('vertices', 'edges', 'face', 'island', 'flipped')
1148 def __init__(self
, face
: bmesh
.types
.BMFace
, island
: Island
, matrix
=1, normal_matrix
=1):
1150 self
.island
= island
1151 self
.flipped
= False # a flipped UVFace has edges clockwise
1153 flatten
= z_up_matrix(normal_matrix
@ face
.normal
) @ matrix
1154 self
.vertices
= {loop
: UVVertex(flatten
@ loop
.vert
.co
) for loop
in face
.loops
}
1155 self
.edges
= {loop
: UVEdge(self
.vertices
[loop
], self
.vertices
[loop
.link_loop_next
], self
, loop
) for loop
in face
.loops
}
1159 """Mark in the document: an arrow denoting the number of the edge it points to"""
1160 __slots__
= ('bounds', 'center', 'rot', 'text', 'size')
1162 def __init__(self
, uvedge
, size
, index
):
1163 self
.text
= str(index
)
1164 edge
= (uvedge
.vb
.co
- uvedge
.va
.co
) if not uvedge
.uvface
.flipped
else (uvedge
.va
.co
- uvedge
.vb
.co
)
1165 self
.center
= (uvedge
.va
.co
+ uvedge
.vb
.co
) / 2
1167 tangent
= edge
.normalized()
1169 self
.rot
= M
.Matrix(((cos
, -sin
), (sin
, cos
)))
1170 normal
= M
.Vector((sin
, -cos
))
1171 self
.bounds
= [self
.center
, self
.center
+ (1.2 * normal
+ tangent
) * size
, self
.center
+ (1.2 * normal
- tangent
) * size
]
1175 """Mark in the document: sticker tab"""
1176 __slots__
= ('bounds', 'center', 'points', 'rot', 'text', 'width')
1178 def __init__(self
, uvedge
, default_width
, index
, other
: UVEdge
):
1179 """Sticker is directly attached to the given UVEdge"""
1180 first_vertex
, second_vertex
= (uvedge
.va
, uvedge
.vb
) if not uvedge
.uvface
.flipped
else (uvedge
.vb
, uvedge
.va
)
1181 edge
= first_vertex
.co
- second_vertex
.co
1182 sticker_width
= min(default_width
, edge
.length
/ 2)
1183 other_first
, other_second
= (other
.va
, other
.vb
) if not other
.uvface
.flipped
else (other
.vb
, other
.va
)
1184 other_edge
= other_second
.co
- other_first
.co
1186 # angle a is at vertex uvedge.va, b is at uvedge.vb
1188 sin_a
= sin_b
= 0.75**0.5
1189 # len_a is length of the side adjacent to vertex a, len_b likewise
1190 len_a
= len_b
= sticker_width
/ sin_a
1192 # fix overlaps with the most often neighbour - its sticking target
1193 if first_vertex
== other_second
:
1194 cos_a
= max(cos_a
, edge
.dot(other_edge
) / (edge
.length_squared
)) # angles between pi/3 and 0
1195 elif second_vertex
== other_first
:
1196 cos_b
= max(cos_b
, edge
.dot(other_edge
) / (edge
.length_squared
)) # angles between pi/3 and 0
1198 # Fix tabs for sticking targets with small angles
1200 other_face_neighbor_left
= other
.neighbor_left
1201 other_face_neighbor_right
= other
.neighbor_right
1202 other_edge_neighbor_a
= other_face_neighbor_left
.vb
.co
- other
.vb
.co
1203 other_edge_neighbor_b
= other_face_neighbor_right
.va
.co
- other
.va
.co
1204 # Adjacent angles in the face
1205 cos_a
= max(cos_a
, -other_edge
.dot(other_edge_neighbor_a
) / (other_edge
.length
*other_edge_neighbor_a
.length
))
1206 cos_b
= max(cos_b
, other_edge
.dot(other_edge_neighbor_b
) / (other_edge
.length
*other_edge_neighbor_b
.length
))
1207 except AttributeError: # neighbor data may be missing for edges with 3+ faces
1209 except ZeroDivisionError:
1212 # Calculate the lengths of the glue tab edges using the possibly smaller angles
1213 sin_a
= abs(1 - cos_a
**2)**0.5
1214 len_b
= min(len_a
, (edge
.length
* sin_a
) / (sin_a
* cos_b
+ sin_b
* cos_a
))
1215 len_a
= 0 if sin_a
== 0 else min(sticker_width
/ sin_a
, (edge
.length
- len_b
*cos_b
) / cos_a
)
1217 sin_b
= abs(1 - cos_b
**2)**0.5
1218 len_a
= min(len_a
, (edge
.length
* sin_b
) / (sin_a
* cos_b
+ sin_b
* cos_a
))
1219 len_b
= 0 if sin_b
== 0 else min(sticker_width
/ sin_b
, (edge
.length
- len_a
* cos_a
) / cos_b
)
1221 v3
= second_vertex
.co
+ M
.Matrix(((cos_b
, -sin_b
), (sin_b
, cos_b
))) @ edge
* len_b
/ edge
.length
1222 v4
= first_vertex
.co
+ M
.Matrix(((-cos_a
, -sin_a
), (sin_a
, -cos_a
))) @ edge
* len_a
/ edge
.length
1224 self
.points
= [second_vertex
.co
, v3
, v4
, first_vertex
.co
]
1226 self
.points
= [second_vertex
.co
, v3
, first_vertex
.co
]
1228 sin
, cos
= edge
.y
/ edge
.length
, edge
.x
/ edge
.length
1229 self
.rot
= M
.Matrix(((cos
, -sin
), (sin
, cos
)))
1230 self
.width
= sticker_width
* 0.9
1231 if index
and uvedge
.uvface
.island
is not other
.uvface
.island
:
1232 self
.text
= "{}:{}".format(other
.uvface
.island
.abbreviation
, index
)
1235 self
.center
= (uvedge
.va
.co
+ uvedge
.vb
.co
) / 2 + self
.rot
@ M
.Vector((0, self
.width
* 0.2))
1236 self
.bounds
= [v3
, v4
, self
.center
] if v3
!= v4
else [v3
, self
.center
]
1240 """Mark in the document: numbering inside the island denoting edges to be sticked"""
1241 __slots__
= ('bounds', 'center', 'rot', 'text', 'size')
1243 def __init__(self
, uvedge
, index
, default_size
=0.005):
1244 """Sticker is directly attached to the given UVEdge"""
1245 edge
= (uvedge
.va
.co
- uvedge
.vb
.co
) if not uvedge
.uvface
.flipped
else (uvedge
.vb
.co
- uvedge
.va
.co
)
1247 self
.size
= default_size
1248 sin
, cos
= edge
.y
/ edge
.length
, edge
.x
/ edge
.length
1249 self
.rot
= M
.Matrix(((cos
, -sin
), (sin
, cos
)))
1251 self
.center
= (uvedge
.va
.co
+ uvedge
.vb
.co
) / 2 - self
.rot
@ M
.Vector((0, self
.size
* 1.2))
1252 self
.bounds
= [self
.center
]
1255 def init_exporter(self
, properties
):
1256 self
.page_size
= M
.Vector((properties
.output_size_x
, properties
.output_size_y
))
1257 self
.style
= properties
.style
1258 margin
= properties
.output_margin
1259 self
.margin
= M
.Vector((margin
, margin
))
1260 self
.pure_net
= (properties
.output_type
== 'NONE')
1261 self
.do_create_stickers
= properties
.do_create_stickers
1262 self
.text_size
= properties
.sticker_width
1263 self
.angle_epsilon
= properties
.angle_epsilon
1267 """Simple SVG exporter"""
1269 def __init__(self
, properties
):
1270 init_exporter(self
, properties
)
1273 def encode_image(cls
, bpy_image
):
1276 with tempfile
.TemporaryDirectory() as directory
:
1277 filename
= directory
+ "/i.png"
1278 bpy_image
.filepath_raw
= filename
1280 return base64
.encodebytes(open(filename
, "rb").read()).decode('ascii')
1282 def format_vertex(self
, vector
):
1283 """Return a string with both coordinates of the given vertex."""
1284 return "{:.6f} {:.6f}".format((vector
.x
+ self
.margin
.x
) * 1000, (self
.page_size
.y
- vector
.y
- self
.margin
.y
) * 1000)
1286 def write(self
, mesh
, filename
):
1287 """Write data to a file given by its name."""
1288 line_through
= " L ".join
# used for formatting of SVG path data
1291 dl
= ["{:.2f}".format(length
* self
.style
.line_width
* 1000) for length
in (2, 5, 10)]
1293 'SOLID': "none", 'DOT': "{0},{1}".format(*dl
), 'DASH': "{1},{2}".format(*dl
),
1294 'LONGDASH': "{2},{1}".format(*dl
), 'DASHDOT': "{2},{1},{0},{1}".format(*dl
)}
1296 def format_color(vec
):
1297 return "#{:02x}{:02x}{:02x}".format(round(vec
[0] * 255), round(vec
[1] * 255), round(vec
[2] * 255))
1299 def format_matrix(matrix
):
1300 return " ".join("{:.6f}".format(cell
) for column
in matrix
for cell
in column
)
1302 def path_convert(string
, relto
=os_path
.dirname(filename
)):
1303 assert(os_path
) # check the module was imported
1304 string
= os_path
.relpath(string
, relto
)
1305 if os_path
.sep
!= '/':
1306 string
= string
.replace(os_path
.sep
, '/')
1310 name
: format_color(getattr(self
.style
, name
)) for name
in (
1311 "outer_color", "outbg_color", "convex_color", "concave_color", "freestyle_color",
1312 "inbg_color", "sticker_color", "text_color")}
1314 name
: format_style
[getattr(self
.style
, name
)] for name
in
1315 ("outer_style", "convex_style", "concave_style", "freestyle_style")})
1317 name
: getattr(self
.style
, attr
)[3] for name
, attr
in (
1318 ("outer_alpha", "outer_color"), ("outbg_alpha", "outbg_color"),
1319 ("convex_alpha", "convex_color"), ("concave_alpha", "concave_color"),
1320 ("freestyle_alpha", "freestyle_color"),
1321 ("inbg_alpha", "inbg_color"), ("sticker_alpha", "sticker_color"),
1322 ("text_alpha", "text_color"))})
1324 name
: getattr(self
.style
, name
) * self
.style
.line_width
* 1000 for name
in
1325 ("outer_width", "convex_width", "concave_width", "freestyle_width", "outbg_width", "inbg_width")})
1326 for num
, page
in enumerate(mesh
.pages
):
1327 page_filename
= "{}_{}.svg".format(filename
[:filename
.rfind(".svg")], page
.name
) if len(mesh
.pages
) > 1 else filename
1328 with
open(page_filename
, 'w') as f
:
1329 print(self
.svg_base
.format(width
=self
.page_size
.x
*1000, height
=self
.page_size
.y
*1000), file=f
)
1330 print(self
.css_base
.format(**styleargs
), file=f
)
1333 self
.image_linked_tag
.format(
1334 pos
="{0:.6f} {0:.6f}".format(self
.margin
.x
*1000),
1335 width
=(self
.page_size
.x
- 2 * self
.margin
.x
)*1000,
1336 height
=(self
.page_size
.y
- 2 * self
.margin
.y
)*1000,
1337 path
=path_convert(page
.image_path
)),
1339 if len(page
.islands
) > 1:
1340 print("<g>", file=f
)
1342 for island
in page
.islands
:
1343 print("<g>", file=f
)
1344 if island
.image_path
:
1346 self
.image_linked_tag
.format(
1347 pos
=self
.format_vertex(island
.pos
+ M
.Vector((0, island
.bounding_box
.y
))),
1348 width
=island
.bounding_box
.x
*1000,
1349 height
=island
.bounding_box
.y
*1000,
1350 path
=path_convert(island
.image_path
)),
1352 elif island
.embedded_image
:
1354 self
.image_embedded_tag
.format(
1355 pos
=self
.format_vertex(island
.pos
+ M
.Vector((0, island
.bounding_box
.y
))),
1356 width
=island
.bounding_box
.x
*1000,
1357 height
=island
.bounding_box
.y
*1000,
1358 path
=island
.image_path
),
1359 island
.embedded_image
, "'/>",
1363 self
.text_tag
.format(
1364 size
=1000 * self
.text_size
,
1365 x
=1000 * (island
.bounding_box
.x
*0.5 + island
.pos
.x
+ self
.margin
.x
),
1366 y
=1000 * (self
.page_size
.y
- island
.pos
.y
- self
.margin
.y
- 0.2 * self
.text_size
),
1367 label
=island
.title
),
1370 data_markers
, data_stickerfill
= list(), list()
1371 for marker
in island
.markers
:
1372 if isinstance(marker
, Sticker
):
1373 data_stickerfill
.append("M {} Z".format(
1374 line_through(self
.format_vertex(co
+ island
.pos
) for co
in marker
.points
)))
1376 data_markers
.append(self
.text_transformed_tag
.format(
1378 pos
=self
.format_vertex(marker
.center
+ island
.pos
),
1379 mat
=format_matrix(marker
.rot
),
1380 size
=marker
.width
* 1000))
1381 elif isinstance(marker
, Arrow
):
1382 size
= marker
.size
* 1000
1383 position
= marker
.center
+ marker
.size
* marker
.rot
@ M
.Vector((0, -0.9))
1384 data_markers
.append(self
.arrow_marker_tag
.format(
1386 arrow_pos
=self
.format_vertex(marker
.center
+ island
.pos
),
1388 pos
=self
.format_vertex(position
+ island
.pos
- marker
.size
*M
.Vector((0, 0.4))),
1389 mat
=format_matrix(size
* marker
.rot
)))
1390 elif isinstance(marker
, NumberAlone
):
1391 data_markers
.append(self
.text_transformed_tag
.format(
1393 pos
=self
.format_vertex(marker
.center
+ island
.pos
),
1394 mat
=format_matrix(marker
.rot
),
1395 size
=marker
.size
* 1000))
1396 if data_stickerfill
and self
.style
.sticker_color
[3] > 0:
1397 print("<path class='sticker' d='", rows(data_stickerfill
), "'/>", file=f
)
1399 data_outer
, data_convex
, data_concave
, data_freestyle
= (list() for i
in range(4))
1400 outer_edges
= set(island
.boundary
)
1403 uvedge
= outer_edges
.pop()
1406 data_loop
.extend(self
.format_vertex(co
+ island
.pos
) for co
in uvedge
.sticker
.points
[1:])
1408 vertex
= uvedge
.vb
if uvedge
.uvface
.flipped
else uvedge
.va
1409 data_loop
.append(self
.format_vertex(vertex
.co
+ island
.pos
))
1410 uvedge
= uvedge
.neighbor_right
1412 outer_edges
.remove(uvedge
)
1415 data_outer
.append("M {} Z".format(line_through(data_loop
)))
1417 visited_edges
= set()
1418 for loop
, uvedge
in island
.edges
.items():
1419 edge
= mesh
.edges
[loop
.edge
]
1420 if edge
.is_cut(uvedge
.uvface
.face
) and not uvedge
.sticker
:
1422 data_uvedge
= "M {}".format(
1423 line_through(self
.format_vertex(v
.co
+ island
.pos
) for v
in (uvedge
.va
, uvedge
.vb
)))
1425 data_freestyle
.append(data_uvedge
)
1426 # each uvedge is in two opposite-oriented variants; we want to add each only once
1427 vertex_pair
= frozenset((uvedge
.va
, uvedge
.vb
))
1428 if vertex_pair
not in visited_edges
:
1429 visited_edges
.add(vertex_pair
)
1430 if edge
.angle
> self
.angle_epsilon
:
1431 data_convex
.append(data_uvedge
)
1432 elif edge
.angle
< -self
.angle_epsilon
:
1433 data_concave
.append(data_uvedge
)
1434 if island
.is_inside_out
:
1435 data_convex
, data_concave
= data_concave
, data_convex
1438 print("<path class='freestyle' d='", rows(data_freestyle
), "'/>", file=f
)
1439 if (data_convex
or data_concave
) and not self
.pure_net
and self
.style
.use_inbg
:
1440 print("<path class='inner_background' d='", rows(data_convex
+ data_concave
), "'/>", file=f
)
1442 print("<path class='convex' d='", rows(data_convex
), "'/>", file=f
)
1444 print("<path class='concave' d='", rows(data_concave
), "'/>", file=f
)
1446 if not self
.pure_net
and self
.style
.use_outbg
:
1447 print("<path class='outer_background' d='", rows(data_outer
), "'/>", file=f
)
1448 print("<path class='outer' d='", rows(data_outer
), "'/>", file=f
)
1450 print(rows(data_markers
), file=f
)
1451 print("</g>", file=f
)
1453 if len(page
.islands
) > 1:
1454 print("</g>", file=f
)
1455 print("</svg>", file=f
)
1457 image_linked_tag
= "<image transform='translate({pos})' width='{width:.6f}' height='{height:.6f}' xlink:href='{path}'/>"
1458 image_embedded_tag
= "<image transform='translate({pos})' width='{width:.6f}' height='{height:.6f}' xlink:href='data:image/png;base64,"
1459 text_tag
= "<text transform='translate({x} {y})' style='font-size:{size:.2f}'><tspan>{label}</tspan></text>"
1460 text_transformed_tag
= "<text transform='matrix({mat} {pos})' style='font-size:{size:.2f}'><tspan>{label}</tspan></text>"
1461 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'/>" \
1462 "<text transform='translate({pos})' style='font-size:{scale:.2f}'><tspan>{index}</tspan></text></g>"
1464 svg_base
= """<?xml version='1.0' encoding='UTF-8' standalone='no'?>
1465 <svg xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' version='1.1'
1466 width='{width:.2f}mm' height='{height:.2f}mm' viewBox='0 0 {width:.2f} {height:.2f}'>"""
1468 css_base
= """<style type="text/css">
1471 stroke-linecap: butt;
1472 stroke-linejoin: bevel;
1473 stroke-dasharray: none;
1476 stroke: {outer_color};
1477 stroke-dasharray: {outer_style};
1478 stroke-dashoffset: 0;
1479 stroke-width: {outer_width:.2};
1480 stroke-opacity: {outer_alpha:.2};
1483 stroke: {convex_color};
1484 stroke-dasharray: {convex_style};
1485 stroke-dashoffset:0;
1486 stroke-width:{convex_width:.2};
1487 stroke-opacity: {convex_alpha:.2}
1490 stroke: {concave_color};
1491 stroke-dasharray: {concave_style};
1492 stroke-dashoffset: 0;
1493 stroke-width: {concave_width:.2};
1494 stroke-opacity: {concave_alpha:.2}
1497 stroke: {freestyle_color};
1498 stroke-dasharray: {freestyle_style};
1499 stroke-dashoffset: 0;
1500 stroke-width: {freestyle_width:.2};
1501 stroke-opacity: {freestyle_alpha:.2}
1503 path.outer_background {{
1504 stroke: {outbg_color};
1505 stroke-opacity: {outbg_alpha};
1506 stroke-width: {outbg_width:.2}
1508 path.inner_background {{
1509 stroke: {inbg_color};
1510 stroke-opacity: {inbg_alpha};
1511 stroke-width: {inbg_width:.2}
1514 fill: {sticker_color};
1516 fill-opacity: {sticker_alpha:.2};
1524 fill-opacity: {text_alpha:.2};
1534 """Simple PDF exporter"""
1536 mm_to_pt
= 72 / 25.4
1537 character_width_packed
= {
1538 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·ÌÍÎÏìíîï',
1539 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¿ßø',
1540 667: '&ABEKPSVXY\x8a\x9fÀÁÂÃÄÅÈÉÊËÝÞ', 722: 'CDHNRUwÇÐÑÙÚÛÜ', 737: '©®', 778: 'GOQÒÓÔÕÖØ', 833: 'Mm¼½¾', 889: '%æ', 944: 'W\x9c', 1000: '\x85\x89\x8c\x97\x99Æ', 1015: '@', }
1541 character_width
= {c
: value
for (value
, chars
) in character_width_packed
.items() for c
in chars
}
1543 def __init__(self
, properties
):
1544 init_exporter(self
, properties
)
1545 self
.styles
= dict()
1547 def text_width(self
, text
, scale
=None):
1548 return (scale
or self
.text_size
) * sum(self
.character_width
.get(c
, 556) for c
in text
) / 1000
1550 def styling(self
, name
, do_stroke
=True):
1551 s
, m
, l
= (length
* self
.style
.line_width
* 1000 for length
in (1, 4, 9))
1552 format_style
= {'SOLID': [], 'DOT': [s
, m
], 'DASH': [m
, l
], 'LONGDASH': [l
, m
], 'DASHDOT': [l
, m
, s
, m
]}
1553 style
, color
, width
= (getattr(self
.style
, f
"{name}_{arg}", None) for arg
in ("style", "color", "width"))
1554 style
= style
or 'SOLID'
1558 "[ " + " ".join("{:.3f}".format(num
) for num
in format_style
[style
]) + " ] 0 d",
1559 "{0:.3f} {1:.3f} {2:.3f} RG".format(*color
),
1560 "{:.3f} w".format(self
.style
.line_width
* 1000 * width
),
1563 result
.append("{0:.3f} {1:.3f} {2:.3f} rg".format(*color
))
1565 style_name
= "R{:03}".format(round(1000 * color
[3]))
1566 result
.append("/{} gs".format(style_name
))
1567 if style_name
not in self
.styles
:
1568 self
.styles
[style_name
] = {"CA": color
[3], "ca": color
[3]}
1572 def encode_image(cls
, bpy_image
):
1573 data
= bytes(int(255 * px
) for (i
, px
) in enumerate(bpy_image
.pixels
) if i
% 4 != 3)
1575 "Type": "XObject", "Subtype": "Image", "Width": bpy_image
.size
[0], "Height": bpy_image
.size
[1],
1576 "ColorSpace": "DeviceRGB", "BitsPerComponent": 8, "Interpolate": True,
1577 "Filter": ["ASCII85Decode", "FlateDecode"], "stream": data
}
1580 def write(self
, mesh
, filename
):
1581 def format_dict(obj
, refs
=tuple()):
1582 content
= "".join("/{} {}\n".format(key
, format_value(value
, refs
)) for (key
, value
) in obj
.items())
1583 return f
"<< {content} >>"
1585 def line_through(seq
):
1586 fmt
= "{0.x:.6f} {0.y:.6f} {1} ".format
1587 return "".join(fmt(1000*co
, cmd
) for (co
, cmd
) in zip(seq
, chain("m", repeat("l"))))
1589 def format_value(value
, refs
=tuple()):
1591 return "{} 0 R".format(refs
.index(value
) + 1)
1592 elif type(value
) is dict:
1593 return format_dict(value
, refs
)
1594 elif type(value
) in (list, tuple):
1595 return "[ " + " ".join(format_value(item
, refs
) for item
in value
) + " ]"
1596 elif type(value
) is int:
1598 elif type(value
) is float:
1599 return "{:.6f}".format(value
)
1600 elif type(value
) is bool:
1601 return "true" if value
else "false"
1603 return "/{}".format(value
) # this script can output only PDF names, no strings
1605 def write_object(index
, obj
, refs
, f
, stream
=None):
1606 byte_count
= f
.write("{} 0 obj\n".format(index
).encode())
1607 if type(obj
) is not dict:
1608 stream
, obj
= obj
, dict()
1609 elif "stream" in obj
:
1610 stream
= obj
.pop("stream")
1612 obj
["Filter"] = "FlateDecode"
1613 stream
= encode(stream
)
1614 obj
["Length"] = len(stream
)
1615 byte_count
+= f
.write(format_dict(obj
, refs
).encode())
1617 byte_count
+= f
.write(b
"\nstream\n")
1618 byte_count
+= f
.write(stream
)
1619 byte_count
+= f
.write(b
"\nendstream")
1620 return byte_count
+ f
.write(b
"\nendobj\n")
1623 from zlib
import compress
1624 if hasattr(data
, "encode"):
1625 data
= data
.encode()
1626 return compress(data
)
1628 page_size_pt
= 1000 * self
.mm_to_pt
* self
.page_size
1629 reset_style
= ["Q"] # graphic command for later use
1630 root
= {"Type": "Pages", "MediaBox": [0, 0, page_size_pt
.x
, page_size_pt
.y
], "Kids": list()}
1631 catalog
= {"Type": "Catalog", "Pages": root
}
1633 "Type": "Font", "Subtype": "Type1", "Name": "F1",
1634 "BaseFont": "Helvetica", "Encoding": "MacRomanEncoding"}
1635 objects
= [root
, catalog
, font
]
1637 for page
in mesh
.pages
:
1638 commands
= ["{0:.6f} 0 0 {0:.6f} 0 0 cm".format(self
.mm_to_pt
)]
1639 resources
= {"Font": {"F1": font
}, "ExtGState": self
.styles
, "ProcSet": ["PDF"]}
1640 if any(island
.embedded_image
for island
in page
.islands
):
1641 resources
["XObject"] = dict()
1642 resources
["ProcSet"].append("ImageC")
1643 for island
in page
.islands
:
1644 commands
.append("q 1 0 0 1 {0.x:.6f} {0.y:.6f} cm".format(1000*(self
.margin
+ island
.pos
)))
1645 if island
.embedded_image
:
1646 identifier
= "I{}".format(len(resources
["XObject"]) + 1)
1647 commands
.append(self
.command_image
.format(1000 * island
.bounding_box
, identifier
))
1648 objects
.append(island
.embedded_image
)
1649 resources
["XObject"][identifier
] = island
.embedded_image
1652 commands
+= self
.styling("text", do_stroke
=False)
1653 commands
.append(self
.command_label
.format(
1654 size
=1000*self
.text_size
,
1655 x
=500 * (island
.bounding_box
.x
- self
.text_width(island
.title
)),
1656 y
=1000 * 0.2 * self
.text_size
,
1657 label
=island
.title
))
1658 commands
+= reset_style
1660 data_markers
, data_stickerfill
= list(), list()
1661 for marker
in island
.markers
:
1662 if isinstance(marker
, Sticker
):
1663 data_stickerfill
.append(line_through(marker
.points
) + "f")
1665 data_markers
.append(self
.command_sticker
.format(
1667 pos
=1000*marker
.center
,
1669 align
=-500 * self
.text_width(marker
.text
, marker
.width
),
1670 size
=1000*marker
.width
))
1671 elif isinstance(marker
, Arrow
):
1672 size
= 1000 * marker
.size
1673 position
= 1000 * (marker
.center
+ marker
.size
* marker
.rot
@ M
.Vector((0, -0.9)))
1674 data_markers
.append(self
.command_arrow
.format(
1676 arrow_pos
=1000 * marker
.center
,
1677 pos
=position
- 1000 * M
.Vector((0.5 * self
.text_width(marker
.text
), 0.4 * self
.text_size
)),
1678 mat
=size
* marker
.rot
,
1680 elif isinstance(marker
, NumberAlone
):
1681 data_markers
.append(self
.command_number
.format(
1683 pos
=1000*marker
.center
,
1685 size
=1000*marker
.size
))
1687 data_outer
, data_convex
, data_concave
, data_freestyle
= (list() for i
in range(4))
1688 outer_edges
= set(island
.boundary
)
1691 uvedge
= outer_edges
.pop()
1694 data_loop
.extend(uvedge
.sticker
.points
[1:])
1696 vertex
= uvedge
.vb
if uvedge
.uvface
.flipped
else uvedge
.va
1697 data_loop
.append(vertex
.co
)
1698 uvedge
= uvedge
.neighbor_right
1700 outer_edges
.remove(uvedge
)
1703 data_outer
.append(line_through(data_loop
) + "s")
1705 for loop
, uvedge
in island
.edges
.items():
1706 edge
= mesh
.edges
[loop
.edge
]
1707 if edge
.is_cut(uvedge
.uvface
.face
) and not uvedge
.sticker
:
1709 data_uvedge
= line_through((uvedge
.va
.co
, uvedge
.vb
.co
)) + "S"
1711 data_freestyle
.append(data_uvedge
)
1712 # each uvedge exists in two opposite-oriented variants; we want to add each only once
1713 if uvedge
.sticker
or uvedge
.uvface
.flipped
!= (id(uvedge
.va
) > id(uvedge
.vb
)):
1714 if edge
.angle
> self
.angle_epsilon
:
1715 data_convex
.append(data_uvedge
)
1716 elif edge
.angle
< -self
.angle_epsilon
:
1717 data_concave
.append(data_uvedge
)
1718 if island
.is_inside_out
:
1719 data_convex
, data_concave
= data_concave
, data_convex
1721 if data_stickerfill
and self
.style
.sticker_color
[3] > 0:
1722 commands
+= chain(self
.styling("sticker", do_stroke
=False), data_stickerfill
, reset_style
)
1724 commands
+= chain(self
.styling("freestyle"), data_freestyle
, reset_style
)
1725 if (data_convex
or data_concave
) and not self
.pure_net
and self
.style
.use_inbg
:
1726 commands
+= chain(self
.styling("inbg"), data_convex
, data_concave
, reset_style
)
1728 commands
+= chain(self
.styling("convex"), data_convex
, reset_style
)
1730 commands
+= chain(self
.styling("concave"), data_concave
, reset_style
)
1732 if not self
.pure_net
and self
.style
.use_outbg
:
1733 commands
+= chain(self
.styling("outbg"), data_outer
, reset_style
)
1734 commands
+= chain(self
.styling("outer"), data_outer
, reset_style
)
1736 commands
+= chain(self
.styling("text", do_stroke
=False), data_markers
, reset_style
)
1737 commands
+= reset_style
# return from island to page coordinates
1738 content
= "\n".join(commands
)
1739 page
= {"Type": "Page", "Parent": root
, "Contents": content
, "Resources": resources
}
1740 root
["Kids"].append(page
)
1741 objects
+= page
, content
1742 objects
.extend(self
.styles
.values())
1744 root
["Count"] = len(root
["Kids"])
1745 with
open(filename
, "wb+") as f
:
1748 position
+= f
.write(b
"%PDF-1.4\n")
1749 position
+= f
.write(b
"%\xde\xad\xbe\xef\n")
1750 for index
, obj
in enumerate(objects
, 1):
1751 xref_table
.append(position
)
1752 position
+= write_object(index
, obj
, objects
, f
)
1754 f
.write("xref\n0 {}\n".format(len(xref_table
) + 1).encode())
1755 f
.write("{:010} {:05} f\r\n".format(0, 65535).encode())
1756 for position
in xref_table
:
1757 f
.write("{:010} {:05} n\r\n".format(position
, 0).encode())
1758 f
.write(b
"trailer\n")
1759 f
.write(format_dict({"Size": len(xref_table
) + 1, "Root": catalog
}, objects
).encode())
1760 f
.write("\nstartxref\n{}\n%%EOF\n".format(xref_pos
).encode())
1762 command_label
= "q /F1 {size:.6f} Tf BT {x:.6f} {y:.6f} Td ({label}) Tj ET Q"
1763 command_image
= "q {0.x:.6f} 0 0 {0.y:.6f} 0 0 cm 1 0 0 -1 0 1 cm /{1} Do Q"
1764 command_sticker
= "q /F1 {size:.6f} Tf {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 ({label}) Tj ET Q"
1765 command_arrow
= "q /F1 {size:.6f} Tf BT {pos.x:.6f} {pos.y:.6f} Td ({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"
1766 command_number
= "q /F1 {size:.6f} Tf {mat[0][0]:.6f} {mat[1][0]:.6f} {mat[0][1]:.6f} {mat[1][1]:.6f} {pos.x:.6f} {pos.y:.6f} cm BT ({label}) Tj ET Q"
1769 class Unfold(bpy
.types
.Operator
):
1770 """Blender Operator: unfold the selected object."""
1772 bl_idname
= "mesh.unfold"
1774 bl_description
= "Mark seams so that the mesh can be exported as a paper model"
1775 bl_options
= {'REGISTER', 'UNDO'}
1776 edit
: bpy
.props
.BoolProperty(default
=False, options
={'HIDDEN'})
1777 priority_effect_convex
: bpy
.props
.FloatProperty(
1778 name
="Priority Convex", description
="Priority effect for edges in convex angles",
1779 default
=default_priority_effect
['CONVEX'], soft_min
=-1, soft_max
=10, subtype
='FACTOR')
1780 priority_effect_concave
: bpy
.props
.FloatProperty(
1781 name
="Priority Concave", description
="Priority effect for edges in concave angles",
1782 default
=default_priority_effect
['CONCAVE'], soft_min
=-1, soft_max
=10, subtype
='FACTOR')
1783 priority_effect_length
: bpy
.props
.FloatProperty(
1784 name
="Priority Length", description
="Priority effect of edge length",
1785 default
=default_priority_effect
['LENGTH'], soft_min
=-10, soft_max
=1, subtype
='FACTOR')
1786 do_create_uvmap
: bpy
.props
.BoolProperty(
1787 name
="Create UVMap", description
="Create a new UV Map showing the islands and page layout", default
=False)
1791 def poll(cls
, context
):
1792 return context
.active_object
and context
.active_object
.type == "MESH"
1794 def draw(self
, context
):
1795 layout
= self
.layout
1796 col
= layout
.column()
1797 col
.active
= not self
.object or len(self
.object.data
.uv_layers
) < 8
1798 col
.prop(self
.properties
, "do_create_uvmap")
1799 layout
.label(text
="Edge Cutting Factors:")
1800 col
= layout
.column(align
=True)
1801 col
.label(text
="Face Angle:")
1802 col
.prop(self
.properties
, "priority_effect_convex", text
="Convex")
1803 col
.prop(self
.properties
, "priority_effect_concave", text
="Concave")
1804 layout
.prop(self
.properties
, "priority_effect_length", text
="Edge Length")
1806 def execute(self
, context
):
1807 sce
= bpy
.context
.scene
1808 settings
= sce
.paper_model
1809 recall_mode
= context
.object.mode
1810 bpy
.ops
.object.mode_set(mode
='EDIT')
1812 self
.object = context
.object
1814 cage_size
= M
.Vector((settings
.output_size_x
, settings
.output_size_y
))
1816 'CONVEX': self
.priority_effect_convex
,
1817 'CONCAVE': self
.priority_effect_concave
,
1818 'LENGTH': self
.priority_effect_length
}
1820 unfolder
= Unfolder(self
.object)
1821 unfolder
.do_create_uvmap
= self
.do_create_uvmap
1822 scale
= sce
.unit_settings
.scale_length
/ settings
.scale
1823 unfolder
.prepare(cage_size
, priority_effect
, scale
, settings
.limit_by_page
)
1824 unfolder
.mesh
.mark_cuts()
1825 except UnfoldError
as error
:
1826 self
.report(type={'ERROR_INVALID_INPUT'}, message
=error
.args
[0])
1828 bpy
.ops
.object.mode_set(mode
=recall_mode
)
1829 return {'CANCELLED'}
1830 mesh
= self
.object.data
1832 if mesh
.paper_island_list
:
1833 unfolder
.copy_island_names(mesh
.paper_island_list
)
1834 island_list
= mesh
.paper_island_list
1835 attributes
= {item
.label
: (item
.abbreviation
, item
.auto_label
, item
.auto_abbrev
) for item
in island_list
}
1836 island_list
.clear() # remove previously defined islands
1837 for island
in unfolder
.mesh
.islands
:
1838 # add islands to UI list and set default descriptions
1839 list_item
= island_list
.add()
1840 # add faces' IDs to the island
1841 for face
in island
.faces
:
1842 lface
= list_item
.faces
.add()
1843 lface
.id = face
.index
1844 list_item
["label"] = island
.label
1845 list_item
["abbreviation"], list_item
["auto_label"], list_item
["auto_abbrev"] = attributes
.get(
1847 (island
.abbreviation
, True, True))
1848 island_item_changed(list_item
, context
)
1849 mesh
.paper_island_index
= -1
1852 bpy
.ops
.object.mode_set(mode
=recall_mode
)
1856 class ClearAllSeams(bpy
.types
.Operator
):
1857 """Blender Operator: clear all seams of the active Mesh and all its unfold data"""
1859 bl_idname
= "mesh.clear_all_seams"
1860 bl_label
= "Clear All Seams"
1861 bl_description
= "Clear all the seams and unfolded islands of the active object"
1864 def poll(cls
, context
):
1865 return context
.active_object
and context
.active_object
.type == 'MESH'
1867 def execute(self
, context
):
1868 ob
= context
.active_object
1871 for edge
in mesh
.edges
:
1872 edge
.use_seam
= False
1873 mesh
.paper_island_list
.clear()
1878 def page_size_preset_changed(self
, context
):
1879 """Update the actual document size to correct values"""
1880 if hasattr(self
, "limit_by_page") and not self
.limit_by_page
:
1882 if self
.page_size_preset
== 'A4':
1883 self
.output_size_x
= 0.210
1884 self
.output_size_y
= 0.297
1885 elif self
.page_size_preset
== 'A3':
1886 self
.output_size_x
= 0.297
1887 self
.output_size_y
= 0.420
1888 elif self
.page_size_preset
== 'US_LETTER':
1889 self
.output_size_x
= 0.216
1890 self
.output_size_y
= 0.279
1891 elif self
.page_size_preset
== 'US_LEGAL':
1892 self
.output_size_x
= 0.216
1893 self
.output_size_y
= 0.356
1896 class PaperModelStyle(bpy
.types
.PropertyGroup
):
1898 ('SOLID', "Solid (----)", "Solid line"),
1899 ('DOT', "Dots (. . .)", "Dotted line"),
1900 ('DASH', "Short Dashes (- - -)", "Solid line"),
1901 ('LONGDASH', "Long Dashes (-- --)", "Solid line"),
1902 ('DASHDOT', "Dash-dotted (-- .)", "Solid line")
1904 outer_color
: bpy
.props
.FloatVectorProperty(
1905 name
="Outer Lines", description
="Color of net outline",
1906 default
=(0.0, 0.0, 0.0, 1.0), min=0, max=1, subtype
='COLOR', size
=4)
1907 outer_style
: bpy
.props
.EnumProperty(
1908 name
="Outer Lines Drawing Style", description
="Drawing style of net outline",
1909 default
='SOLID', items
=line_styles
)
1910 line_width
: bpy
.props
.FloatProperty(
1911 name
="Base Lines Thickness", description
="Base thickness of net lines, each actual value is a multiple of this length",
1912 default
=1e-4, min=0, soft_max
=5e-3, precision
=5, step
=1e-2, subtype
="UNSIGNED", unit
="LENGTH")
1913 outer_width
: bpy
.props
.FloatProperty(
1914 name
="Outer Lines Thickness", description
="Relative thickness of net outline",
1915 default
=3, min=0, soft_max
=10, precision
=1, step
=10, subtype
='FACTOR')
1916 use_outbg
: bpy
.props
.BoolProperty(
1917 name
="Highlight Outer Lines", description
="Add another line below every line to improve contrast",
1919 outbg_color
: bpy
.props
.FloatVectorProperty(
1920 name
="Outer Highlight", description
="Color of the highlight for outer lines",
1921 default
=(1.0, 1.0, 1.0, 1.0), min=0, max=1, subtype
='COLOR', size
=4)
1922 outbg_width
: bpy
.props
.FloatProperty(
1923 name
="Outer Highlight Thickness", description
="Relative thickness of the highlighting lines",
1924 default
=5, min=0, soft_max
=10, precision
=1, step
=10, subtype
='FACTOR')
1926 convex_color
: bpy
.props
.FloatVectorProperty(
1927 name
="Inner Convex Lines", description
="Color of lines to be folded to a convex angle",
1928 default
=(0.0, 0.0, 0.0, 1.0), min=0, max=1, subtype
='COLOR', size
=4)
1929 convex_style
: bpy
.props
.EnumProperty(
1930 name
="Convex Lines Drawing Style", description
="Drawing style of lines to be folded to a convex angle",
1931 default
='DASH', items
=line_styles
)
1932 convex_width
: bpy
.props
.FloatProperty(
1933 name
="Convex Lines Thickness", description
="Relative thickness of concave lines",
1934 default
=2, min=0, soft_max
=10, precision
=1, step
=10, subtype
='FACTOR')
1935 concave_color
: bpy
.props
.FloatVectorProperty(
1936 name
="Inner Concave Lines", description
="Color of lines to be folded to a concave angle",
1937 default
=(0.0, 0.0, 0.0, 1.0), min=0, max=1, subtype
='COLOR', size
=4)
1938 concave_style
: bpy
.props
.EnumProperty(
1939 name
="Concave Lines Drawing Style", description
="Drawing style of lines to be folded to a concave angle",
1940 default
='DASHDOT', items
=line_styles
)
1941 concave_width
: bpy
.props
.FloatProperty(
1942 name
="Concave Lines Thickness", description
="Relative thickness of concave lines",
1943 default
=2, min=0, soft_max
=10, precision
=1, step
=10, subtype
='FACTOR')
1944 freestyle_color
: bpy
.props
.FloatVectorProperty(
1945 name
="Freestyle Edges", description
="Color of lines marked as Freestyle Edge",
1946 default
=(0.0, 0.0, 0.0, 1.0), min=0, max=1, subtype
='COLOR', size
=4)
1947 freestyle_style
: bpy
.props
.EnumProperty(
1948 name
="Freestyle Edges Drawing Style", description
="Drawing style of Freestyle Edges",
1949 default
='SOLID', items
=line_styles
)
1950 freestyle_width
: bpy
.props
.FloatProperty(
1951 name
="Freestyle Edges Thickness", description
="Relative thickness of Freestyle edges",
1952 default
=2, min=0, soft_max
=10, precision
=1, step
=10, subtype
='FACTOR')
1953 use_inbg
: bpy
.props
.BoolProperty(
1954 name
="Highlight Inner Lines", description
="Add another line below every line to improve contrast",
1956 inbg_color
: bpy
.props
.FloatVectorProperty(
1957 name
="Inner Highlight", description
="Color of the highlight for inner lines",
1958 default
=(1.0, 1.0, 1.0, 1.0), min=0, max=1, subtype
='COLOR', size
=4)
1959 inbg_width
: bpy
.props
.FloatProperty(
1960 name
="Inner Highlight Thickness", description
="Relative thickness of the highlighting lines",
1961 default
=2, min=0, soft_max
=10, precision
=1, step
=10, subtype
='FACTOR')
1963 sticker_color
: bpy
.props
.FloatVectorProperty(
1964 name
="Tabs Fill", description
="Fill color of sticking tabs",
1965 default
=(0.9, 0.9, 0.9, 1.0), min=0, max=1, subtype
='COLOR', size
=4)
1966 text_color
: bpy
.props
.FloatVectorProperty(
1967 name
="Text Color", description
="Color of all text used in the document",
1968 default
=(0.0, 0.0, 0.0, 1.0), min=0, max=1, subtype
='COLOR', size
=4)
1969 bpy
.utils
.register_class(PaperModelStyle
)
1972 class ExportPaperModel(bpy
.types
.Operator
):
1973 """Blender Operator: save the selected object's net and optionally bake its texture"""
1975 bl_idname
= "export_mesh.paper_model"
1976 bl_label
= "Export Paper Model"
1977 bl_description
= "Export the selected object's net and optionally bake its texture"
1978 bl_options
= {'PRESET'}
1980 filepath
: bpy
.props
.StringProperty(
1981 name
="File Path", description
="Target file to save the SVG", options
={'SKIP_SAVE'})
1982 filename
: bpy
.props
.StringProperty(
1983 name
="File Name", description
="Name of the file", options
={'SKIP_SAVE'})
1984 directory
: bpy
.props
.StringProperty(
1985 name
="Directory", description
="Directory of the file", options
={'SKIP_SAVE'})
1986 page_size_preset
: bpy
.props
.EnumProperty(
1987 name
="Page Size", description
="Size of the exported document",
1988 default
='A4', update
=page_size_preset_changed
, items
=global_paper_sizes
)
1989 output_size_x
: bpy
.props
.FloatProperty(
1990 name
="Page Width", description
="Width of the exported document",
1991 default
=0.210, soft_min
=0.105, soft_max
=0.841, subtype
="UNSIGNED", unit
="LENGTH")
1992 output_size_y
: bpy
.props
.FloatProperty(
1993 name
="Page Height", description
="Height of the exported document",
1994 default
=0.297, soft_min
=0.148, soft_max
=1.189, subtype
="UNSIGNED", unit
="LENGTH")
1995 output_margin
: bpy
.props
.FloatProperty(
1996 name
="Page Margin", description
="Distance from page borders to the printable area",
1997 default
=0.005, min=0, soft_max
=0.1, step
=0.1, subtype
="UNSIGNED", unit
="LENGTH")
1998 output_type
: bpy
.props
.EnumProperty(
1999 name
="Textures", description
="Source of a texture for the model",
2000 default
='NONE', items
=[
2001 ('NONE', "No Texture", "Export the net only"),
2002 ('TEXTURE', "From Materials", "Render the diffuse color and all painted textures"),
2003 ('AMBIENT_OCCLUSION', "Ambient Occlusion", "Render the Ambient Occlusion pass"),
2004 ('RENDER', "Full Render", "Render the material in actual scene illumination"),
2005 ('SELECTED_TO_ACTIVE', "Selected to Active", "Render all selected surrounding objects as a texture")
2007 do_create_stickers
: bpy
.props
.BoolProperty(
2008 name
="Create Tabs", description
="Create gluing tabs around the net (useful for paper)",
2010 do_create_numbers
: bpy
.props
.BoolProperty(
2011 name
="Create Numbers", description
="Enumerate edges to make it clear which edges should be sticked together",
2013 sticker_width
: bpy
.props
.FloatProperty(
2014 name
="Tabs and Text Size", description
="Width of gluing tabs and their numbers",
2015 default
=0.005, soft_min
=0, soft_max
=0.05, step
=0.1, subtype
="UNSIGNED", unit
="LENGTH")
2016 angle_epsilon
: bpy
.props
.FloatProperty(
2017 name
="Hidden Edge Angle", description
="Folds with angle below this limit will not be drawn",
2018 default
=pi
/360, min=0, soft_max
=pi
/4, step
=0.01, subtype
="ANGLE", unit
="ROTATION")
2019 output_dpi
: bpy
.props
.FloatProperty(
2020 name
="Resolution (DPI)", description
="Resolution of images in pixels per inch",
2021 default
=90, min=1, soft_min
=30, soft_max
=600, subtype
="UNSIGNED")
2022 bake_samples
: bpy
.props
.IntProperty(
2023 name
="Samples", description
="Number of samples to render for each pixel",
2024 default
=64, min=1, subtype
="UNSIGNED")
2025 file_format
: bpy
.props
.EnumProperty(
2026 name
="Document Format", description
="File format of the exported net",
2027 default
='PDF', items
=[
2028 ('PDF', "PDF", "Adobe Portable Document Format 1.4"),
2029 ('SVG', "SVG", "W3C Scalable Vector Graphics"),
2031 image_packing
: bpy
.props
.EnumProperty(
2032 name
="Image Packing Method", description
="Method of attaching baked image(s) to the SVG",
2033 default
='ISLAND_EMBED', items
=[
2034 ('PAGE_LINK', "Single Linked", "Bake one image per page of output and save it separately"),
2035 ('ISLAND_LINK', "Linked", "Bake images separately for each island and save them in a directory"),
2036 ('ISLAND_EMBED', "Embedded", "Bake images separately for each island and embed them into the SVG")
2038 scale
: bpy
.props
.FloatProperty(
2039 name
="Scale", description
="Divisor of all dimensions when exporting",
2040 default
=1, soft_min
=1.0, soft_max
=100.0, subtype
='FACTOR', precision
=1)
2041 do_create_uvmap
: bpy
.props
.BoolProperty(
2042 name
="Create UVMap",
2043 description
="Create a new UV Map showing the islands and page layout",
2044 default
=False, options
={'SKIP_SAVE'})
2045 ui_expanded_document
: bpy
.props
.BoolProperty(
2046 name
="Show Document Settings Expanded",
2047 description
="Shows the box 'Document Settings' expanded in user interface",
2048 default
=True, options
={'SKIP_SAVE'})
2049 ui_expanded_style
: bpy
.props
.BoolProperty(
2050 name
="Show Style Settings Expanded",
2051 description
="Shows the box 'Colors and Style' expanded in user interface",
2052 default
=False, options
={'SKIP_SAVE'})
2053 style
: bpy
.props
.PointerProperty(type=PaperModelStyle
)
2058 def poll(cls
, context
):
2059 return context
.active_object
and context
.active_object
.type == 'MESH'
2061 def prepare(self
, context
):
2063 self
.recall_mode
= context
.object.mode
2064 bpy
.ops
.object.mode_set(mode
='EDIT')
2066 self
.object = context
.active_object
2067 self
.unfolder
= Unfolder(self
.object)
2068 cage_size
= M
.Vector((sce
.paper_model
.output_size_x
, sce
.paper_model
.output_size_y
))
2069 unfolder_scale
= sce
.unit_settings
.scale_length
/self
.scale
2070 self
.unfolder
.prepare(cage_size
, scale
=unfolder_scale
, limit_by_page
=sce
.paper_model
.limit_by_page
)
2071 if sce
.paper_model
.use_auto_scale
:
2072 self
.scale
= ceil(self
.get_scale_ratio(sce
))
2077 bpy
.ops
.object.mode_set(mode
=self
.recall_mode
)
2079 def invoke(self
, context
, event
):
2080 self
.scale
= context
.scene
.paper_model
.scale
2082 self
.prepare(context
)
2083 except UnfoldError
as error
:
2084 self
.report(type={'ERROR_INVALID_INPUT'}, message
=error
.args
[0])
2087 return {'CANCELLED'}
2088 wm
= context
.window_manager
2089 wm
.fileselect_add(self
)
2090 return {'RUNNING_MODAL'}
2092 def execute(self
, context
):
2093 if not self
.unfolder
:
2094 self
.prepare(context
)
2095 self
.unfolder
.do_create_uvmap
= self
.do_create_uvmap
2097 if self
.object.data
.paper_island_list
:
2098 self
.unfolder
.copy_island_names(self
.object.data
.paper_island_list
)
2099 self
.unfolder
.save(self
.properties
)
2100 self
.report({'INFO'}, "Saved a {}-page document".format(len(self
.unfolder
.mesh
.pages
)))
2102 except UnfoldError
as error
:
2103 self
.report(type={'ERROR_INVALID_INPUT'}, message
=error
.args
[0])
2104 return {'CANCELLED'}
2108 def get_scale_ratio(self
, sce
):
2109 margin
= self
.output_margin
+ self
.sticker_width
2110 if min(self
.output_size_x
, self
.output_size_y
) <= 2 * margin
:
2112 output_inner_size
= M
.Vector((self
.output_size_x
- 2*margin
, self
.output_size_y
- 2*margin
))
2113 ratio
= self
.unfolder
.mesh
.largest_island_ratio(output_inner_size
)
2114 return ratio
* sce
.unit_settings
.scale_length
/ self
.scale
2116 def draw(self
, context
):
2117 layout
= self
.layout
2118 layout
.prop(self
.properties
, "do_create_uvmap")
2119 layout
.prop(self
.properties
, "scale", text
="Scale: 1/")
2120 scale_ratio
= self
.get_scale_ratio(context
.scene
)
2123 text
="An island is roughly {:.1f}x bigger than page".format(scale_ratio
),
2125 elif scale_ratio
> 0:
2126 layout
.label(text
="Largest island is roughly 1/{:.1f} of page".format(1 / scale_ratio
))
2128 if context
.scene
.unit_settings
.scale_length
!= 1:
2130 text
="Unit scale {:.1f} makes page size etc. not display correctly".format(
2131 context
.scene
.unit_settings
.scale_length
), icon
="ERROR")
2133 row
= box
.row(align
=True)
2135 self
.properties
, "ui_expanded_document", text
="",
2136 icon
=('TRIA_DOWN' if self
.ui_expanded_document
else 'TRIA_RIGHT'), emboss
=False)
2137 row
.label(text
="Document Settings")
2139 if self
.ui_expanded_document
:
2140 box
.prop(self
.properties
, "file_format", text
="Format")
2141 box
.prop(self
.properties
, "page_size_preset")
2142 col
= box
.column(align
=True)
2143 col
.active
= self
.page_size_preset
== 'USER'
2144 col
.prop(self
.properties
, "output_size_x")
2145 col
.prop(self
.properties
, "output_size_y")
2146 box
.prop(self
.properties
, "output_margin")
2148 col
.prop(self
.properties
, "do_create_stickers")
2149 col
.prop(self
.properties
, "do_create_numbers")
2151 col
.active
= self
.do_create_stickers
or self
.do_create_numbers
2152 col
.prop(self
.properties
, "sticker_width")
2153 box
.prop(self
.properties
, "angle_epsilon")
2155 box
.prop(self
.properties
, "output_type")
2157 col
.active
= (self
.output_type
!= 'NONE')
2158 if len(self
.object.data
.uv_layers
) >= 8:
2159 col
.label(text
="No UV slots left, No Texture is the only option.", icon
='ERROR')
2160 elif context
.scene
.render
.engine
!= 'CYCLES' and self
.output_type
!= 'NONE':
2161 col
.label(text
="Cycles will be used for texture baking.", icon
='ERROR')
2163 row
.active
= self
.output_type
in ('AMBIENT_OCCLUSION', 'RENDER', 'SELECTED_TO_ACTIVE')
2164 row
.prop(self
.properties
, "bake_samples")
2165 col
.prop(self
.properties
, "output_dpi")
2167 row
.active
= self
.file_format
== 'SVG'
2168 row
.prop(self
.properties
, "image_packing", text
="Images")
2171 row
= box
.row(align
=True)
2173 self
.properties
, "ui_expanded_style", text
="",
2174 icon
=('TRIA_DOWN' if self
.ui_expanded_style
else 'TRIA_RIGHT'), emboss
=False)
2175 row
.label(text
="Colors and Style")
2177 if self
.ui_expanded_style
:
2178 box
.prop(self
.style
, "line_width", text
="Default line width")
2180 col
.prop(self
.style
, "outer_color")
2181 col
.prop(self
.style
, "outer_width", text
="Relative width")
2182 col
.prop(self
.style
, "outer_style", text
="Style")
2184 col
.active
= self
.output_type
!= 'NONE'
2185 col
.prop(self
.style
, "use_outbg", text
="Outer Lines Highlight:")
2187 sub
.active
= self
.output_type
!= 'NONE' and self
.style
.use_outbg
2188 sub
.prop(self
.style
, "outbg_color", text
="")
2189 sub
.prop(self
.style
, "outbg_width", text
="Relative width")
2191 col
.prop(self
.style
, "convex_color")
2192 col
.prop(self
.style
, "convex_width", text
="Relative width")
2193 col
.prop(self
.style
, "convex_style", text
="Style")
2195 col
.prop(self
.style
, "concave_color")
2196 col
.prop(self
.style
, "concave_width", text
="Relative width")
2197 col
.prop(self
.style
, "concave_style", text
="Style")
2199 col
.prop(self
.style
, "freestyle_color")
2200 col
.prop(self
.style
, "freestyle_width", text
="Relative width")
2201 col
.prop(self
.style
, "freestyle_style", text
="Style")
2203 col
.active
= self
.output_type
!= 'NONE'
2204 col
.prop(self
.style
, "use_inbg", text
="Inner Lines Highlight:")
2206 sub
.active
= self
.output_type
!= 'NONE' and self
.style
.use_inbg
2207 sub
.prop(self
.style
, "inbg_color", text
="")
2208 sub
.prop(self
.style
, "inbg_width", text
="Relative width")
2210 col
.active
= self
.do_create_stickers
2211 col
.prop(self
.style
, "sticker_color")
2212 box
.prop(self
.style
, "text_color")
2215 def menu_func_export(self
, context
):
2216 self
.layout
.operator("export_mesh.paper_model", text
="Paper Model (.pdf/.svg)")
2219 def menu_func_unfold(self
, context
):
2220 self
.layout
.operator("mesh.unfold", text
="Unfold")
2223 class SelectIsland(bpy
.types
.Operator
):
2224 """Blender Operator: select all faces of the active island"""
2226 bl_idname
= "mesh.select_paper_island"
2227 bl_label
= "Select Island"
2228 bl_description
= "Select an island of the paper model net"
2230 operation
: bpy
.props
.EnumProperty(
2231 name
="Operation", description
="Operation with the current selection",
2232 default
='ADD', items
=[
2233 ('ADD', "Add", "Add to current selection"),
2234 ('REMOVE', "Remove", "Remove from selection"),
2235 ('REPLACE', "Replace", "Select only the ")
2239 def poll(cls
, context
):
2240 return context
.active_object
and context
.active_object
.type == 'MESH' and context
.mode
== 'EDIT_MESH'
2242 def execute(self
, context
):
2243 ob
= context
.active_object
2245 bm
= bmesh
.from_edit_mesh(me
)
2246 island
= me
.paper_island_list
[me
.paper_island_index
]
2247 faces
= {face
.id for face
in island
.faces
}
2250 if self
.operation
== 'REPLACE':
2251 for face
in bm
.faces
:
2252 selected
= face
.index
in faces
2253 face
.select
= selected
2255 edges
.update(face
.edges
)
2256 verts
.update(face
.verts
)
2257 for edge
in bm
.edges
:
2258 edge
.select
= edge
in edges
2259 for vert
in bm
.verts
:
2260 vert
.select
= vert
in verts
2262 selected
= (self
.operation
== 'ADD')
2264 face
= bm
.faces
[index
]
2265 face
.select
= selected
2266 edges
.update(face
.edges
)
2267 verts
.update(face
.verts
)
2269 edge
.select
= any(face
.select
for face
in edge
.link_faces
)
2271 vert
.select
= any(edge
.select
for edge
in vert
.link_edges
)
2272 bmesh
.update_edit_mesh(me
, loop_triangles
=False, destructive
=False)
2276 class VIEW3D_PT_paper_model_tools(bpy
.types
.Panel
):
2277 bl_space_type
= 'VIEW_3D'
2278 bl_region_type
= 'UI'
2279 bl_category
= 'Paper'
2282 def draw(self
, context
):
2283 layout
= self
.layout
2285 obj
= context
.active_object
2286 mesh
= obj
.data
if obj
and obj
.type == 'MESH' else None
2288 layout
.operator("mesh.unfold")
2290 if context
.mode
== 'EDIT_MESH':
2291 row
= layout
.row(align
=True)
2292 row
.operator("mesh.mark_seam", text
="Mark Seam").clear
= False
2293 row
.operator("mesh.mark_seam", text
="Clear Seam").clear
= True
2295 layout
.operator("mesh.clear_all_seams")
2298 class VIEW3D_PT_paper_model_settings(bpy
.types
.Panel
):
2299 bl_space_type
= 'VIEW_3D'
2300 bl_region_type
= 'UI'
2301 bl_category
= 'Paper'
2304 def draw(self
, context
):
2305 layout
= self
.layout
2307 obj
= context
.active_object
2308 mesh
= obj
.data
if obj
and obj
.type == 'MESH' else None
2310 layout
.operator("export_mesh.paper_model")
2311 props
= sce
.paper_model
2312 layout
.prop(props
, "use_auto_scale")
2314 sub
.active
= not props
.use_auto_scale
2315 sub
.prop(props
, "scale", text
="Model Scale: 1/")
2317 layout
.prop(props
, "limit_by_page")
2318 col
= layout
.column()
2319 col
.active
= props
.limit_by_page
2320 col
.prop(props
, "page_size_preset")
2321 sub
= col
.column(align
=True)
2322 sub
.active
= props
.page_size_preset
== 'USER'
2323 sub
.prop(props
, "output_size_x")
2324 sub
.prop(props
, "output_size_y")
2327 class DATA_PT_paper_model_islands(bpy
.types
.Panel
):
2328 bl_space_type
= 'PROPERTIES'
2329 bl_region_type
= 'WINDOW'
2331 bl_label
= "Paper Model Islands"
2332 COMPAT_ENGINES
= {'BLENDER_RENDER', 'BLENDER_EEVEE', 'BLENDER_WORKBENCH'}
2334 def draw(self
, context
):
2335 layout
= self
.layout
2337 obj
= context
.active_object
2338 mesh
= obj
.data
if obj
and obj
.type == 'MESH' else None
2340 layout
.operator("mesh.unfold", icon
='FILE_REFRESH')
2341 if mesh
and mesh
.paper_island_list
:
2343 text
="1 island:" if len(mesh
.paper_island_list
) == 1 else
2344 "{} islands:".format(len(mesh
.paper_island_list
)))
2345 layout
.template_list(
2346 'UI_UL_list', 'paper_model_island_list', mesh
,
2347 'paper_island_list', mesh
, 'paper_island_index', rows
=1, maxrows
=5)
2348 sub
= layout
.split(align
=True)
2349 sub
.operator("mesh.select_paper_island", text
="Select").operation
= 'ADD'
2350 sub
.operator("mesh.select_paper_island", text
="Deselect").operation
= 'REMOVE'
2351 sub
.prop(sce
.paper_model
, "sync_island", icon
='UV_SYNC_SELECT', toggle
=True)
2352 if mesh
.paper_island_index
>= 0:
2353 list_item
= mesh
.paper_island_list
[mesh
.paper_island_index
]
2354 sub
= layout
.column(align
=True)
2355 sub
.prop(list_item
, "auto_label")
2356 sub
.prop(list_item
, "label")
2357 sub
.prop(list_item
, "auto_abbrev")
2359 row
.active
= not list_item
.auto_abbrev
2360 row
.prop(list_item
, "abbreviation")
2362 layout
.box().label(text
="Not unfolded")
2365 def label_changed(self
, context
):
2366 """The label of an island was changed"""
2367 # accessing properties via [..] to avoid a recursive call after the update
2368 self
["auto_label"] = not self
.label
or self
.label
.isspace()
2369 island_item_changed(self
, context
)
2372 def island_item_changed(self
, context
):
2373 """The labelling of an island was changed"""
2374 def increment(abbrev
, collisions
):
2375 letters
= "ABCDEFGHIJKLMNPQRSTUVWXYZ123456789"
2376 while abbrev
in collisions
:
2377 abbrev
= abbrev
.rstrip(letters
[-1])
2378 abbrev
= abbrev
[:2] + letters
[letters
.find(abbrev
[-1]) + 1 if len(abbrev
) == 3 else 0]
2381 # accessing properties via [..] to avoid a recursive call after the update
2382 island_list
= context
.active_object
.data
.paper_island_list
2384 self
["label"] = "" # avoid self-conflict
2386 while any(item
.label
== "Island {}".format(number
) for item
in island_list
):
2388 self
["label"] = "Island {}".format(number
)
2389 if self
.auto_abbrev
:
2390 self
["abbreviation"] = "" # avoid self-conflict
2391 abbrev
= "".join(first_letters(self
.label
))[:3].upper()
2392 self
["abbreviation"] = increment(abbrev
, {item
.abbreviation
for item
in island_list
})
2393 elif len(self
.abbreviation
) > 3:
2394 self
["abbreviation"] = self
.abbreviation
[:3]
2395 self
.name
= "[{}] {} ({} {})".format(
2396 self
.abbreviation
, self
.label
, len(self
.faces
), "faces" if len(self
.faces
) > 1 else "face")
2399 def island_index_changed(self
, context
):
2400 """The active island was changed"""
2401 if context
.scene
.paper_model
.sync_island
and SelectIsland
.poll(context
):
2402 bpy
.ops
.mesh
.select_paper_island(operation
='REPLACE')
2405 class FaceList(bpy
.types
.PropertyGroup
):
2406 id: bpy
.props
.IntProperty(name
="Face ID")
2409 class IslandList(bpy
.types
.PropertyGroup
):
2410 faces
: bpy
.props
.CollectionProperty(
2411 name
="Faces", description
="Faces belonging to this island", type=FaceList
)
2412 label
: bpy
.props
.StringProperty(
2413 name
="Label", description
="Label on this island",
2414 default
="", update
=label_changed
)
2415 abbreviation
: bpy
.props
.StringProperty(
2416 name
="Abbreviation", description
="Three-letter label to use when there is not enough space",
2417 default
="", update
=island_item_changed
)
2418 auto_label
: bpy
.props
.BoolProperty(
2419 name
="Auto Label", description
="Generate the label automatically",
2420 default
=True, update
=island_item_changed
)
2421 auto_abbrev
: bpy
.props
.BoolProperty(
2422 name
="Auto Abbreviation", description
="Generate the abbreviation automatically",
2423 default
=True, update
=island_item_changed
)
2426 class PaperModelSettings(bpy
.types
.PropertyGroup
):
2427 sync_island
: bpy
.props
.BoolProperty(
2428 name
="Sync", description
="Keep faces of the active island selected",
2429 default
=False, update
=island_index_changed
)
2430 limit_by_page
: bpy
.props
.BoolProperty(
2431 name
="Limit Island Size", description
="Do not create islands larger than given dimensions",
2432 default
=False, update
=page_size_preset_changed
)
2433 page_size_preset
: bpy
.props
.EnumProperty(
2434 name
="Page Size", description
="Maximal size of an island",
2435 default
='A4', update
=page_size_preset_changed
, items
=global_paper_sizes
)
2436 output_size_x
: bpy
.props
.FloatProperty(
2437 name
="Width", description
="Maximal width of an island",
2438 default
=0.2, soft_min
=0.105, soft_max
=0.841, subtype
="UNSIGNED", unit
="LENGTH")
2439 output_size_y
: bpy
.props
.FloatProperty(
2440 name
="Height", description
="Maximal height of an island",
2441 default
=0.29, soft_min
=0.148, soft_max
=1.189, subtype
="UNSIGNED", unit
="LENGTH")
2442 use_auto_scale
: bpy
.props
.BoolProperty(
2443 name
="Automatic Scale", description
="Scale the net automatically to fit on paper",
2445 scale
: bpy
.props
.FloatProperty(
2446 name
="Scale", description
="Divisor of all dimensions when exporting",
2447 default
=1, soft_min
=1.0, soft_max
=100.0, subtype
='FACTOR', precision
=1,
2448 update
=lambda settings
, _
: settings
.__setattr
__('use_auto_scale', False))
2451 def factory_update_addon_category(cls
, prop
):
2452 def func(self
, context
):
2453 if hasattr(bpy
.types
, cls
.__name
__):
2454 bpy
.utils
.unregister_class(cls
)
2455 cls
.bl_category
= self
[prop
]
2456 bpy
.utils
.register_class(cls
)
2460 class PaperAddonPreferences(bpy
.types
.AddonPreferences
):
2461 bl_idname
= __name__
2462 unfold_category
: bpy
.props
.StringProperty(
2463 name
="Unfold Panel Category", description
="Category in 3D View Toolbox where the Unfold panel is displayed",
2464 default
="Paper", update
=factory_update_addon_category(VIEW3D_PT_paper_model_tools
, 'unfold_category'))
2465 export_category
: bpy
.props
.StringProperty(
2466 name
="Export Panel Category", description
="Category in 3D View Toolbox where the Export panel is displayed",
2467 default
="Paper", update
=factory_update_addon_category(VIEW3D_PT_paper_model_settings
, 'export_category'))
2469 def draw(self
, context
):
2470 sub
= self
.layout
.column(align
=True)
2471 sub
.use_property_split
= True
2472 sub
.label(text
="3D View Panel Category:")
2473 sub
.prop(self
, "unfold_category", text
="Unfold Panel:")
2474 sub
.prop(self
, "export_category", text
="Export Panel:")
2485 DATA_PT_paper_model_islands
,
2486 VIEW3D_PT_paper_model_tools
,
2487 VIEW3D_PT_paper_model_settings
,
2488 PaperAddonPreferences
,
2493 for cls
in module_classes
:
2494 bpy
.utils
.register_class(cls
)
2495 bpy
.types
.Scene
.paper_model
= bpy
.props
.PointerProperty(
2496 name
="Paper Model", description
="Settings of the Export Paper Model script",
2497 type=PaperModelSettings
, options
={'SKIP_SAVE'})
2498 bpy
.types
.Mesh
.paper_island_list
= bpy
.props
.CollectionProperty(
2499 name
="Island List", type=IslandList
)
2500 bpy
.types
.Mesh
.paper_island_index
= bpy
.props
.IntProperty(
2501 name
="Island List Index",
2502 default
=-1, min=-1, max=100, options
={'SKIP_SAVE'}, update
=island_index_changed
)
2503 bpy
.types
.TOPBAR_MT_file_export
.append(menu_func_export
)
2504 bpy
.types
.VIEW3D_MT_edit_mesh
.prepend(menu_func_unfold
)
2505 # Force an update on the panel category properties
2506 prefs
= bpy
.context
.preferences
.addons
[__name__
].preferences
2507 prefs
.unfold_category
= prefs
.unfold_category
2508 prefs
.export_category
= prefs
.export_category
2512 bpy
.types
.TOPBAR_MT_file_export
.remove(menu_func_export
)
2513 bpy
.types
.VIEW3D_MT_edit_mesh
.remove(menu_func_unfold
)
2514 for cls
in reversed(module_classes
):
2515 bpy
.utils
.unregister_class(cls
)
2518 if __name__
== "__main__":