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 #####
22 "name": "Freestyle SVG Exporter",
23 "author": "Folkert de Vries",
25 "blender": (2, 72, 1),
26 "location": "Properties > Render > Freestyle SVG Export",
27 "description": "Exports Freestyle's stylized edges in SVG format",
34 import parameter_editor
38 import xml
.etree
.cElementTree
as et
40 from freestyle
.types
import (
45 from freestyle
.utils
import getCurrentScene
46 from freestyle
.functions
import GetShapeF1D
, CurveMaterialF0D
47 from freestyle
.predicates
import (
52 QuantitativeInvisibilityUP1D
,
56 from freestyle
.chainingiterators
import ChainPredicateIterator
57 from parameter_editor
import get_dashed_pattern
59 from bpy
.props
import (
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}">
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)
91 # Note that this flag is set to False only after the first frame
92 # has been written to file.
97 def render_init(scene
):
98 RenderState
.is_preview
= True
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
)
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(
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",
140 ('FRAME', "Frame", "Export a single frame", 0),
141 ('ANIMATION', "Animation", "Export an animation", 1),
145 line_join_type
= EnumProperty(
148 ('MITTER', "Mitter", "Corners are sharp", 0),
149 ('ROUND', "Round", "Corners are smoothed", 1),
150 ('BEVEL', "Bevel", "Corners are bevelled", 2),
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
):
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')
177 row
.prop(svg
, "mode", expand
=True)
180 row
.prop(svg
, "split_at_invisible")
181 row
.prop(svg
, "object_fill")
184 row
.prop(svg
, "line_join_type", expand
=True)
188 def svg_export_header(scene
):
189 if not (scene
.render
.use_freestyle
and scene
.svg_export
.use_svg_export
):
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
:
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
)))
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"
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
)
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
)
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
252 self
.filepath
= filepath
254 self
.frame_current
= frame_current
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 '
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
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
))
279 return cls(name
, style
, filepath
, res_y
, split_at_invisible
, frame_current
)
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 """
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;
293 # fast-forward till the next visible vertex
294 it
= itertools
.dropwhile(f
, it
)
295 # yield next visible vertex
296 svert
= next(it
, None)
300 yield '{:.3f}, {:.3f} '.format(x
, height
- y
)
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
))
310 """Write SVG data tree to file """
311 tree
= et
.parse(self
.filepath
)
312 root
= tree
.getroot()
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
= {
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',
339 'inkscape:label': 'strokes'}
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
)
346 lineset_group
.append(stroke_group
)
349 print("SVG Export: writing to ", self
.filepath
)
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
364 def shade(self
, stroke
, func
=GetShapeF1D(), curvemat
=CurveMaterialF0D()):
365 shape
= func(stroke
)[0].id.first
366 item
= self
.shape_map
.get(shape
)
369 item
[0].append(stroke
)
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
377 v
.attribute
.visible
= False
380 def pathgen(vertices
, path
, height
):
382 for point
in vertices
:
384 yield '{:.3f}, {:.3f} '.format(x
, height
- y
)
385 yield 'z" />' # closes the path; connects the current to the first point
388 """Write SVG data tree to file """
390 tree
= et
.parse(self
.filepath
)
391 root
= tree
.getroot()
393 scene
= bpy
.context
.scene
395 # create XML elements from the acquired data
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
= {
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',
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
)
435 lineset_group
.insert(0, stroke_group
)
439 tree
.write(self
.filepath
, encoding
='ascii', xml_declaration
=True)
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
):
457 def modifier_post(cls
, scene
, layer
, lineset
):
458 if not (scene
.render
.use_freestyle
and scene
.svg_export
.use_svg_export
):
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
)
468 def lineset_post(cls
, scene
, *args
):
469 if not (scene
.render
.use_freestyle
and scene
.svg_export
.use_svg_export
):
475 class SVGFillShaderCallback(ParameterEditorCallback
):
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
):
481 # reset the stroke selection (but don't delete the already generated strokes)
482 Operators
.reset(delete_strokes
=False)
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
, ])
497 def indent_xml(elem
, level
=0, indentsize
=4):
498 """Prettifies XML code (used in SVG exporter) """
499 i
= "\n" + level
* " " * indentsize
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():
506 indent_xml(elem
, level
+ 1)
507 if not elem
.tail
or not elem
.tail
.strip():
509 elif level
and (not elem
.tail
or not elem
.tail
.strip()):
522 bpy
.utils
.register_class(cls
)
523 bpy
.types
.Scene
.svg_export
= PointerProperty(type=SVGExport
)
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")
545 bpy
.utils
.unregister_class(cls
)
546 del bpy
.types
.Scene
.svg_export
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__":