# SPDX-License-Identifier: GPL-2.0-or-later bl_info = { "name": "Freestyle SVG Exporter", "author": "Folkert de Vries", "version": (1, 0), "blender": (2, 80, 0), "location": "Properties > Render > Freestyle SVG Export", "description": "Exports Freestyle's stylized edges in SVG format", "warning": "", "doc_url": "{BLENDER_MANUAL_URL}/addons/render/render_freestyle_svg.html", "support": 'OFFICIAL', "category": "Render", } import bpy import parameter_editor import itertools import os import xml.etree.cElementTree as et from bpy.app.handlers import persistent from collections import OrderedDict from functools import partial from mathutils import Vector from freestyle.types import ( StrokeShader, Interface0DIterator, Operators, Nature, StrokeVertex, ) from freestyle.utils import ( getCurrentScene, BoundingBox, is_poly_clockwise, StrokeCollector, material_from_fedge, get_object_name, ) from freestyle.functions import ( GetShapeF1D, CurveMaterialF0D, ) from freestyle.predicates import ( AndBP1D, AndUP1D, ContourUP1D, ExternalContourUP1D, MaterialBP1D, NotBP1D, NotUP1D, OrBP1D, OrUP1D, pyNatureUP1D, pyZBP1D, pyZDiscontinuityBP1D, QuantitativeInvisibilityUP1D, SameShapeIdBP1D, TrueBP1D, TrueUP1D, ) from freestyle.chainingiterators import ChainPredicateIterator from parameter_editor import get_dashed_pattern from bpy.props import ( BoolProperty, EnumProperty, PointerProperty, ) # use utf-8 here to keep ElementTree happy, end result is utf-16 svg_primitive = """ """ # xml namespaces namespaces = { "inkscape": "http://www.inkscape.org/namespaces/inkscape", "svg": "http://www.w3.org/2000/svg", "sodipodi": "http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd", "": "http://www.w3.org/2000/svg", } # wrap XMLElem.find, so the namespaces don't need to be given as an argument def find_xml_elem(obj, search, namespaces, *, all=False): if all: return obj.findall(search, namespaces=namespaces) return obj.find(search, namespaces=namespaces) find_svg_elem = partial(find_xml_elem, namespaces=namespaces) def render_height(scene): return int(scene.render.resolution_y * scene.render.resolution_percentage / 100) def render_width(scene): return int(scene.render.resolution_x * scene.render.resolution_percentage / 100) def format_rgb(color): return 'rgb({}, {}, {})'.format(*(int(v * 255) for v in color)) # stores the state of the render, used to differ between animation and single frame renders. class RenderState: # Note that this flag is set to False only after the first frame # has been written to file. is_preview = True @persistent def render_init(scene): RenderState.is_preview = True @persistent def render_write(scene): RenderState.is_preview = False def is_preview_render(scene): return RenderState.is_preview or scene.svg_export.mode == 'FRAME' def create_path(scene): """Creates the output path for the svg file""" path = os.path.dirname(scene.render.frame_path()) file_dir_path = os.path.dirname(bpy.data.filepath) # try to use the given path if it is absolute if os.path.isabs(path): dirname = path # otherwise, use current file's location as a start for the relative path elif bpy.data.is_saved and file_dir_path: dirname = os.path.normpath(os.path.join(file_dir_path, path)) # otherwise, use the folder from which blender was called as the start else: dirname = os.path.abspath(bpy.path.abspath(path)) basename = bpy.path.basename(scene.render.filepath) if scene.svg_export.mode == 'FRAME': frame = "{:04d}".format(scene.frame_current) else: frame = "{:04d}-{:04d}".format(scene.frame_start, scene.frame_end) return os.path.join(dirname, basename + frame + ".svg") class SVGExporterLinesetPanel(bpy.types.Panel): """Creates a Panel in the Render Layers context of the properties editor""" bl_idname = "RENDER_PT_SVGExporterLinesetPanel" bl_space_type = 'PROPERTIES' bl_label = "Freestyle Line Style SVG Export" bl_region_type = 'WINDOW' bl_context = "view_layer" def draw(self, context): layout = self.layout scene = context.scene svg = scene.svg_export freestyle = context.window.view_layer.freestyle_settings try: linestyle = freestyle.linesets.active.linestyle except AttributeError: # Linestyles can be removed, so 0 linestyles is possible. # there is nothing to draw in those cases. # see https://developer.blender.org/T49855 return else: layout.active = (svg.use_svg_export and freestyle.mode != 'SCRIPT') row = layout.row() column = row.column() column.prop(linestyle, 'use_export_strokes') column = row.column() column.active = svg.object_fill column.prop(linestyle, 'use_export_fills') row = layout.row() row.prop(linestyle, "stroke_color_mode", expand=True) class SVGExport(bpy.types.PropertyGroup): """Implements the properties for the SVG exporter""" bl_idname = "RENDER_PT_svg_export" use_svg_export: BoolProperty( name="SVG Export", description="Export Freestyle edges to an .svg format", ) split_at_invisible: BoolProperty( name="Split at Invisible", description="Split the stroke at an invisible vertex", ) object_fill: BoolProperty( name="Fill Contours", description="Fill the contour with the object's material color", ) mode: EnumProperty( name="Mode", items=( ('FRAME', "Frame", "Export a single frame", 0), ('ANIMATION', "Animation", "Export an animation", 1), ), default='FRAME', ) line_join_type: EnumProperty( name="Line Join", items=( ('MITER', "Miter", "Corners are sharp", 0), ('ROUND', "Round", "Corners are smoothed", 1), ('BEVEL', "Bevel", "Corners are beveled", 2), ), default='ROUND', ) class SVGExporterPanel(bpy.types.Panel): """Creates a Panel in the render context of the properties editor""" bl_idname = "RENDER_PT_SVGExporterPanel" bl_space_type = 'PROPERTIES' bl_label = "Freestyle SVG Export" bl_region_type = 'WINDOW' bl_context = "render" def draw_header(self, context): self.layout.prop(context.scene.svg_export, "use_svg_export", text="") def draw(self, context): layout = self.layout scene = context.scene svg = scene.svg_export freestyle = context.window.view_layer.freestyle_settings layout.active = (svg.use_svg_export and freestyle.mode != 'SCRIPT') row = layout.row() row.prop(svg, "mode", expand=True) row = layout.row() row.prop(svg, "split_at_invisible") row.prop(svg, "object_fill") row = layout.row() row.prop(svg, "line_join_type", expand=True) @persistent def svg_export_header(scene): if not (scene.render.use_freestyle and scene.svg_export.use_svg_export): return # write the header only for the first frame when animation is being rendered if not is_preview_render(scene) and scene.frame_current != scene.frame_start: return # this may fail still. The error is printed to the console. with open(create_path(scene), "w") as f: f.write(svg_primitive.format(render_width(scene), render_height(scene))) @persistent def svg_export_animation(scene): """makes an animation of the exported SVG file """ render = scene.render svg = scene.svg_export if render.use_freestyle and svg.use_svg_export and not is_preview_render(scene): write_animation(create_path(scene), scene.frame_start, render.fps) def write_animation(filepath, frame_begin, fps): """Adds animate tags to the specified file.""" tree = et.parse(filepath) root = tree.getroot() linesets = find_svg_elem(tree, ".//svg:g[@inkscape:groupmode='lineset']", all=True) for i, lineset in enumerate(linesets): name = lineset.get('id') frames = find_svg_elem(lineset, ".//svg:g[@inkscape:groupmode='frame']", all=True) n_of_frames = len(frames) keyTimes = ";".join(str(round(x / n_of_frames, 3)) for x in range(n_of_frames)) + ";1" style = { 'attributeName': 'display', 'values': "none;" * (n_of_frames - 1) + "inline;none", 'repeatCount': 'indefinite', 'keyTimes': keyTimes, 'dur': "{:.3f}s".format(n_of_frames / fps), } for j, frame in enumerate(frames): id = 'anim_{}_{:06n}'.format(name, j + frame_begin) # create animate tag frame_anim = et.XML(''.format(id, (j - n_of_frames) / fps)) # add per-lineset style attributes frame_anim.attrib.update(style) # add to the current frame frame.append(frame_anim) # write SVG to file indent_xml(root) tree.write(filepath, encoding='ascii', xml_declaration=True) # - StrokeShaders - # class SVGPathShader(StrokeShader): """Stroke Shader for writing stroke data to a .svg file.""" def __init__(self, name, style, filepath, res_y, split_at_invisible, stroke_color_mode, frame_current): StrokeShader.__init__(self) # attribute 'name' of 'StrokeShader' objects is not writable, so _name is used self._name = name self.filepath = filepath self.h = res_y self.frame_current = frame_current self.elements = [] self.split_at_invisible = split_at_invisible self.stroke_color_mode = stroke_color_mode # BASE | FIRST | LAST self.style = style @classmethod def from_lineset(cls, lineset, filepath, res_y, split_at_invisible, use_stroke_color, frame_current, *, name=""): """Builds a SVGPathShader using data from the given lineset""" name = name or lineset.name linestyle = lineset.linestyle # extract style attributes from the linestyle and scene svg = getCurrentScene().svg_export style = { 'fill': 'none', 'stroke-width': linestyle.thickness, 'stroke-linecap': linestyle.caps.lower(), 'stroke-opacity': linestyle.alpha, 'stroke': format_rgb(linestyle.color), 'stroke-linejoin': svg.line_join_type.lower(), } # get dashed line pattern (if specified) if linestyle.use_dashed_line: style['stroke-dasharray'] = ",".join(str(elem) for elem in get_dashed_pattern(linestyle)) # return instance return cls(name, style, filepath, res_y, split_at_invisible, use_stroke_color, frame_current) @staticmethod def pathgen(stroke, style, height, split_at_invisible, stroke_color_mode, f=lambda v: not v.attribute.visible): """Generator that creates SVG paths (as strings) from the current stroke """ if len(stroke) <= 1: return "" if stroke_color_mode != 'BASE': # try to use the color of the first or last vertex try: index = 0 if stroke_color_mode == 'FIRST' else -1 color = format_rgb(stroke[index].attribute.color) style["stroke"] = color except (ValueError, IndexError): # default is linestyle base color pass # put style attributes into a single svg path definition path = '\n' + path # fast-forward till the next visible vertex it = itertools.dropwhile(f, it) # yield next visible vertex svert = next(it, None) if svert is None: break x, y = svert.point yield '{:.3f}, {:.3f} '.format(x, height - y) # close current path yield '" />' def shade(self, stroke): stroke_to_paths = "".join(self.pathgen(stroke, self.style, self.h, self.split_at_invisible, self.stroke_color_mode)).split("\n") # convert to actual XML. Empty strokes are empty strings; they are ignored. self.elements.extend(et.XML(elem) for elem in stroke_to_paths if elem) # if len(elem.strip()) > len(self.path)) def write(self): """Write SVG data tree to file """ tree = et.parse(self.filepath) root = tree.getroot() name = self._name scene = bpy.context.scene # create for lineset as a whole (don't overwrite) # when rendering an animation, frames will be nested in here, otherwise a group of strokes and optionally fills. lineset_group = find_svg_elem(tree, ".//svg:g[@id='{}']".format(name)) if lineset_group is None: lineset_group = et.XML('') lineset_group.attrib = { 'id': name, 'xmlns:inkscape': namespaces["inkscape"], 'inkscape:groupmode': 'lineset', 'inkscape:label': name, } root.append(lineset_group) # create for the current frame id = "frame_{:04n}".format(self.frame_current) stroke_group = et.XML("") stroke_group.attrib = { 'xmlns:inkscape': namespaces["inkscape"], 'inkscape:groupmode': 'layer', 'id': 'strokes', 'inkscape:label': 'strokes' } # nest the structure stroke_group.extend(self.elements) if scene.svg_export.mode == 'ANIMATION': frame_group = et.XML("") frame_group.attrib = {'id': id, 'inkscape:groupmode': 'frame', 'inkscape:label': id} frame_group.append(stroke_group) lineset_group.append(frame_group) else: lineset_group.append(stroke_group) # write SVG to file print("SVG Export: writing to", self.filepath) indent_xml(root) tree.write(self.filepath, encoding='ascii', xml_declaration=True) class SVGFillBuilder: def __init__(self, filepath, height, name): self.filepath = filepath self._name = name self.stroke_to_fill = partial(self.stroke_to_svg, height=height) @staticmethod def pathgen(vertices, path, height): yield path for point in vertices: x, y = point yield '{:.3f}, {:.3f} '.format(x, height - y) yield ' z" />' # closes the path; connects the current to the first point @staticmethod def get_merged_strokes(strokes): def extend_stroke(stroke, vertices): for vert in map(StrokeVertex, vertices): stroke.insert_vertex(vert, stroke.stroke_vertices_end()) return stroke base_strokes = tuple(stroke for stroke in strokes if not is_poly_clockwise(stroke)) merged_strokes = OrderedDict((s, list()) for s in base_strokes) for stroke in filter(is_poly_clockwise, strokes): for base in base_strokes: # don't merge when diffuse colors don't match if diffuse_from_stroke(stroke) != diffuse_from_stroke(stroke): continue # only merge when the 'hole' is inside the base elif stroke_inside_stroke(stroke, base): merged_strokes[base].append(stroke) break # if it isn't a hole, it is likely that there are two strokes belonging # to the same object separated by another object. let's try to join them elif (get_object_name(base) == get_object_name(stroke) and diffuse_from_stroke(stroke) == diffuse_from_stroke(stroke)): base = extend_stroke(base, (sv for sv in stroke)) break else: # if all else fails, treat this stroke as a base stroke merged_strokes.update({stroke: []}) return merged_strokes def stroke_to_svg(self, stroke, height, parameters=None): if parameters is None: *color, alpha = diffuse_from_stroke(stroke) color = tuple(int(255 * c) for c in color) parameters = { 'fill_rule': 'evenodd', 'stroke': 'none', 'fill-opacity': alpha, 'fill': 'rgb' + repr(color), } param_str = " ".join('{}="{}"'.format(k, v) for k, v in parameters.items()) path = '') lineset_group.attrib = { 'id': name, 'xmlns:inkscape': namespaces["inkscape"], 'inkscape:groupmode': 'lineset', 'inkscape:label': name, } root.append(lineset_group) print('added new lineset group ', name) # for the fills of the current frame fill_group = et.XML('') fill_group.attrib = { 'xmlns:inkscape': namespaces["inkscape"], 'inkscape:groupmode': 'layer', 'inkscape:label': 'fills', 'id': 'fills' } fill_elements = self.create_fill_elements(strokes) fill_group.extend(reversed(tuple(fill_elements))) if scene.svg_export.mode == 'ANIMATION': # add the fills to the of the current frame frame_group = find_svg_elem(lineset_group, ".//svg:g[@id='frame_{:04n}']".format(scene.frame_current)) frame_group.insert(0, fill_group) else: lineset_group.insert(0, fill_group) # write SVG to file indent_xml(root) tree.write(self.filepath, encoding='ascii', xml_declaration=True) def stroke_inside_stroke(a, b): box_a = BoundingBox.from_sequence(svert.point for svert in a) box_b = BoundingBox.from_sequence(svert.point for svert in b) return box_a.inside(box_b) def diffuse_from_stroke(stroke, curvemat=CurveMaterialF0D()): material = curvemat(Interface0DIterator(stroke)) return material.diffuse # - Callbacks - # class ParameterEditorCallback(object): """Object to store callbacks for the Parameter Editor in""" def lineset_pre(self, scene, layer, lineset): raise NotImplementedError() def modifier_post(self, scene, layer, lineset): raise NotImplementedError() def lineset_post(self, scene, layer, lineset): raise NotImplementedError() class SVGPathShaderCallback(ParameterEditorCallback): @classmethod def poll(cls, scene, linestyle): return scene.render.use_freestyle and scene.svg_export.use_svg_export and linestyle.use_export_strokes @classmethod def modifier_post(cls, scene, layer, lineset): if not cls.poll(scene, lineset.linestyle): return [] split = scene.svg_export.split_at_invisible stroke_color_mode = lineset.linestyle.stroke_color_mode cls.shader = SVGPathShader.from_lineset( lineset, create_path(scene), render_height(scene), split, stroke_color_mode, scene.frame_current, name=layer.name + '_' + lineset.name) return [cls.shader] @classmethod def lineset_post(cls, scene, layer, lineset): if not cls.poll(scene, lineset.linestyle): return [] cls.shader.write() class SVGFillShaderCallback(ParameterEditorCallback): @classmethod def poll(cls, scene, linestyle): return scene.render.use_freestyle and scene.svg_export.use_svg_export and scene.svg_export.object_fill and linestyle.use_export_fills @classmethod def lineset_post(cls, scene, layer, lineset): if not cls.poll(scene, lineset.linestyle): return # reset the stroke selection (but don't delete the already generated strokes) Operators.reset(delete_strokes=False) # Unary Predicates: visible and correct edge nature upred = AndUP1D( QuantitativeInvisibilityUP1D(0), OrUP1D(ExternalContourUP1D(), pyNatureUP1D(Nature.BORDER)), ) # select the new edges Operators.select(upred) # Binary Predicates bpred = AndBP1D( MaterialBP1D(), NotBP1D(pyZDiscontinuityBP1D()), ) bpred = OrBP1D(bpred, AndBP1D(NotBP1D(bpred), AndBP1D(SameShapeIdBP1D(), MaterialBP1D()))) # chain the edges Operators.bidirectional_chain(ChainPredicateIterator(upred, bpred)) # export SVG collector = StrokeCollector() Operators.create(TrueUP1D(), [collector]) builder = SVGFillBuilder(create_path(scene), render_height(scene), layer.name + '_' + lineset.name) builder.write(collector.strokes) # make strokes used for filling invisible for stroke in collector.strokes: for svert in stroke: svert.attribute.visible = False def indent_xml(elem, level=0, indentsize=4): """Prettifies XML code (used in SVG exporter) """ i = "\n" + level * " " * indentsize if len(elem): if not elem.text or not elem.text.strip(): elem.text = i + " " * indentsize if not elem.tail or not elem.tail.strip(): elem.tail = i for elem in elem: indent_xml(elem, level + 1) if not elem.tail or not elem.tail.strip(): elem.tail = i elif level and (not elem.tail or not elem.tail.strip()): elem.tail = i def register_namespaces(namespaces=namespaces): for name, url in namespaces.items(): if name != 'svg': # creates invalid xml et.register_namespace(name, url) @persistent def handle_versions(self): # We don't modify startup file because it assumes to # have all the default values only. if not bpy.data.is_saved: return # Revision https://developer.blender.org/rBA861519e44adc5674545fa18202dc43c4c20f2d1d # changed the default for fills. # fix by Sergey https://developer.blender.org/T46150 if bpy.data.version <= (2, 76, 0): for linestyle in bpy.data.linestyles: linestyle.use_export_fills = True classes = ( SVGExporterPanel, SVGExporterLinesetPanel, SVGExport, ) def register(): linestyle = bpy.types.FreestyleLineStyle linestyle.use_export_strokes = BoolProperty( name="Export Strokes", description="Export strokes for this Line Style", default=True, ) linestyle.stroke_color_mode = EnumProperty( name="Stroke Color Mode", items=( ('BASE', "Base Color", "Use the linestyle's base color", 0), ('FIRST', "First Vertex", "Use the color of a stroke's first vertex", 1), ('FINAL', "Final Vertex", "Use the color of a stroke's final vertex", 2), ), default='BASE', ) linestyle.use_export_fills = BoolProperty( name="Export Fills", description="Export fills for this Line Style", default=False, ) for cls in classes: bpy.utils.register_class(cls) bpy.types.Scene.svg_export = PointerProperty(type=SVGExport) # add callbacks bpy.app.handlers.render_init.append(render_init) bpy.app.handlers.render_write.append(render_write) bpy.app.handlers.render_pre.append(svg_export_header) bpy.app.handlers.render_complete.append(svg_export_animation) # manipulate shaders list parameter_editor.callbacks_modifiers_post.append(SVGPathShaderCallback.modifier_post) parameter_editor.callbacks_lineset_post.append(SVGPathShaderCallback.lineset_post) parameter_editor.callbacks_lineset_post.append(SVGFillShaderCallback.lineset_post) # register namespaces register_namespaces() # handle regressions bpy.app.handlers.version_update.append(handle_versions) def unregister(): for cls in classes: bpy.utils.unregister_class(cls) del bpy.types.Scene.svg_export linestyle = bpy.types.FreestyleLineStyle del linestyle.use_export_strokes del linestyle.use_export_fills # remove callbacks bpy.app.handlers.render_init.remove(render_init) bpy.app.handlers.render_write.remove(render_write) bpy.app.handlers.render_pre.remove(svg_export_header) bpy.app.handlers.render_complete.remove(svg_export_animation) # manipulate shaders list parameter_editor.callbacks_modifiers_post.remove(SVGPathShaderCallback.modifier_post) parameter_editor.callbacks_lineset_post.remove(SVGPathShaderCallback.lineset_post) parameter_editor.callbacks_lineset_post.remove(SVGFillShaderCallback.lineset_post) bpy.app.handlers.version_update.remove(handle_versions) if __name__ == "__main__": register()