772 lines
26 KiB
Python
772 lines
26 KiB
Python
# 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 version="1.0" encoding="ascii" standalone="no"?>
|
|
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN"
|
|
"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
|
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" width="{:d}" height="{:d}">
|
|
</svg>"""
|
|
|
|
|
|
# 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('<animate id="{}" begin="{:.3f}s" />'.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 ' + "".join('{}="{}" '.format(k, v) for k, v in style.items()) + 'd=" M '
|
|
|
|
it = iter(stroke)
|
|
# start first path
|
|
yield path
|
|
for v in it:
|
|
x, y = v.point
|
|
yield '{:.3f}, {:.3f} '.format(x, height - y)
|
|
if split_at_invisible and v.attribute.visible is False:
|
|
# end current and start new path;
|
|
yield '" />' + 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 <g> 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('<g/>')
|
|
lineset_group.attrib = {
|
|
'id': name,
|
|
'xmlns:inkscape': namespaces["inkscape"],
|
|
'inkscape:groupmode': 'lineset',
|
|
'inkscape:label': name,
|
|
}
|
|
root.append(lineset_group)
|
|
|
|
# create <g> for the current frame
|
|
id = "frame_{:04n}".format(self.frame_current)
|
|
|
|
stroke_group = et.XML("<g/>")
|
|
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("<g/>")
|
|
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 = '<path {} d=" M '.format(param_str)
|
|
vertices = (svert.point for svert in stroke)
|
|
s = "".join(self.pathgen(vertices, path, height))
|
|
result = et.XML(s)
|
|
return result
|
|
|
|
def create_fill_elements(self, strokes):
|
|
"""Creates ElementTree objects by merging stroke objects together and turning them into SVG paths."""
|
|
merged_strokes = self.get_merged_strokes(strokes)
|
|
for k, v in merged_strokes.items():
|
|
base = self.stroke_to_fill(k)
|
|
fills = (self.stroke_to_fill(stroke).get("d") for stroke in v)
|
|
merged_points = " ".join(fills)
|
|
base.attrib['d'] += merged_points
|
|
yield base
|
|
|
|
def write(self, strokes):
|
|
"""Write SVG data tree to file """
|
|
|
|
tree = et.parse(self.filepath)
|
|
root = tree.getroot()
|
|
scene = bpy.context.scene
|
|
name = self._name
|
|
|
|
lineset_group = find_svg_elem(tree, ".//svg:g[@id='{}']".format(self._name))
|
|
if lineset_group is None:
|
|
lineset_group = et.XML('<g/>')
|
|
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)
|
|
|
|
|
|
# <g> for the fills of the current frame
|
|
fill_group = et.XML('<g/>')
|
|
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 <g> 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()
|