Cleanup: pep8
[blender.git] / release / scripts / freestyle / modules / export_svg.py
1 # ##### BEGIN GPL LICENSE BLOCK #####
2 #
3 #  This program is free software; you can redistribute it and/or
4 #  modify it under the terms of the GNU General Public License
5 #  as published by the Free Software Foundation; either version 2
6 #  of the License, or (at your option) any later version.
7 #
8 #  This program is distributed in the hope that it will be useful,
9 #  but WITHOUT ANY WARRANTY; without even the implied warranty of
10 #  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
11 #  GNU General Public License for more details.
12 #
13 #  You should have received a copy of the GNU General Public License
14 #  along with this program; if not, write to the Free Software Foundation,
15 #  Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
16 #
17 # ##### END GPL LICENSE BLOCK #####
18
19 # <pep8 compliant>
20
21
22 import bpy
23 import xml.etree.cElementTree as et
24
25 from bpy.path import abspath
26 from bpy.app.handlers import persistent
27 from bpy_extras.object_utils import world_to_camera_view
28
29 from freestyle.types import StrokeShader, ChainingIterator, BinaryPredicate1D, Interface0DIterator, AdjacencyIterator
30 from freestyle.utils import getCurrentScene, get_dashed_pattern, get_test_stroke
31 from freestyle.functions import GetShapeF1D, CurveMaterialF0D
32
33 from itertools import dropwhile, repeat
34 from collections import OrderedDict
35
36 __all__ = (
37     "SVGPathShader",
38     "SVGFillShader",
39     "ShapeZ",
40     "indent_xml",
41     "svg_export_header",
42     "svg_export_animation",
43     )
44
45 # register namespaces
46 et.register_namespace("", "http://www.w3.org/2000/svg")
47 et.register_namespace("inkscape", "http://www.inkscape.org/namespaces/inkscape")
48 et.register_namespace("sodipodi", "http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd")
49
50
51 # use utf-8 here to keep ElementTree happy
52 svg_primitive = """<?xml version="1.0" encoding="UTF-8" standalone="no"?>
53 <svg xmlns="http://www.w3.org/2000/svg" version="1.1" width="{:d}" height="{:d}">
54 </svg>"""
55
56
57 # xml namespaces
58 namespaces = {
59     "inkscape": "http://www.inkscape.org/namespaces/inkscape",
60     "svg": "http://www.w3.org/2000/svg",
61     }
62
63
64 # - SVG export - #
65 class SVGPathShader(StrokeShader):
66     """Stroke Shader for writing stroke data to a .svg file."""
67     def __init__(self, name, style, filepath, res_y, split_at_invisible, frame_current):
68         StrokeShader.__init__(self)
69         # attribute 'name' of 'StrokeShader' objects is not writable, so _name is used
70         self._name = name
71         self.filepath = filepath
72         self.h = res_y
73         self.frame_current = frame_current
74         self.elements = []
75         self.split_at_invisible = split_at_invisible
76         # put style attributes into a single svg path definition
77         self.path = '\n<path ' + "".join('{}="{}" '.format(k, v) for k, v in style.items()) + 'd=" M '
78
79     @classmethod
80     def from_lineset(cls, lineset, filepath, res_y, split_at_invisible, frame_current, *, name=""):
81         """Builds a SVGPathShader using data from the given lineset"""
82         name = name or lineset.name
83         linestyle = lineset.linestyle
84         # extract style attributes from the linestyle
85         style = {
86             'fill': 'none',
87             'stroke-width': linestyle.thickness,
88             'stroke-linecap': linestyle.caps.lower(),
89             'stroke-opacity': linestyle.alpha,
90             'stroke': 'rgb({}, {}, {})'.format(*(int(c * 255) for c in linestyle.color))
91             }
92         # get dashed line pattern (if specified)
93         if linestyle.use_dashed_line:
94             style['stroke-dasharray'] = ",".join(str(elem) for elem in get_dashed_pattern(linestyle))
95         # return instance
96         return cls(name, style, filepath, res_y, split_at_invisible, frame_current)
97
98     @staticmethod
99     def pathgen(stroke, path, height, split_at_invisible, f=lambda v: not v.attribute.visible):
100         """Generator that creates SVG paths (as strings) from the current stroke """
101         it = iter(stroke)
102         # start first path
103         yield path
104         for v in it:
105             x, y = v.point
106             yield '{:.3f}, {:.3f} '.format(x, height - y)
107             if split_at_invisible and v.attribute.visible is False:
108                 # end current and start new path;
109                 yield '" />' + path
110                 # fast-forward till the next visible vertex
111                 it = dropwhile(f, it)
112                 # yield next visible vertex
113                 svert = next(it, None)
114                 if svert is None:
115                     break
116                 x, y = svert.point
117                 yield '{:.3f}, {:.3f} '.format(x, height - y)
118         # close current path
119         yield '" />'
120
121     def shade(self, stroke):
122         stroke_to_paths = "".join(self.pathgen(stroke, self.path, self.h, self.split_at_invisible)).split("\n")
123         # convert to actual XML, check to prevent empty paths
124         self.elements.extend(et.XML(elem) for elem in stroke_to_paths if len(elem.strip()) > len(self.path))
125
126     def write(self):
127         """Write SVG data tree to file """
128         tree = et.parse(self.filepath)
129         root = tree.getroot()
130         name = self._name
131
132         # make <g> for lineset as a whole (don't overwrite)
133         lineset_group = tree.find(".//svg:g[@id='{}']".format(name), namespaces=namespaces)
134         if lineset_group is None:
135             lineset_group = et.XML('<g/>')
136             lineset_group.attrib = {
137                 'id': name,
138                 'xmlns:inkscape': namespaces["inkscape"],
139                 'inkscape:groupmode': 'lineset',
140                 'inkscape:label': name,
141                 }
142             root.insert(0, lineset_group)
143
144         # make <g> for the current frame
145         id = "{}_frame_{:06n}".format(name, self.frame_current)
146         frame_group = et.XML("<g/>")
147         frame_group.attrib = {'id': id, 'inkscape:groupmode': 'frame', 'inkscape:label': id}
148         frame_group.extend(self.elements)
149         lineset_group.append(frame_group)
150
151         # write SVG to file
152         indent_xml(root)
153         tree.write(self.filepath, encoding='UTF-8', xml_declaration=True)
154
155
156 # - Fill export - #
157 class ShapeZ(BinaryPredicate1D):
158     """Sort ViewShapes by their z-index"""
159     def __init__(self, scene):
160         BinaryPredicate1D.__init__(self)
161         self.z_map = dict()
162         self.scene = scene
163
164     def __call__(self, i1, i2):
165         return self.get_z_curve(i1) < self.get_z_curve(i2)
166
167     def get_z_curve(self, curve, func=GetShapeF1D()):
168         shape = func(curve)[0]
169         # get the shapes z-index
170         z = self.z_map.get(shape.id.first)
171         if z is None:
172             o = bpy.data.objects[shape.name]
173             z = world_to_camera_view(self.scene, self.scene.camera, o.location).z
174             self.z_map[shape.id.first] = z
175         return z
176
177
178 class SVGFillShader(StrokeShader):
179     """Creates SVG fills from the current stroke set"""
180     def __init__(self, filepath, height, name):
181         StrokeShader.__init__(self)
182         # use an ordered dict to maintain input and z-order
183         self.shape_map = OrderedDict()
184         self.filepath = filepath
185         self.h = height
186         self._name = name
187
188     def shade(self, stroke, func=GetShapeF1D(), curvemat=CurveMaterialF0D()):
189         shape = func(stroke)[0]
190         shape = shape.id.first
191         item = self.shape_map.get(shape)
192         if len(stroke) > 2:
193             if item is not None:
194                 item[0].append(stroke)
195             else:
196                 # the shape is not yet present, let's create it.
197                 material = curvemat(Interface0DIterator(stroke))
198                 *color, alpha = material.diffuse
199                 self.shape_map[shape] = ([stroke], color, alpha)
200         # make the strokes of the second drawing invisible
201         for v in stroke:
202             v.attribute.visible = False
203
204     @staticmethod
205     def pathgen(vertices, path, height):
206         yield path
207         for point in vertices:
208             x, y = point
209             yield '{:.3f}, {:.3f} '.format(x, height - y)
210         # closes the path; connects the current to the first point
211         yield 'z" />'
212
213     def write(self):
214         """Write SVG data tree to file """
215         # initialize SVG
216         tree = et.parse(self.filepath)
217         root = tree.getroot()
218         name = self._name
219
220         # create XML elements from the acquired data
221         elems = []
222         path = '<path fill-rule="evenodd" stroke="none" fill-opacity="{}" fill="rgb({}, {}, {})"  d=" M '
223         for strokes, col, alpha in self.shape_map.values():
224             p = path.format(alpha, *(int(255 * c) for c in col))
225             for stroke in strokes:
226                 elems.append(et.XML("".join(self.pathgen((sv.point for sv in stroke), p, self.h))))
227
228         # make <g> for lineset as a whole (don't overwrite)
229         lineset_group = tree.find(".//svg:g[@id='{}']".format(name), namespaces=namespaces)
230         if lineset_group is None:
231             lineset_group = et.XML('<g/>')
232             lineset_group.attrib = {
233                 'id': name,
234                 'xmlns:inkscape': namespaces["inkscape"],
235                 'inkscape:groupmode': 'lineset',
236                 'inkscape:label': name,
237                 }
238             root.insert(0, lineset_group)
239
240         # make <g> for fills
241         frame_group = et.XML('<g />')
242         frame_group.attrib = {'id': "layer_fills", 'inkscape:groupmode': 'fills', 'inkscape:label': 'fills'}
243         # reverse the elements so they are correctly ordered in the image
244         frame_group.extend(reversed(elems))
245         lineset_group.insert(0, frame_group)
246
247         # write SVG to file
248         indent_xml(root)
249         tree.write(self.filepath, encoding='UTF-8', xml_declaration=True)
250
251
252 def indent_xml(elem, level=0, indentsize=4):
253     """Prettifies XML code (used in SVG exporter) """
254     i = "\n" + level * " " * indentsize
255     if len(elem):
256         if not elem.text or not elem.text.strip():
257             elem.text = i + " " * indentsize
258         if not elem.tail or not elem.tail.strip():
259             elem.tail = i
260         for elem in elem:
261             indent_xml(elem, level + 1)
262         if not elem.tail or not elem.tail.strip():
263             elem.tail = i
264     elif level and (not elem.tail or not elem.tail.strip()):
265         elem.tail = i
266
267
268 # - callbacks - #
269 @persistent
270 def svg_export_header(scene):
271     render = scene.render
272     if not (render.use_freestyle and render.use_svg_export):
273         return
274     # create new file (overwrite existing)
275     width, height = render.resolution_x, render.resolution_y
276     scale = render.resolution_percentage / 100
277
278     try:
279         with open(abspath(render.svg_path), "w") as f:
280             f.write(svg_primitive.format(int(width * scale), int(height * scale)))
281     except:
282         # invalid path is properly handled in the parameter editor
283         print("SVG export: invalid path")
284
285
286 @persistent
287 def svg_export_animation(scene):
288     """makes an animation of the exported SVG file """
289     render = scene.render
290     if render.use_freestyle and render.use_svg_export and render.svg_mode == 'ANIMATION':
291         write_animation(abspath(render.svg_path), scene.frame_start, render.fps)
292
293
294 def write_animation(filepath, frame_begin, fps=25):
295     """Adds animate tags to the specified file."""
296     tree = et.parse(filepath)
297     root = tree.getroot()
298
299     linesets = tree.findall(".//svg:g[@inkscape:groupmode='lineset']", namespaces=namespaces)
300     for i, lineset in enumerate(linesets):
301         name = lineset.get('id')
302         frames = lineset.findall(".//svg:g[@inkscape:groupmode='frame']", namespaces=namespaces)
303         fills = lineset.findall(".//svg:g[@inkscape:groupmode='fills']", namespaces=namespaces)
304         fills = reversed(fills) if fills else repeat(None, len(frames))
305
306         n_of_frames = len(frames)
307         keyTimes = ";".join(str(round(x / n_of_frames, 3)) for x in range(n_of_frames)) + ";1"
308
309         style = {
310             'attributeName': 'display',
311             'values': "none;" * (n_of_frames - 1) + "inline;none",
312             'repeatCount': 'indefinite',
313             'keyTimes': keyTimes,
314             'dur': str(n_of_frames / fps) + 's',
315             }
316
317         for j, (frame, fill) in enumerate(zip(frames, fills)):
318             id = 'anim_{}_{:06n}'.format(name, j + frame_begin)
319             # create animate tag
320             frame_anim = et.XML('<animate id="{}" begin="{}s" />'.format(id, (j - n_of_frames) / fps))
321             # add per-lineset style attributes
322             frame_anim.attrib.update(style)
323             # add to the current frame
324             frame.append(frame_anim)
325             # append the animation to the associated fill as well (if valid)
326             if fill is not None:
327                 fill.append(frame_anim)
328
329     # write SVG to file
330     indent_xml(root)
331     tree.write(filepath, encoding='UTF-8', xml_declaration=True)