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