824c7bad8c0d989f0eb96a980f9b2163bf079a0d
[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 # - SVG export - #
64 class SVGPathShader(StrokeShader):
65     """Stroke Shader for writing stroke data to a .svg file."""
66     def __init__(self, name, style, filepath, res_y, split_at_invisible, frame_current):
67         StrokeShader.__init__(self)
68         # attribute 'name' of 'StrokeShader' objects is not writable, so _name is used
69         self._name = name
70         self.filepath = filepath
71         self.h = res_y
72         self.frame_current = frame_current
73         self.elements = []
74         self.split_at_invisible = split_at_invisible
75         # put style attributes into a single svg path definition
76         self.path = '\n<path ' + "".join('{}="{}" '.format(k, v) for k, v in style.items()) + 'd=" M '
77
78     @classmethod
79     def from_lineset(cls, lineset, filepath, res_y, split_at_invisible, frame_current, *, name=""):
80         """Builds a SVGPathShader using data from the given lineset"""
81         name = name or lineset.name
82         linestyle = lineset.linestyle
83         # extract style attributes from the linestyle
84         style = {
85             'fill': 'none',
86             'stroke-width': linestyle.thickness,
87             'stroke-linecap': linestyle.caps.lower(),
88             'stroke-opacity': linestyle.alpha,
89             'stroke': 'rgb({}, {}, {})'.format(*(int(c * 255) for c in linestyle.color))
90             }
91         # get dashed line pattern (if specified)
92         if linestyle.use_dashed_line:
93             style['stroke-dasharray'] = ",".join(str(elem) for elem in get_dashed_pattern(linestyle))
94         # return instance
95         return cls(name, style, filepath, res_y, split_at_invisible, frame_current)
96
97     @staticmethod
98     def pathgen(stroke, path, height, split_at_invisible, f=lambda v: not v.attribute.visible):
99         """Generator that creates SVG paths (as strings) from the current stroke """
100         it = iter(stroke)
101         # start first path
102         yield path
103         for v in it:
104             x, y = v.point
105             yield '{:.3f}, {:.3f} '.format(x, height - y)
106             if split_at_invisible and v.attribute.visible == False:
107                 # end current and start new path;
108                 yield '" />' + path
109                 # fast-forward till the next visible vertex
110                 it = dropwhile(f, it)
111                 # yield next visible vertex
112                 svert = next(it, None)
113                 if svert is None:
114                     break
115                 x, y = svert.point
116                 yield '{:.3f}, {:.3f} '.format(x, height - y)
117         # close current path
118         yield '" />'
119
120     def shade(self, stroke):
121         stroke_to_paths = "".join(self.pathgen(stroke, self.path, self.h, self.split_at_invisible)).split("\n")
122         # convert to actual XML, check to prevent empty paths
123         self.elements.extend(et.XML(elem) for elem in stroke_to_paths if len(elem.strip()) > len(self.path))
124
125     def write(self):
126         """Write SVG data tree to file """
127         tree = et.parse(self.filepath)
128         root = tree.getroot()
129         name = self._name
130
131         # make <g> for lineset as a whole (don't overwrite)
132         lineset_group = tree.find(".//svg:g[@id='{}']".format(name), namespaces=namespaces)
133         if lineset_group is None:
134             lineset_group = et.XML('<g/>')
135             lineset_group.attrib = {
136                 'id': name,
137                 'xmlns:inkscape': namespaces["inkscape"],
138                 'inkscape:groupmode': 'lineset',
139                 'inkscape:label': name,
140                 }
141             root.insert(0, lineset_group)
142
143         # make <g> for the current frame
144         id = "{}_frame_{:06n}".format(name, self.frame_current)
145         frame_group = et.XML("<g/>")
146         frame_group.attrib = {'id': id, 'inkscape:groupmode': 'frame', 'inkscape:label': id}
147         frame_group.extend(self.elements)
148         lineset_group.append(frame_group)
149
150         # write SVG to file
151         indent_xml(root)
152         tree.write(self.filepath, encoding='UTF-8', xml_declaration=True)
153
154 # - Fill export - #
155 class ShapeZ(BinaryPredicate1D):
156     """Sort ViewShapes by their z-index"""
157     def __init__(self, scene):
158         BinaryPredicate1D.__init__(self)
159         self.z_map = dict()
160         self.scene = scene
161
162     def __call__(self, i1, i2):
163         return self.get_z_curve(i1) < self.get_z_curve(i2)
164
165     def get_z_curve(self, curve, func=GetShapeF1D()):
166         shape = func(curve)[0]
167         # get the shapes z-index
168         z = self.z_map.get(shape.id.first)
169         if z is None:
170             o = bpy.data.objects[shape.name]
171             z = world_to_camera_view(self.scene, self.scene.camera, o.location).z
172             self.z_map[shape.id.first] = z
173         return z
174
175
176 class SVGFillShader(StrokeShader):
177     """Creates SVG fills from the current stroke set"""
178     def __init__(self, filepath, height, name):
179         StrokeShader.__init__(self)
180         # use an ordered dict to maintain input and z-order
181         self.shape_map = OrderedDict()
182         self.filepath = filepath
183         self.h = height
184         self._name = name
185
186     def shade(self, stroke, func=GetShapeF1D(), curvemat=CurveMaterialF0D()):
187         shape = func(stroke)[0]
188         shape = shape.id.first
189         item = self.shape_map.get(shape)
190         if len(stroke) > 2:
191             if item is not None:
192                 item[0].append(stroke)
193             else:
194                 # the shape is not yet present, let's create it.
195                 material = curvemat(Interface0DIterator(stroke))
196                 *color, alpha = material.diffuse
197                 self.shape_map[shape] = ([stroke], color, alpha)
198         # make the strokes of the second drawing invisible
199         for v in stroke:
200             v.attribute.visible = False
201
202     @staticmethod
203     def pathgen(vertices, path, height):
204         yield path
205         for point in vertices:
206             x, y = point
207             yield '{:.3f}, {:.3f} '.format(x, height - y)
208         yield 'z" />' # closes the path; connects the current to the first point
209
210     def write(self):
211         """Write SVG data tree to file """
212         # initialize SVG
213         tree = et.parse(self.filepath)
214         root = tree.getroot()
215         name = self._name
216
217         # create XML elements from the acquired data
218         elems = []
219         path = '<path fill-rule="evenodd" stroke="none" fill-opacity="{}" fill="rgb({}, {}, {})"  d=" M '
220         for strokes, col, alpha in self.shape_map.values():
221             p = path.format(alpha, *(int(255 * c) for c in col))
222             for stroke in strokes:
223                 elems.append(et.XML("".join(self.pathgen((sv.point for sv in stroke), p, self.h))))
224
225         # make <g> for lineset as a whole (don't overwrite)
226         lineset_group = tree.find(".//svg:g[@id='{}']".format(name), namespaces=namespaces)
227         if lineset_group is None:
228             lineset_group = et.XML('<g/>')
229             lineset_group.attrib = {
230                 'id': name,
231                 'xmlns:inkscape': namespaces["inkscape"],
232                 'inkscape:groupmode': 'lineset',
233                 'inkscape:label': name,
234                 }
235             root.insert(0, lineset_group)
236
237         # make <g> for fills
238         frame_group = et.XML('<g />')
239         frame_group.attrib = {'id': "layer_fills", 'inkscape:groupmode': 'fills', 'inkscape:label': 'fills'}
240         # reverse the elements so they are correctly ordered in the image
241         frame_group.extend(reversed(elems))
242         lineset_group.insert(0, frame_group)
243
244         # write SVG to file
245         indent_xml(root)
246         tree.write(self.filepath, encoding='UTF-8', xml_declaration=True)
247
248
249 def indent_xml(elem, level=0, indentsize=4):
250     """Prettifies XML code (used in SVG exporter) """
251     i = "\n" + level * " " * indentsize
252     if len(elem):
253         if not elem.text or not elem.text.strip():
254             elem.text = i + " " * indentsize
255         if not elem.tail or not elem.tail.strip():
256             elem.tail = i
257         for elem in elem:
258             indent_xml(elem, level + 1)
259         if not elem.tail or not elem.tail.strip():
260             elem.tail = i
261     elif level and (not elem.tail or not elem.tail.strip()):
262         elem.tail = i
263
264 # - callbacks - #
265 @persistent
266 def svg_export_header(scene):
267     render = scene.render
268     if not (render.use_freestyle and render.use_svg_export):
269         return
270     #create new file (overwrite existing)
271     width, height = render.resolution_x, render.resolution_y
272     scale = render.resolution_percentage / 100
273
274     try:
275         with open(abspath(render.svg_path), "w") as f:
276             f.write(svg_primitive.format(int(width * scale), int(height * scale)))
277     except:
278         # invalid path is properly handled in the parameter editor
279         print("SVG export: invalid path")
280
281 @persistent
282 def svg_export_animation(scene):
283     """makes an animation of the exported SVG file """
284     render = scene.render
285     if render.use_freestyle and render.use_svg_export and render.svg_mode == 'ANIMATION':
286         write_animation(abspath(render.svg_path), scene.frame_start, render.fps)
287
288
289 def write_animation(filepath, frame_begin, fps=25):
290     """Adds animate tags to the specified file."""
291     tree = et.parse(filepath)
292     root = tree.getroot()
293
294     linesets = tree.findall(".//svg:g[@inkscape:groupmode='lineset']", namespaces=namespaces)
295     for i, lineset in enumerate(linesets):
296         name = lineset.get('id')
297         frames = lineset.findall(".//svg:g[@inkscape:groupmode='frame']", namespaces=namespaces)
298         fills = lineset.findall(".//svg:g[@inkscape:groupmode='fills']", namespaces=namespaces)
299         fills = reversed(fills) if fills else repeat(None, len(frames))
300
301         n_of_frames = len(frames)
302         keyTimes = ";".join(str(round(x / n_of_frames, 3)) for x in range(n_of_frames)) + ";1"
303
304         style = {
305             'attributeName': 'display',
306             'values': "none;" * (n_of_frames - 1) + "inline;none",
307             'repeatCount': 'indefinite',
308             'keyTimes': keyTimes,
309             'dur': str(n_of_frames / fps) + 's',
310             }
311
312         for j, (frame, fill) in enumerate(zip(frames, fills)):
313             id = 'anim_{}_{:06n}'.format(name, j + frame_begin)
314             # create animate tag
315             frame_anim = et.XML('<animate id="{}" begin="{}s" />'.format(id, (j - n_of_frames) / fps))
316             # add per-lineset style attributes
317             frame_anim.attrib.update(style)
318             # add to the current frame
319             frame.append(frame_anim)
320             # append the animation to the associated fill as well (if valid)
321             if fill is not None:
322                 fill.append(frame_anim)
323
324     # write SVG to file
325     indent_xml(root)
326     tree.write(filepath, encoding='UTF-8', xml_declaration=True)