rename IOHelperOrientation -> OrientationHelper
[blender-addons.git] / render_freestyle_svg.py
blob6423847fadb4b7c9873f762e555acb6444312e69
1 # ##### BEGIN GPL LICENSE BLOCK #####
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.
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.
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.
17 # ##### END GPL LICENSE BLOCK #####
19 # <pep8 compliant>
21 bl_info = {
22 "name": "Freestyle SVG Exporter",
23 "author": "Folkert de Vries",
24 "version": (1, 0),
25 "blender": (2, 72, 1),
26 "location": "Properties > Render > Freestyle SVG Export",
27 "description": "Exports Freestyle's stylized edges in SVG format",
28 "warning": "",
29 "wiki_url": "",
30 "category": "Render",
33 import bpy
34 import parameter_editor
35 import itertools
36 import os
38 import xml.etree.cElementTree as et
40 from freestyle.types import (
41 StrokeShader,
42 Interface0DIterator,
43 Operators,
45 from freestyle.utils import getCurrentScene
46 from freestyle.functions import GetShapeF1D, CurveMaterialF0D
47 from freestyle.predicates import (
48 AndUP1D,
49 ContourUP1D,
50 SameShapeIdBP1D,
51 NotUP1D,
52 QuantitativeInvisibilityUP1D,
53 TrueUP1D,
54 pyZBP1D,
56 from freestyle.chainingiterators import ChainPredicateIterator
57 from parameter_editor import get_dashed_pattern
59 from bpy.props import (
60 BoolProperty,
61 EnumProperty,
62 PointerProperty,
64 from bpy.app.handlers import persistent
65 from collections import OrderedDict
66 from mathutils import Vector
69 # use utf-8 here to keep ElementTree happy, end result is utf-16
70 svg_primitive = """<?xml version="1.0" encoding="ascii" standalone="no"?>
71 <svg xmlns="http://www.w3.org/2000/svg" version="1.1" width="{:d}" height="{:d}">
72 </svg>"""
75 # xml namespaces
76 namespaces = {
77 "inkscape": "http://www.inkscape.org/namespaces/inkscape",
78 "svg": "http://www.w3.org/2000/svg",
82 def render_height(scene):
83 return int(scene.render.resolution_y * scene.render.resolution_percentage / 100)
86 def render_width(scene):
87 return int(scene.render.resolution_x * scene.render.resolution_percentage / 100)
90 class RenderState:
91 # Note that this flag is set to False only after the first frame
92 # has been written to file.
93 is_preview = True
96 @persistent
97 def render_init(scene):
98 RenderState.is_preview = True
101 @persistent
102 def render_write(scene):
103 RenderState.is_preview = False
106 def is_preview_render(scene):
107 return RenderState.is_preview or scene.svg_export.mode == 'FRAME'
110 def create_path(scene):
111 """Creates the output path for the svg file"""
112 dirname = os.path.dirname(scene.render.frame_path())
113 basename = bpy.path.basename(scene.render.filepath)
114 if scene.svg_export.mode == 'FRAME':
115 frame = "{:04d}".format(scene.frame_current)
116 else:
117 frame = "{:04d}-{:04d}".format(scene.frame_start, scene.frame_end)
118 return os.path.join(dirname, basename + frame + ".svg")
121 class SVGExport(bpy.types.PropertyGroup):
122 """Implements the properties for the SVG exporter"""
123 bl_idname = "RENDER_PT_svg_export"
125 use_svg_export = BoolProperty(
126 name="SVG Export",
127 description="Export Freestyle edges to an .svg format",
129 split_at_invisible = BoolProperty(
130 name="Split at Invisible",
131 description="Split the stroke at an invisible vertex",
133 object_fill = BoolProperty(
134 name="Fill Contours",
135 description="Fill the contour with the object's material color",
137 mode = EnumProperty(
138 name="Mode",
139 items=(
140 ('FRAME', "Frame", "Export a single frame", 0),
141 ('ANIMATION', "Animation", "Export an animation", 1),
143 default='FRAME',
145 line_join_type = EnumProperty(
146 name="Linejoin",
147 items=(
148 ('MITTER', "Mitter", "Corners are sharp", 0),
149 ('ROUND', "Round", "Corners are smoothed", 1),
150 ('BEVEL', "Bevel", "Corners are bevelled", 2),
152 default='ROUND',
156 class SVGExporterPanel(bpy.types.Panel):
157 """Creates a Panel in the render context of the properties editor"""
158 bl_idname = "RENDER_PT_SVGExporterPanel"
159 bl_space_type = 'PROPERTIES'
160 bl_label = "Freestyle SVG Export"
161 bl_region_type = 'WINDOW'
162 bl_context = "render"
164 def draw_header(self, context):
165 self.layout.prop(context.scene.svg_export, "use_svg_export", text="")
167 def draw(self, context):
168 layout = self.layout
170 scene = context.scene
171 svg = scene.svg_export
172 freestyle = scene.render.layers.active.freestyle_settings
174 layout.active = (svg.use_svg_export and freestyle.mode != 'SCRIPT')
176 row = layout.row()
177 row.prop(svg, "mode", expand=True)
179 row = layout.row()
180 row.prop(svg, "split_at_invisible")
181 row.prop(svg, "object_fill")
183 row = layout.row()
184 row.prop(svg, "line_join_type", expand=True)
187 @persistent
188 def svg_export_header(scene):
189 if not (scene.render.use_freestyle and scene.svg_export.use_svg_export):
190 return
192 # write the header only for the first frame when animation is being rendered
193 if not is_preview_render(scene) and scene.frame_current != scene.frame_start:
194 return
196 # this may fail still. The error is printed to the console.
197 with open(create_path(scene), "w") as f:
198 f.write(svg_primitive.format(render_width(scene), render_height(scene)))
201 @persistent
202 def svg_export_animation(scene):
203 """makes an animation of the exported SVG file """
204 render = scene.render
205 svg = scene.svg_export
207 if render.use_freestyle and svg.use_svg_export and not is_preview_render(scene):
208 write_animation(create_path(scene), scene.frame_start, render.fps)
211 def write_animation(filepath, frame_begin, fps):
212 """Adds animate tags to the specified file."""
213 tree = et.parse(filepath)
214 root = tree.getroot()
216 linesets = tree.findall(".//svg:g[@inkscape:groupmode='lineset']", namespaces=namespaces)
217 for i, lineset in enumerate(linesets):
218 name = lineset.get('id')
219 frames = lineset.findall(".//svg:g[@inkscape:groupmode='frame']", namespaces=namespaces)
220 n_of_frames = len(frames)
221 keyTimes = ";".join(str(round(x / n_of_frames, 3)) for x in range(n_of_frames)) + ";1"
223 style = {
224 'attributeName': 'display',
225 'values': "none;" * (n_of_frames - 1) + "inline;none",
226 'repeatCount': 'indefinite',
227 'keyTimes': keyTimes,
228 'dur': "{:.3f}s".format(n_of_frames / fps),
231 for j, frame in enumerate(frames):
232 id = 'anim_{}_{:06n}'.format(name, j + frame_begin)
233 # create animate tag
234 frame_anim = et.XML('<animate id="{}" begin="{:.3f}s" />'.format(id, (j - n_of_frames) / fps))
235 # add per-lineset style attributes
236 frame_anim.attrib.update(style)
237 # add to the current frame
238 frame.append(frame_anim)
240 # write SVG to file
241 indent_xml(root)
242 tree.write(filepath, encoding='ascii', xml_declaration=True)
245 # - StrokeShaders - #
246 class SVGPathShader(StrokeShader):
247 """Stroke Shader for writing stroke data to a .svg file."""
248 def __init__(self, name, style, filepath, res_y, split_at_invisible, frame_current):
249 StrokeShader.__init__(self)
250 # attribute 'name' of 'StrokeShader' objects is not writable, so _name is used
251 self._name = name
252 self.filepath = filepath
253 self.h = res_y
254 self.frame_current = frame_current
255 self.elements = []
256 self.split_at_invisible = split_at_invisible
257 # put style attributes into a single svg path definition
258 self.path = '\n<path ' + "".join('{}="{}" '.format(k, v) for k, v in style.items()) + 'd=" M '
260 @classmethod
261 def from_lineset(cls, lineset, filepath, res_y, split_at_invisible, frame_current, *, name=""):
262 """Builds a SVGPathShader using data from the given lineset"""
263 name = name or lineset.name
264 linestyle = lineset.linestyle
265 # extract style attributes from the linestyle and scene
266 svg = getCurrentScene().svg_export
267 style = {
268 'fill': 'none',
269 'stroke-width': linestyle.thickness,
270 'stroke-linecap': linestyle.caps.lower(),
271 'stroke-opacity': linestyle.alpha,
272 'stroke': 'rgb({}, {}, {})'.format(*(int(c * 255) for c in linestyle.color)),
273 'stroke-linejoin': svg.line_join_type.lower(),
275 # get dashed line pattern (if specified)
276 if linestyle.use_dashed_line:
277 style['stroke-dasharray'] = ",".join(str(elem) for elem in get_dashed_pattern(linestyle))
278 # return instance
279 return cls(name, style, filepath, res_y, split_at_invisible, frame_current)
281 @staticmethod
282 def pathgen(stroke, path, height, split_at_invisible, f=lambda v: not v.attribute.visible):
283 """Generator that creates SVG paths (as strings) from the current stroke """
284 it = iter(stroke)
285 # start first path
286 yield path
287 for v in it:
288 x, y = v.point
289 yield '{:.3f}, {:.3f} '.format(x, height - y)
290 if split_at_invisible and v.attribute.visible is False:
291 # end current and start new path;
292 yield '" />' + path
293 # fast-forward till the next visible vertex
294 it = itertools.dropwhile(f, it)
295 # yield next visible vertex
296 svert = next(it, None)
297 if svert is None:
298 break
299 x, y = svert.point
300 yield '{:.3f}, {:.3f} '.format(x, height - y)
301 # close current path
302 yield '" />'
304 def shade(self, stroke):
305 stroke_to_paths = "".join(self.pathgen(stroke, self.path, self.h, self.split_at_invisible)).split("\n")
306 # convert to actual XML, check to prevent empty paths
307 self.elements.extend(et.XML(elem) for elem in stroke_to_paths if len(elem.strip()) > len(self.path))
309 def write(self):
310 """Write SVG data tree to file """
311 tree = et.parse(self.filepath)
312 root = tree.getroot()
313 name = self._name
314 scene = bpy.context.scene
316 # make <g> for lineset as a whole (don't overwrite)
317 lineset_group = tree.find(".//svg:g[@id='{}']".format(name), namespaces=namespaces)
318 if lineset_group is None:
319 lineset_group = et.XML('<g/>')
320 lineset_group.attrib = {
321 'id': name,
322 'xmlns:inkscape': namespaces["inkscape"],
323 'inkscape:groupmode': 'lineset',
324 'inkscape:label': name,
326 root.insert(0, lineset_group)
328 # make <g> for the current frame
329 id = "frame_{:04n}".format(self.frame_current)
331 if scene.svg_export.mode == 'ANIMATION':
332 frame_group = et.XML("<g/>")
333 frame_group.attrib = {'id': id, 'inkscape:groupmode': 'frame', 'inkscape:label': id}
335 stroke_group = et.XML("<g/>")
336 stroke_group.attrib = {'xmlns:inkscape': namespaces["inkscape"],
337 'inkscape:groupmode': 'layer',
338 'id': 'strokes',
339 'inkscape:label': 'strokes'}
340 # nest the structure
341 stroke_group.extend(self.elements)
342 if scene.svg_export.mode == 'ANIMATION':
343 frame_group.append(stroke_group)
344 lineset_group.append(frame_group)
345 else:
346 lineset_group.append(stroke_group)
348 # write SVG to file
349 print("SVG Export: writing to ", self.filepath)
350 indent_xml(root)
351 tree.write(self.filepath, encoding='ascii', xml_declaration=True)
354 class SVGFillShader(StrokeShader):
355 """Creates SVG fills from the current stroke set"""
356 def __init__(self, filepath, height, name):
357 StrokeShader.__init__(self)
358 # use an ordered dict to maintain input and z-order
359 self.shape_map = OrderedDict()
360 self.filepath = filepath
361 self.h = height
362 self._name = name
364 def shade(self, stroke, func=GetShapeF1D(), curvemat=CurveMaterialF0D()):
365 shape = func(stroke)[0].id.first
366 item = self.shape_map.get(shape)
367 if len(stroke) > 2:
368 if item is not None:
369 item[0].append(stroke)
370 else:
371 # the shape is not yet present, let's create it.
372 material = curvemat(Interface0DIterator(stroke))
373 *color, alpha = material.diffuse
374 self.shape_map[shape] = ([stroke], color, alpha)
375 # make the strokes of the second drawing invisible
376 for v in stroke:
377 v.attribute.visible = False
379 @staticmethod
380 def pathgen(vertices, path, height):
381 yield path
382 for point in vertices:
383 x, y = point
384 yield '{:.3f}, {:.3f} '.format(x, height - y)
385 yield 'z" />' # closes the path; connects the current to the first point
387 def write(self):
388 """Write SVG data tree to file """
389 # initialize SVG
390 tree = et.parse(self.filepath)
391 root = tree.getroot()
392 name = self._name
393 scene = bpy.context.scene
395 # create XML elements from the acquired data
396 elems = []
397 path = '<path fill-rule="evenodd" stroke="none" fill-opacity="{}" fill="rgb({}, {}, {})" d=" M '
398 for strokes, col, alpha in self.shape_map.values():
399 p = path.format(alpha, *(int(255 * c) for c in col))
400 for stroke in strokes:
401 elems.append(et.XML("".join(self.pathgen((sv.point for sv in stroke), p, self.h))))
403 # make <g> for lineset as a whole (don't overwrite)
404 lineset_group = tree.find(".//svg:g[@id='{}']".format(name), namespaces=namespaces)
405 if lineset_group is None:
406 lineset_group = et.XML('<g/>')
407 lineset_group.attrib = {
408 'id': name,
409 'xmlns:inkscape': namespaces["inkscape"],
410 'inkscape:groupmode': 'lineset',
411 'inkscape:label': name,
413 root.insert(0, lineset_group)
415 if scene.svg_export.mode == 'ANIMATION':
416 # add the fills to the <g> of the current frame
417 frame_group = tree.find(".//svg:g[@id='frame_{:04n}']".format(scene.frame_current), namespaces=namespaces)
418 if frame_group is None:
419 # something has gone very wrong
420 raise RuntimeError("SVGFillShader: frame_group is None")
422 # add <g> for the strokes of the current frame
423 stroke_group = et.XML("<g/>")
424 stroke_group.attrib = {'xmlns:inkscape': namespaces["inkscape"],
425 'inkscape:groupmode': 'layer',
426 'inkscape:label': 'fills',
427 'id': 'fills'}
429 # reverse fills to get the correct order
430 stroke_group.extend(reversed(elems))
432 if scene.svg_export.mode == 'ANIMATION':
433 frame_group.insert(0, stroke_group)
434 else:
435 lineset_group.insert(0, stroke_group)
437 # write SVG to file
438 indent_xml(root)
439 tree.write(self.filepath, encoding='ascii', xml_declaration=True)
442 # - Callbacks - #
443 class ParameterEditorCallback(object):
444 """Object to store callbacks for the Parameter Editor in"""
445 def lineset_pre(self, scene, layer, lineset):
446 raise NotImplementedError()
448 def modifier_post(self, scene, layer, lineset):
449 raise NotImplementedError()
451 def lineset_post(self, scene, layer, lineset):
452 raise NotImplementedError()
455 class SVGPathShaderCallback(ParameterEditorCallback):
456 @classmethod
457 def modifier_post(cls, scene, layer, lineset):
458 if not (scene.render.use_freestyle and scene.svg_export.use_svg_export):
459 return []
461 split = scene.svg_export.split_at_invisible
462 cls.shader = SVGPathShader.from_lineset(
463 lineset, create_path(scene),
464 render_height(scene), split, scene.frame_current)
465 return [cls.shader]
467 @classmethod
468 def lineset_post(cls, scene, *args):
469 if not (scene.render.use_freestyle and scene.svg_export.use_svg_export):
470 return
472 cls.shader.write()
475 class SVGFillShaderCallback(ParameterEditorCallback):
476 @staticmethod
477 def lineset_post(scene, layer, lineset):
478 if not (scene.render.use_freestyle and scene.svg_export.use_svg_export and scene.svg_export.object_fill):
479 return
481 # reset the stroke selection (but don't delete the already generated strokes)
482 Operators.reset(delete_strokes=False)
483 # shape detection
484 upred = AndUP1D(QuantitativeInvisibilityUP1D(0), ContourUP1D())
485 Operators.select(upred)
486 # chain when the same shape and visible
487 bpred = SameShapeIdBP1D()
488 Operators.bidirectional_chain(ChainPredicateIterator(upred, bpred), NotUP1D(QuantitativeInvisibilityUP1D(0)))
489 # sort according to the distance from camera
490 Operators.sort(pyZBP1D())
491 # render and write fills
492 shader = SVGFillShader(create_path(scene), render_height(scene), lineset.name)
493 Operators.create(TrueUP1D(), [shader, ])
494 shader.write()
497 def indent_xml(elem, level=0, indentsize=4):
498 """Prettifies XML code (used in SVG exporter) """
499 i = "\n" + level * " " * indentsize
500 if len(elem):
501 if not elem.text or not elem.text.strip():
502 elem.text = i + " " * indentsize
503 if not elem.tail or not elem.tail.strip():
504 elem.tail = i
505 for elem in elem:
506 indent_xml(elem, level + 1)
507 if not elem.tail or not elem.tail.strip():
508 elem.tail = i
509 elif level and (not elem.tail or not elem.tail.strip()):
510 elem.tail = i
513 classes = (
514 SVGExporterPanel,
515 SVGExport,
519 def register():
521 for cls in classes:
522 bpy.utils.register_class(cls)
523 bpy.types.Scene.svg_export = PointerProperty(type=SVGExport)
525 # add callbacks
526 bpy.app.handlers.render_init.append(render_init)
527 bpy.app.handlers.render_write.append(render_write)
528 bpy.app.handlers.render_pre.append(svg_export_header)
529 bpy.app.handlers.render_complete.append(svg_export_animation)
531 # manipulate shaders list
532 parameter_editor.callbacks_modifiers_post.append(SVGPathShaderCallback.modifier_post)
533 parameter_editor.callbacks_lineset_post.append(SVGPathShaderCallback.lineset_post)
534 parameter_editor.callbacks_lineset_post.append(SVGFillShaderCallback.lineset_post)
536 # register namespaces
537 et.register_namespace("", "http://www.w3.org/2000/svg")
538 et.register_namespace("inkscape", "http://www.inkscape.org/namespaces/inkscape")
539 et.register_namespace("sodipodi", "http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd")
542 def unregister():
544 for cls in classes:
545 bpy.utils.unregister_class(cls)
546 del bpy.types.Scene.svg_export
548 # remove callbacks
549 bpy.app.handlers.render_init.remove(render_init)
550 bpy.app.handlers.render_write.remove(render_write)
551 bpy.app.handlers.render_pre.remove(svg_export_header)
552 bpy.app.handlers.render_complete.remove(svg_export_animation)
554 # manipulate shaders list
555 parameter_editor.callbacks_modifiers_post.remove(SVGPathShaderCallback.modifier_post)
556 parameter_editor.callbacks_lineset_post.remove(SVGPathShaderCallback.lineset_post)
557 parameter_editor.callbacks_lineset_post.remove(SVGFillShaderCallback.lineset_post)
560 if __name__ == "__main__":
561 register()