Newer
Older
# -*- coding: utf-8 -*-
# This script is Free software. Please share and reuse.
# ♡2010-2020 Adam Dominec <adominec@gmail.com>
## Code structure
# This file consists of several components, in this order:
# * Unfolding and baking
# * Export (SVG or PDF)
# * User interface
# During the unfold process, the mesh is mirrored into a 2D structure: UVFace, UVEdge, UVVertex.
bl_info = {
"name": "Export Paper Model",
"author": "Addam Dominec",
"version": (1, 2),
"blender": (2, 83, 0),
"location": "File > Export > Paper Model",
"warning": "",
"description": "Export printable net of the active mesh",
"doc_url": "{BLENDER_MANUAL_URL}/addons/import_export/paper_model.html",
"category": "Import-Export",
}
# Task: split into four files (SVG and PDF separately)
# * does any portion of baking belong into the export module?
# * sketch out the code for GCODE and two-sided export
# QuickSweepline is very much broken -- it throws GeometryError for all nets > ~15 faces
# rotate islands to minimize area -- and change that only if necessary to fill the page size
# check conflicts in island naming and either:
# * append a number to the conflicting names or
# * enumerate faces uniquely within all islands of the same name (requires a check that both label and abbr. equals)
import bpy
import bl_operators
import mathutils as M
from re import compile as re_compile
from itertools import chain, repeat, product, combinations
from math import pi, ceil, asin, atan2
import os.path as os_path
default_priority_effect = {
'CONVEX': 0.5,
'CONCAVE': 1,
'LENGTH': -0.05
}
global_paper_sizes = [
('USER', "User defined", "User defined paper size"),
('A4', "A4", "International standard paper size"),
('A3', "A3", "International standard paper size"),
('US_LETTER', "Letter", "North American paper size"),
('US_LEGAL', "Legal", "North American paper size")
]
def first_letters(text):
"""Iterator over the first letter of each word"""
for match in first_letters.pattern.finditer(text):
yield text[match.start()]
first_letters.pattern = re_compile(r"((?<!\w)\w)|\d")
def is_upsidedown_wrong(name):
"""Tell if the string would get a different meaning if written upside down"""
chars = set(name)
mistakable = set("69NZMWpbqd")
rotatable = set("80oOxXIl").union(mistakable)
return chars.issubset(rotatable) and not chars.isdisjoint(mistakable)
def pairs(sequence):
"""Generate consecutive pairs throughout the given sequence; at last, it gives elements last, first."""
i = iter(sequence)
previous = first = next(i)
for this in i:
yield previous, this
previous = this
yield this, first
def fitting_matrix(v1, v2):
"""Get a matrix that rotates v1 to the same direction as v2"""
return (1 / v1.length_squared) * M.Matrix((
(v1.x*v2.x + v1.y*v2.y, v1.y*v2.x - v1.x*v2.y),
(v1.x*v2.y - v1.y*v2.x, v1.x*v2.x + v1.y*v2.y)))
def z_up_matrix(n):
"""Get a rotation matrix that aligns given vector upwards."""
b = n.xy.length
if b > 0:
return M.Matrix((
(n.x*n.z/(b*s), n.y*n.z/(b*s), -b/s),
(-n.y/b, n.x/b, 0),
(0, 0, 0)
))
else:
# no need for rotation
return M.Matrix((
(1, 0, 0),
(0, (-1 if n.z < 0 else 1), 0),
(0, 0, 0)
))
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
def cage_fit(points, aspect):
"""Find rotation for a minimum bounding box with a given aspect ratio
returns a tuple: rotation angle, box height"""
def guesses(polygon):
"""Yield all tentative extrema of the bounding box height wrt. polygon rotation"""
for a, b in pairs(polygon):
if a == b:
continue
direction = (b - a).normalized()
sinx, cosx = -direction.y, direction.x
rot = M.Matrix(((cosx, -sinx), (sinx, cosx)))
rot_polygon = [rot @ p for p in polygon]
left, right = [fn(rot_polygon, key=lambda p: p.to_tuple()) for fn in (min, max)]
bottom, top = [fn(rot_polygon, key=lambda p: p.yx.to_tuple()) for fn in (min, max)]
horz, vert = right - left, top - bottom
# solve (rot * a).y == (rot * b).y
yield max(aspect * horz.x, vert.y), sinx, cosx
# solve (rot * a).x == (rot * b).x
yield max(horz.x, aspect * vert.y), -cosx, sinx
# solve aspect * (rot * (right - left)).x == (rot * (top - bottom)).y
# using substitution t = tan(rot / 2)
q = aspect * horz.x - vert.y
r = vert.x + aspect * horz.y
t = ((r**2 + q**2)**0.5 - r) / q if q != 0 else 0
t = -1 / t if abs(t) > 1 else t # pick the positive solution
siny, cosy = 2 * t / (1 + t**2), (1 - t**2) / (1 + t**2)
rot = M.Matrix(((cosy, -siny), (siny, cosy)))
for p in rot_polygon:
p[:] = rot @ p # note: this also modifies left, right, bottom, top
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):
yield max(aspect * (right - left).x, (top - bottom).y), sinx*cosy + cosx*siny, cosx*cosy - sinx*siny
polygon = [points[i] for i in M.geometry.convex_hull_2d(points)]
height, sinx, cosx = min(guesses(polygon))
return atan2(sinx, cosx), height
def create_blank_image(image_name, dimensions, alpha=1):
"""Create a new image and assign white color to all its pixels"""
image_name = image_name[:64]
width, height = int(dimensions.x), int(dimensions.y)
image = bpy.data.images.new(image_name, width, height, alpha=True)
if image.users > 0:
raise UnfoldError(
"There is something wrong with the material of the model. "
"Please report this on the BlenderArtists forum. Export failed.")
image.pixels = [1, 1, 1, alpha] * (width * height)
image.file_format = 'PNG'
return image
def store_rna_properties(*datablocks):
return [{prop.identifier: getattr(data, prop.identifier) for prop in data.rna_type.properties if not prop.is_readonly} for data in datablocks]
def apply_rna_properties(memory, *datablocks):
for recall, data in zip(memory, datablocks):
for key, value in recall.items():
setattr(data, key, value)
class UnfoldError(ValueError):
def mesh_select(self):
if len(self.args) > 1:
elems, bm = self.args[1:3]
bpy.context.tool_settings.mesh_select_mode = [bool(elems[key]) for key in ("verts", "edges", "faces")]
for elem in chain(bm.verts, bm.edges, bm.faces):
elem.select = False
for elem in chain(*elems.values()):
elem.select_set(True)
bmesh.update_edit_mesh(bpy.context.object.data, loop_triangles=False, destructive=False)
class Unfolder:
def __init__(self, ob):
self.do_create_uvmap = False
bm = bmesh.from_edit_mesh(ob.data)
self.mesh = Mesh(bm, ob.matrix_world)
self.mesh.check_correct()
def __del__(self):
if not self.do_create_uvmap:
self.mesh.delete_uvmap()
def prepare(self, cage_size=None, priority_effect=default_priority_effect, scale=1, limit_by_page=False):
"""Create the islands of the net"""
self.mesh.generate_cuts(cage_size / scale if limit_by_page and cage_size else None, priority_effect)
self.mesh.finalize_islands(cage_size or M.Vector((1, 1)))
self.mesh.enumerate_islands()
self.mesh.save_uv()
def copy_island_names(self, island_list):
"""Copy island label and abbreviation from the best matching island in the list"""
orig_islands = [{face.id for face in item.faces} for item in island_list]
matching = list()
for i, island in enumerate(self.mesh.islands):
islfaces = {face.index for face in island.faces}
matching.extend((len(islfaces.intersection(item)), i, j) for j, item in enumerate(orig_islands))
matching.sort(reverse=True)
available_new = [True for island in self.mesh.islands]
available_orig = [True for item in island_list]
for face_count, i, j in matching:
if available_new[i] and available_orig[j]:
available_new[i] = available_orig[j] = False
self.mesh.islands[i].label = island_list[j].label
self.mesh.islands[i].abbreviation = island_list[j].abbreviation
def save(self, properties):
"""Export the document"""
# Note about scale: input is directly in blender length
# Mesh.scale_islands multiplies everything by a user-defined ratio
# exporters (SVG or PDF) multiply everything by 1000 (output in millimeters)
Exporter = Svg if properties.file_format == 'SVG' else Pdf
filepath = properties.filepath
extension = properties.file_format.lower()
filepath = bpy.path.ensure_ext(filepath, "." + extension)
# page size in meters
page_size = M.Vector((properties.output_size_x, properties.output_size_y))
# printable area size in meters
printable_size = page_size - 2 * properties.output_margin * M.Vector((1, 1))
unit_scale = bpy.context.scene.unit_settings.scale_length
ppm = properties.output_dpi * 100 / 2.54 # pixels per meter
# after this call, all dimensions will be in meters
self.mesh.scale_islands(unit_scale/properties.scale)
if properties.do_create_stickers:
self.mesh.generate_stickers(properties.sticker_width, properties.do_create_numbers)
elif properties.do_create_numbers:
self.mesh.generate_numbers_alone(properties.sticker_width)
text_height = properties.sticker_width if (properties.do_create_numbers and len(self.mesh.islands) > 1) else 0
# title height must be somewhat larger that text size, glyphs go below the baseline
self.mesh.finalize_islands(printable_size, title_height=text_height * 1.2)
self.mesh.fit_islands(printable_size)
if properties.output_type != 'NONE':
# bake an image and save it as a PNG to disk or into memory
image_packing = properties.image_packing if properties.file_format == 'SVG' else 'ISLAND_EMBED'
use_separate_images = image_packing in ('ISLAND_LINK', 'ISLAND_EMBED')
self.mesh.save_uv(cage_size=printable_size, separate_image=use_separate_images)
sce = bpy.context.scene
rd = sce.render
bk = rd.bake
recall = store_rna_properties(rd, bk, sce.cycles)
rd.engine = 'CYCLES'
for p in ('ambient_occlusion', 'color', 'diffuse', 'direct', 'emit', 'glossy', 'indirect', 'transmission'):
setattr(bk, f"use_pass_{p}", (properties.output_type != 'TEXTURE'))
lookup = {'TEXTURE': 'DIFFUSE', 'AMBIENT_OCCLUSION': 'AO', 'RENDER': 'COMBINED', 'SELECTED_TO_ACTIVE': 'COMBINED'}
sce.cycles.bake_type = lookup[properties.output_type]
bk.use_selected_to_active = (properties.output_type == 'SELECTED_TO_ACTIVE')
bk.margin, bk.cage_extrusion, bk.use_cage, bk.use_clear = 1, 10, False, False
if properties.output_type == 'TEXTURE':
bk.use_pass_direct, bk.use_pass_indirect, bk.use_pass_color = False, False, True
sce.cycles.samples = 1
if sce.cycles.bake_type == 'COMBINED':
bk.use_pass_direct, bk.use_pass_indirect = True, True
bk.use_pass_diffuse, bk.use_pass_glossy, bk.use_pass_transmission, bk.use_pass_ambient_occlusion, bk.use_pass_emit = True, False, False, True, True
if image_packing == 'PAGE_LINK':
self.mesh.save_image(printable_size * ppm, filepath)
elif image_packing == 'ISLAND_LINK':
image_dir = filepath[:filepath.rfind(".")]
self.mesh.save_separate_images(ppm, image_dir)
elif image_packing == 'ISLAND_EMBED':
self.mesh.save_separate_images(ppm, filepath, embed=Exporter.encode_image)
apply_rna_properties(recall, rd, bk, sce.cycles)
exporter = Exporter(properties)
exporter.write(self.mesh, filepath)
class Mesh:
"""Wrapper for Bpy Mesh"""
def __init__(self, bmesh, matrix):
self.data = bmesh
self.matrix = matrix.to_3x3()
self.looptex = bmesh.loops.layers.uv.new("Unfolded")
self.edges = {bmedge: Edge(bmedge) for bmedge in bmesh.edges}
self.islands = list()
self.pages = list()
for edge in self.edges.values():
edge.choose_main_faces()
if edge.main_faces:
edge.calculate_angle()
self.copy_freestyle_marks()
def delete_uvmap(self):
self.data.loops.layers.uv.remove(self.looptex) if self.looptex else None
def copy_freestyle_marks(self):
# NOTE: this is a workaround for NotImplementedError on bmesh.edges.layers.freestyle
mesh = bpy.data.meshes.new("unfolder_temp")
self.data.to_mesh(mesh)
for bmedge, edge in self.edges.items():
edge.freestyle = mesh.edges[bmedge.index].use_freestyle_mark
bpy.data.meshes.remove(mesh)
def mark_cuts(self):
for bmedge, edge in self.edges.items():
if edge.is_main_cut and not bmedge.is_boundary:
bmedge.seam = True
def check_correct(self, epsilon=1e-6):
"""Check for invalid geometry"""
def is_twisted(face):
if len(face.verts) <= 3:
return False
center = face.calc_center_median()
plane_d = center.dot(face.normal)
diameter = max((center - vertex.co).length for vertex in face.verts)
threshold = 0.01 * diameter
return any(abs(v.co.dot(face.normal) - plane_d) > threshold for v in face.verts)
null_edges = {e for e in self.edges.keys() if e.calc_length() < epsilon and e.link_faces}
null_faces = {f for f in self.data.faces if f.calc_area() < epsilon}
twisted_faces = {f for f in self.data.faces if is_twisted(f)}
inverted_scale = self.matrix.determinant() <= 0
if not (null_edges or null_faces or twisted_faces or inverted_scale):
return True
if inverted_scale:
raise UnfoldError(
"The object is flipped inside-out.\n"
"You can use Object -> Apply -> Scale to fix it. Export failed.")
disease = [("Remove Doubles", null_edges or null_faces), ("Triangulate", twisted_faces)]
cure = " and ".join(s for s, k in disease if k)
raise UnfoldError(
"The model contains:\n" +
(" {} zero-length edge(s)\n".format(len(null_edges)) if null_edges else "") +
(" {} zero-area face(s)\n".format(len(null_faces)) if null_faces else "") +
(" {} twisted polygon(s)\n".format(len(twisted_faces)) if twisted_faces else "") +
"The offenders are selected and you can use {} to fix them. Export failed.".format(cure),
{"verts": set(), "edges": null_edges, "faces": null_faces | twisted_faces}, self.data)
def generate_cuts(self, page_size, priority_effect):
"""Cut the mesh so that it can be unfolded to a flat net."""
normal_matrix = self.matrix.inverted().transposed()
islands = {Island(self, face, self.matrix, normal_matrix) for face in self.data.faces}
uvfaces = {face: uvface for island in islands for face, uvface in island.faces.items()}
uvedges = {loop: uvedge for island in islands for loop, uvedge in island.edges.items()}
for loop, uvedge in uvedges.items():
self.edges[loop.edge].uvedges.append(uvedge)
# check for edges that are cut permanently
edges = [edge for edge in self.edges.values() if not edge.force_cut and edge.main_faces]
average_length = sum(edge.vector.length for edge in edges) / len(edges)
for edge in edges:
edge.generate_priority(priority_effect, average_length)
edges.sort(reverse=False, key=lambda edge: edge.priority)
for edge in edges:
if not edge.vector:
edge_a, edge_b = (uvedges[l] for l in edge.main_faces)
old_island = join(edge_a, edge_b, size_limit=page_size)
if old_island:
islands.remove(old_island)
self.islands = sorted(islands, reverse=True, key=lambda island: len(island.faces))
for edge in self.edges.values():
# some edges did not know until now whether their angle is convex or concave
if edge.main_faces and (uvfaces[edge.main_faces[0].face].flipped or uvfaces[edge.main_faces[1].face].flipped):
edge.calculate_angle()
# ensure that the order of faces corresponds to the order of uvedges
if edge.main_faces:
reordered = [None, None]
for uvedge in edge.uvedges:
try:
index = edge.main_faces.index(uvedge.loop)
reordered[index] = uvedge
except ValueError:
reordered.append(uvedge)
edge.uvedges = reordered
for island in self.islands:
# if the normals are ambiguous, flip them so that there are more convex edges than concave ones
if any(uvface.flipped for uvface in island.faces.values()):
island_edges = {self.edges[uvedge.edge] for uvedge in island.edges}
balance = sum((+1 if edge.angle > 0 else -1) for edge in island_edges if not edge.is_cut(uvedge.uvface.face))
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
if balance < 0:
island.is_inside_out = True
# construct a linked list from each island's boundary
# uvedge.neighbor_right is clockwise = forward = via uvedge.vb if not uvface.flipped
neighbor_lookup, conflicts = dict(), dict()
for uvedge in island.boundary:
uvvertex = uvedge.va if uvedge.uvface.flipped else uvedge.vb
if uvvertex not in neighbor_lookup:
neighbor_lookup[uvvertex] = uvedge
else:
if uvvertex not in conflicts:
conflicts[uvvertex] = [neighbor_lookup[uvvertex], uvedge]
else:
conflicts[uvvertex].append(uvedge)
for uvedge in island.boundary:
uvvertex = uvedge.vb if uvedge.uvface.flipped else uvedge.va
if uvvertex not in conflicts:
# using the 'get' method so as to handle single-connected vertices properly
uvedge.neighbor_right = neighbor_lookup.get(uvvertex, uvedge)
uvedge.neighbor_right.neighbor_left = uvedge
else:
conflicts[uvvertex].append(uvedge)
# resolve merged vertices with more boundaries crossing
def direction_to_float(vector):
return (1 - vector.x/vector.length) if vector.y > 0 else (vector.x/vector.length - 1)
for uvvertex, uvedges in conflicts.items():
def is_inwards(uvedge):
return uvedge.uvface.flipped == (uvedge.va is uvvertex)
def uvedge_sortkey(uvedge):
if is_inwards(uvedge):
return direction_to_float(uvedge.va.co - uvedge.vb.co)
else:
return direction_to_float(uvedge.vb.co - uvedge.va.co)
uvedges.sort(key=uvedge_sortkey)
Loading
Loading full blame...