From 61a330baca0ff9bb3cf477c04f539ef276a0356f Mon Sep 17 00:00:00 2001 From: Tamito Kajiyama Date: Sat, 18 Oct 2014 18:35:29 +0900 Subject: Freestyle: Built-in SVG exporter. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Features: * Both still image and animation rendering, as well as polygon fills are supported. * The exporter creates a new SVG layer for every Freestyle line set. The different layers are correctly sorted. * SVG paths use data from line styles, so the base color of a line style becomes the color of paths, idem for dashes and stroke thickness. * Strokes can be split at invisible parts. This functionality is useful when exporting for instance dashed lines or line styles with a Blue Print shader * The exporter can be used not only in the Parameter Editor mode, but also from within style modules written for the Python Scripting mode. Acknowledgements: The author would like to thank Francesco Fantoni and Jarno Leppänen for their [[ https://github.com/hvfrancesco/freestylesvg | Freestyle SVG exporter ]]. Differential revision: https://developer.blender.org/D785 Author: flokkievids (Folkert de Vries) Reviewed by: kjym3 (Tamito Kajiyama) --- .../scripts/freestyle/modules/freestyle/utils.py | 14 + .../scripts/freestyle/modules/parameter_editor.py | 71 +++-- release/scripts/freestyle/modules/svg_export.py | 305 +++++++++++++++++++++ 3 files changed, 373 insertions(+), 17 deletions(-) create mode 100644 release/scripts/freestyle/modules/svg_export.py (limited to 'release/scripts/freestyle') diff --git a/release/scripts/freestyle/modules/freestyle/utils.py b/release/scripts/freestyle/modules/freestyle/utils.py index 6c5e1d5887a..a8e4743ae4b 100644 --- a/release/scripts/freestyle/modules/freestyle/utils.py +++ b/release/scripts/freestyle/modules/freestyle/utils.py @@ -86,6 +86,20 @@ def bounding_box(stroke): x, y = zip(*(svert.point for svert in stroke)) return (Vector((min(x), min(y))), Vector((max(x), max(y)))) +def get_dashed_pattern(linestyle): + """Extracts the dashed pattern from the various UI options """ + pattern = [] + if linestyle.dash1 > 0 and linestyle.gap1 > 0: + pattern.append(linestyle.dash1) + pattern.append(linestyle.gap1) + if linestyle.dash2 > 0 and linestyle.gap2 > 0: + pattern.append(linestyle.dash2) + pattern.append(linestyle.gap2) + if linestyle.dash3 > 0 and linestyle.gap3 > 0: + pattern.append(linestyle.dash3) + pattern.append(linestyle.gap3) + return pattern + # -- General helper functions -- # diff --git a/release/scripts/freestyle/modules/parameter_editor.py b/release/scripts/freestyle/modules/parameter_editor.py index 9ac5c665f1e..0498213e6f4 100644 --- a/release/scripts/freestyle/modules/parameter_editor.py +++ b/release/scripts/freestyle/modules/parameter_editor.py @@ -59,6 +59,7 @@ from freestyle.predicates import ( NotUP1D, OrUP1D, QuantitativeInvisibilityUP1D, + SameShapeIdBP1D, TrueBP1D, TrueUP1D, WithinImageBoundaryUP1D, @@ -97,7 +98,8 @@ from freestyle.utils import ( stroke_normal, bound, pairwise, - BoundedProperty + BoundedProperty, + get_dashed_pattern, ) from _freestyle import ( blendRamp, @@ -105,10 +107,19 @@ from _freestyle import ( evaluateCurveMappingF, ) +from svg_export import ( + SVGPathShader, + SVGFillShader, + ShapeZ, + ) + import time + from mathutils import Vector from math import pi, sin, cos, acos, radians from itertools import cycle, tee +from bpy.path import abspath +from os.path import isfile class ColorRampModifier(StrokeShader): @@ -419,7 +430,7 @@ class ColorMaterialShader(ColorRampModifier): for svert in it: material = self.func(it) if self.attribute == 'LINE': - b = material.line[0:3] + b = material.line[0:3] elif self.attribute == 'DIFF': b = material.diffuse[0:3] else: @@ -887,7 +898,6 @@ integration_types = { # main function for parameter processing - def process(layer_name, lineset_name): scene = getCurrentScene() layer = scene.render.layers[layer_name] @@ -1172,24 +1182,51 @@ def process(layer_name, lineset_name): has_tex = True if has_tex: shaders_list.append(StrokeTextureStepShader(linestyle.texture_spacing)) + # -- Dashed line -- # + if linestyle.use_dashed_line: + pattern = get_dashed_pattern(linestyle) + if len(pattern) > 0: + shaders_list.append(DashedLineShader(pattern)) + # -- SVG export -- # + render = scene.render + filepath = abspath(render.svg_path) + # if the export path is invalid: log to console, but continue normal rendering + if render.use_svg_export: + if not isfile(filepath): + print("Error: SVG export: path is invalid") + else: + height = render.resolution_y * render.resolution_percentage / 100 + split_at_inv = render.svg_split_at_invisible + frame_current = scene.frame_current + # SVGPathShader: keep reference and add to shader list + renderer = SVGPathShader.from_lineset(lineset, filepath, height, split_at_inv, frame_current) + shaders_list.append(renderer) + # -- Stroke caps -- # + # appended after svg shader to ensure correct svg output if linestyle.caps == 'ROUND': shaders_list.append(RoundCapShader()) elif linestyle.caps == 'SQUARE': shaders_list.append(SquareCapShader()) - # -- Dashed line -- # - if linestyle.use_dashed_line: - pattern = [] - if linestyle.dash1 > 0 and linestyle.gap1 > 0: - pattern.append(linestyle.dash1) - pattern.append(linestyle.gap1) - if linestyle.dash2 > 0 and linestyle.gap2 > 0: - pattern.append(linestyle.dash2) - pattern.append(linestyle.gap2) - if linestyle.dash3 > 0 and linestyle.gap3 > 0: - pattern.append(linestyle.dash3) - pattern.append(linestyle.gap3) - if len(pattern) > 0: - shaders_list.append(DashedLineShader(pattern)) + # create strokes using the shaders list Operators.create(TrueUP1D(), shaders_list) + + if render.use_svg_export and isfile(filepath): + # write svg output to file + renderer.write() + if render.svg_use_object_fill: + # reset the stroke selection (but don't delete the already generated ones) + Operators.reset(delete_strokes=False) + # shape detection + upred = AndUP1D(QuantitativeInvisibilityUP1D(0), ContourUP1D()) + Operators.select(upred) + # chain when the same shape and visible + bpred = SameShapeIdBP1D() + Operators.bidirectional_chain(ChainPredicateIterator(upred, bpred), NotUP1D(QuantitativeInvisibilityUP1D(0))) + # sort according to the distance from camera + Operators.sort(ShapeZ(scene)) + # render and write fills + renderer = SVGFillShader(filepath, height, lineset.name) + Operators.create(TrueUP1D(), [renderer,]) + renderer.write() diff --git a/release/scripts/freestyle/modules/svg_export.py b/release/scripts/freestyle/modules/svg_export.py new file mode 100644 index 00000000000..09566730891 --- /dev/null +++ b/release/scripts/freestyle/modules/svg_export.py @@ -0,0 +1,305 @@ +import bpy +import xml.etree.cElementTree as et + +from bpy.path import abspath +from bpy.app.handlers import persistent +from bpy_extras.object_utils import world_to_camera_view + +from freestyle.types import StrokeShader, ChainingIterator, BinaryPredicate1D, Interface0DIterator, AdjacencyIterator +from freestyle.utils import getCurrentScene, get_dashed_pattern, get_test_stroke +from freestyle.functions import GetShapeF1D, CurveMaterialF0D + +from itertools import dropwhile, repeat +from collections import OrderedDict + +__all__ = ( + "SVGPathShader", + "SVGFillShader", + "ShapeZ", + "indent_xml", + "svg_export_header", + "svg_export_animation", + ) + +# register namespaces +et.register_namespace("", "http://www.w3.org/2000/svg") +et.register_namespace("inkscape", "http://www.inkscape.org/namespaces/inkscape") +et.register_namespace("sodipodi", "http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd") + + +# 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", + } + +# - SVG export - # +class SVGPathShader(StrokeShader): + """Stroke Shader for writing stroke data to a .svg file.""" + def __init__(self, name, style, filepath, res_y, split_at_invisible, 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 + # put style attributes into a single svg path definition + self.path = '\n' + path + # fast-forward till the next visible vertex + it = 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.path, self.h, self.split_at_invisible)).split("\n") + # convert to actual XML, check to prevent empty paths + self.elements.extend(et.XML(elem) for elem in stroke_to_paths 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 + + # make for lineset as a whole (don't overwrite) + lineset_group = tree.find(".//svg:g[@id='{}']".format(name), namespaces=namespaces) + 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.insert(0, lineset_group) + + # make for the current frame + id = "{}_frame_{:06n}".format(name, self.frame_current) + frame_group = et.XML("") + frame_group.attrib = {'id': id, 'inkscape:groupmode': 'frame', 'inkscape:label': id} + frame_group.extend(self.elements) + lineset_group.append(frame_group) + + # write SVG to file + indent_xml(root) + tree.write(self.filepath, encoding='UTF-16', xml_declaration=True) + +# - Fill export - # +class ShapeZ(BinaryPredicate1D): + """Sort ViewShapes by their z-index""" + def __init__(self, scene): + BinaryPredicate1D.__init__(self) + self.z_map = dict() + self.scene = scene + + def __call__(self, i1, i2): + return self.get_z_curve(i1) < self.get_z_curve(i2) + + def get_z_curve(self, curve, func=GetShapeF1D()): + shape = func(curve)[0] + # get the shapes z-index + z = self.z_map.get(shape.id.first) + if z is None: + o = bpy.data.objects[shape.name] + z = world_to_camera_view(self.scene, self.scene.camera, o.location).z + self.z_map[shape.id.first] = z + return z + + +class SVGFillShader(StrokeShader): + """Creates SVG fills from the current stroke set""" + def __init__(self, filepath, height, name): + StrokeShader.__init__(self) + # use an ordered dict to maintain input and z-order + self.shape_map = OrderedDict() + self.filepath = filepath + self.h = height + self._name = name + + def shade(self, stroke, func=GetShapeF1D(), curvemat=CurveMaterialF0D()): + shape = func(stroke)[0] + shape = shape.id.first + item = self.shape_map.get(shape) + if len(stroke) > 2: + if item is not None: + item[0].append(stroke) + else: + # the shape is not yet present, let's create it. + material = curvemat(Interface0DIterator(stroke)) + *color, alpha = material.diffuse + self.shape_map[shape] = ([stroke], color, alpha) + # make the strokes of the second drawing invisible + for v in stroke: + v.attribute.visible = False + + @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 + + def write(self): + """Write SVG data tree to file """ + # initialize SVG + tree = et.parse(self.filepath) + root = tree.getroot() + name = self._name + + # create XML elements from the acquired data + elems = [] + path = ' for lineset as a whole (don't overwrite) + lineset_group = tree.find(".//svg:g[@id='{}']".format(name), namespaces=namespaces) + 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.insert(0, lineset_group) + + # make for fills + frame_group = et.XML('') + frame_group.attrib = {'id': "layer_fills", 'inkscape:groupmode': 'fills', 'inkscape:label': 'fills'} + # reverse the elements so they are correctly ordered in the image + frame_group.extend(reversed(elems)) + lineset_group.insert(0, frame_group) + + # write SVG to file + indent_xml(root) + tree.write(self.filepath, encoding='UTF-16', xml_declaration=True) + + +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 + +# - callbacks - # +@persistent +def svg_export_header(scene): + render = scene.render + if not (render.use_freestyle and render.use_svg_export): + return + #create new file (overwrite existing) + width, height = render.resolution_x, render.resolution_y + scale = render.resolution_percentage / 100 + + try: + with open(abspath(render.svg_path), "w") as f: + f.write(svg_primitive.format(int(width * scale), int(height * scale))) + except: + # invalid path is properly handled in the parameter editor + print("SVG export: invalid path") + +@persistent +def svg_export_animation(scene): + """makes an animation of the exported SVG file """ + render = scene.render + if render.use_freestyle and render.use_svg_export and render.svg_mode == 'ANIMATION': + write_animation(abspath(render.svg_path), scene.frame_start, render.fps) + + +def write_animation(filepath, frame_begin, fps=25): + """Adds animate tags to the specified file.""" + tree = et.parse(filepath) + root = tree.getroot() + + linesets = tree.findall(".//svg:g[@inkscape:groupmode='lineset']", namespaces=namespaces) + for i, lineset in enumerate(linesets): + name = lineset.get('id') + frames = lineset.findall(".//svg:g[@inkscape:groupmode='frame']", namespaces=namespaces) + fills = lineset.findall(".//svg:g[@inkscape:groupmode='fills']", namespaces=namespaces) + fills = reversed(fills) if fills else repeat(None, len(frames)) + + 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': str(n_of_frames / fps) + 's', + } + + for j, (frame, fill) in enumerate(zip(frames, fills)): + 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) + # append the animation to the associated fill as well (if valid) + if fill is not None: + fill.append(frame_anim) + + # write SVG to file + indent_xml(root) + tree.write(filepath, encoding='UTF-16', xml_declaration=True) -- cgit v1.2.3