Skip to content
Snippets Groups Projects
Commit 1aaf1d7b authored by Folkert de Vries's avatar Folkert de Vries
Browse files

Freestyle SVG Exporter: more robust filling

parent 83b911f4
No related branches found
No related tags found
No related merge requests found
...@@ -37,21 +37,47 @@ import os ...@@ -37,21 +37,47 @@ import os
import xml.etree.cElementTree as et 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 ( from freestyle.types import (
StrokeShader, StrokeShader,
Interface0DIterator, Interface0DIterator,
Operators, Operators,
Nature,
StrokeVertex,
) )
from freestyle.utils import getCurrentScene from freestyle.utils import (
from freestyle.functions import GetShapeF1D, CurveMaterialF0D getCurrentScene,
BoundingBox,
is_poly_clockwise,
StrokeCollector,
material_from_fedge,
get_object_name,
)
from freestyle.functions import (
GetShapeF1D,
CurveMaterialF0D,
)
from freestyle.predicates import ( from freestyle.predicates import (
AndBP1D,
AndUP1D, AndUP1D,
ContourUP1D, ContourUP1D,
SameShapeIdBP1D, ExternalContourUP1D,
MaterialBP1D,
NotBP1D,
NotUP1D, NotUP1D,
OrBP1D,
OrUP1D,
pyNatureUP1D,
pyZBP1D,
pyZDiscontinuityBP1D,
QuantitativeInvisibilityUP1D, QuantitativeInvisibilityUP1D,
SameShapeIdBP1D,
TrueBP1D,
TrueUP1D, TrueUP1D,
pyZBP1D,
) )
from freestyle.chainingiterators import ChainPredicateIterator from freestyle.chainingiterators import ChainPredicateIterator
from parameter_editor import get_dashed_pattern from parameter_editor import get_dashed_pattern
...@@ -61,14 +87,12 @@ from bpy.props import ( ...@@ -61,14 +87,12 @@ from bpy.props import (
EnumProperty, EnumProperty,
PointerProperty, PointerProperty,
) )
from bpy.app.handlers import persistent
from collections import OrderedDict
from functools import partial
from mathutils import Vector
# use utf-8 here to keep ElementTree happy, end result is utf-16 # use utf-8 here to keep ElementTree happy, end result is utf-16
svg_primitive = """<?xml version="1.0" encoding="ascii" standalone="no"?> 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 xmlns="http://www.w3.org/2000/svg" version="1.1" width="{:d}" height="{:d}">
</svg>""" </svg>"""
...@@ -77,8 +101,11 @@ svg_primitive = """<?xml version="1.0" encoding="ascii" standalone="no"?> ...@@ -77,8 +101,11 @@ svg_primitive = """<?xml version="1.0" encoding="ascii" standalone="no"?>
namespaces = { namespaces = {
"inkscape": "http://www.inkscape.org/namespaces/inkscape", "inkscape": "http://www.inkscape.org/namespaces/inkscape",
"svg": "http://www.w3.org/2000/svg", "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 # wrap XMLElem.find, so the namespaces don't need to be given as an argument
def find_xml_elem(obj, search, namespaces, *, all=False): def find_xml_elem(obj, search, namespaces, *, all=False):
if all: if all:
...@@ -98,6 +125,7 @@ def render_width(scene): ...@@ -98,6 +125,7 @@ def render_width(scene):
# stores the state of the render, used to differ between animation and single frame renders. # stores the state of the render, used to differ between animation and single frame renders.
class RenderState: class RenderState:
# Note that this flag is set to False only after the first frame # Note that this flag is set to False only after the first frame
# has been written to file. # has been written to file.
is_preview = True is_preview = True
...@@ -288,6 +316,7 @@ class SVGPathShader(StrokeShader): ...@@ -288,6 +316,7 @@ class SVGPathShader(StrokeShader):
# return instance # return instance
return cls(name, style, filepath, res_y, split_at_invisible, frame_current) return cls(name, style, filepath, res_y, split_at_invisible, frame_current)
@staticmethod @staticmethod
def pathgen(stroke, path, height, split_at_invisible, f=lambda v: not v.attribute.visible): def pathgen(stroke, path, height, split_at_invisible, f=lambda v: not v.attribute.visible):
"""Generator that creates SVG paths (as strings) from the current stroke """ """Generator that creates SVG paths (as strings) from the current stroke """
...@@ -340,10 +369,12 @@ class SVGPathShader(StrokeShader): ...@@ -340,10 +369,12 @@ class SVGPathShader(StrokeShader):
id = "frame_{:04n}".format(self.frame_current) id = "frame_{:04n}".format(self.frame_current)
stroke_group = et.XML("<g/>") stroke_group = et.XML("<g/>")
stroke_group.attrib = {'xmlns:inkscape': namespaces["inkscape"], stroke_group.attrib = {
'inkscape:groupmode': 'layer', 'xmlns:inkscape': namespaces["inkscape"],
'id': 'strokes', 'inkscape:groupmode': 'layer',
'inkscape:label': 'strokes'} 'id': 'strokes',
'inkscape:label': 'strokes'
}
# nest the structure # nest the structure
stroke_group.extend(self.elements) stroke_group.extend(self.elements)
if scene.svg_export.mode == 'ANIMATION': if scene.svg_export.mode == 'ANIMATION':
...@@ -360,30 +391,11 @@ class SVGPathShader(StrokeShader): ...@@ -360,30 +391,11 @@ class SVGPathShader(StrokeShader):
tree.write(self.filepath, encoding='ascii', xml_declaration=True) tree.write(self.filepath, encoding='ascii', xml_declaration=True)
class SVGFillShader(StrokeShader): class SVGFillBuilder:
"""Creates SVG fills from the current stroke set"""
def __init__(self, filepath, height, name): 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.filepath = filepath
self.h = height
self._name = name self._name = name
self.stroke_to_fill = partial(self.stroke_to_svg, height=height)
def shade(self, stroke, func=GetShapeF1D(), curvemat=CurveMaterialF0D()):
shape = func(stroke)[0].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 @staticmethod
def pathgen(vertices, path, height): def pathgen(vertices, path, height):
...@@ -391,48 +403,94 @@ class SVGFillShader(StrokeShader): ...@@ -391,48 +403,94 @@ class SVGFillShader(StrokeShader):
for point in vertices: for point in vertices:
x, y = point x, y = point
yield '{:.3f}, {:.3f} '.format(x, height - y) yield '{:.3f}, {:.3f} '.format(x, height - y)
yield 'z" />' # closes the path; connects the current to the first point yield ' z" />' # closes the path; connects the current to the first point
def write(self):
@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 """ """Write SVG data tree to file """
# initialize SVG
tree = et.parse(self.filepath) tree = et.parse(self.filepath)
root = tree.getroot() root = tree.getroot()
name = self._name
scene = bpy.context.scene scene = bpy.context.scene
lineset_group = find_svg_elem(tree, ".//svg:g[@id='{}']".format(name)) lineset_group = find_svg_elem(tree, ".//svg:g[@id='{}']".format(self._name))
if lineset_group is None:
# create XML elements from the acquired data print("searched for {}, but could not find a <g> with that id".format(self._name))
elems = [] return
path = '<path fill-rule="evenodd" stroke="none" fill-opacity="{}" fill="rgb({}, {}, {})" d=" M '
for strokes, col, alpha in self.shape_map.values():
p = path.format(alpha, *(int(255 * c) for c in col))
for stroke in strokes:
elems.append(et.XML("".join(self.pathgen((sv.point for sv in stroke), p, self.h))))
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))
if frame_group is None:
# something has gone very wrong
raise RuntimeError("SVGFillShader: frame_group is None")
# <g> for the fills of the current frame # <g> for the fills of the current frame
fill_group = et.XML('<g/>') fill_group = et.XML('<g/>')
fill_group.attrib = { fill_group.attrib = {
'xmlns:inkscape': namespaces["inkscape"], 'xmlns:inkscape': namespaces["inkscape"],
'inkscape:groupmode': 'layer', 'inkscape:groupmode': 'layer',
'inkscape:label': 'fills', 'inkscape:label': 'fills',
'id': 'fills' 'id': 'fills'
} }
fill_group.extend(reversed(elems)) fill_elements = self.create_fill_elements(strokes)
fill_group.extend(reversed(tuple(fill_elements)))
if scene.svg_export.mode == 'ANIMATION': 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) frame_group.insert(0, fill_group)
else: else:
# get the current lineset group. if it's None we're in trouble, so may as well error hard.
lineset_group = tree.find(".//svg:g[@id='{}']".format(name), namespaces=namespaces)
lineset_group.insert(0, fill_group) lineset_group.insert(0, fill_group)
# write SVG to file # write SVG to file
...@@ -440,6 +498,16 @@ class SVGFillShader(StrokeShader): ...@@ -440,6 +498,16 @@ class SVGFillShader(StrokeShader):
tree.write(self.filepath, encoding='ascii', xml_declaration=True) 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 - # # - Callbacks - #
class ParameterEditorCallback(object): class ParameterEditorCallback(object):
"""Object to store callbacks for the Parameter Editor in""" """Object to store callbacks for the Parameter Editor in"""
...@@ -452,11 +520,19 @@ class ParameterEditorCallback(object): ...@@ -452,11 +520,19 @@ class ParameterEditorCallback(object):
def lineset_post(self, scene, layer, lineset): def lineset_post(self, scene, layer, lineset):
raise NotImplementedError() raise NotImplementedError()
@classmethod
def evaluate(cls, scene):
'Evaluates whether these callbacks should run'
return (
scene.render.use_freestyle
and scene.svg_export.use_svg_export
)
class SVGPathShaderCallback(ParameterEditorCallback): class SVGPathShaderCallback(ParameterEditorCallback):
@classmethod @classmethod
def modifier_post(cls, scene, layer, lineset): def modifier_post(cls, scene, layer, lineset):
if not (scene.render.use_freestyle and scene.svg_export.use_svg_export): if not cls.evaluate(scene):
return [] return []
split = scene.svg_export.split_at_invisible split = scene.svg_export.split_at_invisible
...@@ -467,9 +543,8 @@ class SVGPathShaderCallback(ParameterEditorCallback): ...@@ -467,9 +543,8 @@ class SVGPathShaderCallback(ParameterEditorCallback):
@classmethod @classmethod
def lineset_post(cls, scene, *args): def lineset_post(cls, scene, *args):
if not (scene.render.use_freestyle and scene.svg_export.use_svg_export): if not cls.evaluate(scene):
return return
cls.shader.write() cls.shader.write()
...@@ -481,19 +556,33 @@ class SVGFillShaderCallback(ParameterEditorCallback): ...@@ -481,19 +556,33 @@ class SVGFillShaderCallback(ParameterEditorCallback):
# reset the stroke selection (but don't delete the already generated strokes) # reset the stroke selection (but don't delete the already generated strokes)
Operators.reset(delete_strokes=False) Operators.reset(delete_strokes=False)
# shape detection # Unary Predicates: visible and correct edge nature
upred = AndUP1D(QuantitativeInvisibilityUP1D(0), ContourUP1D()) upred = AndUP1D(
QuantitativeInvisibilityUP1D(0),
OrUP1D(ExternalContourUP1D(),
pyNatureUP1D(Nature.BORDER)),
)
# select the new edges
Operators.select(upred) Operators.select(upred)
# chain when the same shape and visible # Binary Predicates
bpred = SameShapeIdBP1D() bpred = AndBP1D(
Operators.bidirectional_chain(ChainPredicateIterator(upred, bpred), NotUP1D(QuantitativeInvisibilityUP1D(0))) MaterialBP1D(),
# sort according to the distance from camera NotBP1D(pyZDiscontinuityBP1D()),
Operators.sort(pyZBP1D()) )
# render and write fills bpred = OrBP1D(bpred, AndBP1D(NotBP1D(bpred), AndBP1D(SameShapeIdBP1D(), MaterialBP1D())))
shader = SVGFillShader(create_path(scene), render_height(scene), layer.name + '_' + lineset.name) # chain the edges
Operators.create(TrueUP1D(), [shader, ]) 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
shader.write()
def indent_xml(elem, level=0, indentsize=4): def indent_xml(elem, level=0, indentsize=4):
...@@ -512,6 +601,12 @@ def indent_xml(elem, level=0, indentsize=4): ...@@ -512,6 +601,12 @@ def indent_xml(elem, level=0, indentsize=4):
elem.tail = i 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)
classes = ( classes = (
SVGExporterPanel, SVGExporterPanel,
SVGExport, SVGExport,
...@@ -536,9 +631,7 @@ def register(): ...@@ -536,9 +631,7 @@ def register():
parameter_editor.callbacks_lineset_post.append(SVGFillShaderCallback.lineset_post) parameter_editor.callbacks_lineset_post.append(SVGFillShaderCallback.lineset_post)
# register namespaces # register namespaces
et.register_namespace("", "http://www.w3.org/2000/svg") register_namespaces()
et.register_namespace("inkscape", "http://www.inkscape.org/namespaces/inkscape")
et.register_namespace("sodipodi", "http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd")
def unregister(): def unregister():
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please register or to comment