Freestyle SVG Exporter: more robust filling

This commit is contained in:
Folkert de Vries 2015-05-31 16:46:21 +02:00
parent 83b911f409
commit 1aaf1d7b76
1 changed files with 171 additions and 78 deletions

View File

@ -37,21 +37,47 @@ 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
from freestyle.functions import GetShapeF1D, CurveMaterialF0D
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,
SameShapeIdBP1D,
ExternalContourUP1D,
MaterialBP1D,
NotBP1D,
NotUP1D,
QuantitativeInvisibilityUP1D,
TrueUP1D,
OrBP1D,
OrUP1D,
pyNatureUP1D,
pyZBP1D,
pyZDiscontinuityBP1D,
QuantitativeInvisibilityUP1D,
SameShapeIdBP1D,
TrueBP1D,
TrueUP1D,
)
from freestyle.chainingiterators import ChainPredicateIterator
from parameter_editor import get_dashed_pattern
@ -61,14 +87,12 @@ from bpy.props import (
EnumProperty,
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
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>"""
@ -77,8 +101,11 @@ svg_primitive = """<?xml version="1.0" encoding="ascii" standalone="no"?>
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:
@ -98,6 +125,7 @@ def render_width(scene):
# 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
@ -288,6 +316,7 @@ class SVGPathShader(StrokeShader):
# return instance
return cls(name, style, filepath, res_y, split_at_invisible, frame_current)
@staticmethod
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 """
@ -340,10 +369,12 @@ class SVGPathShader(StrokeShader):
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'}
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':
@ -360,30 +391,11 @@ class SVGPathShader(StrokeShader):
tree.write(self.filepath, encoding='ascii', xml_declaration=True)
class SVGFillShader(StrokeShader):
"""Creates SVG fills from the current stroke set"""
class SVGFillBuilder:
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].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
self.stroke_to_fill = partial(self.stroke_to_svg, height=height)
@staticmethod
def pathgen(vertices, path, height):
@ -391,48 +403,94 @@ class SVGFillShader(StrokeShader):
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
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 """
# initialize SVG
tree = et.parse(self.filepath)
root = tree.getroot()
name = self._name
scene = bpy.context.scene
lineset_group = find_svg_elem(tree, ".//svg:g[@id='{}']".format(name))
# create XML elements from the acquired data
elems = []
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")
lineset_group = find_svg_elem(tree, ".//svg:g[@id='{}']".format(self._name))
if lineset_group is None:
print("searched for {}, but could not find a <g> with that id".format(self._name))
return
# <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'
'inkscape:groupmode': 'layer',
'inkscape:label': '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':
# 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:
# 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)
# write SVG to file
@ -440,6 +498,16 @@ class SVGFillShader(StrokeShader):
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"""
@ -452,11 +520,19 @@ class ParameterEditorCallback(object):
def lineset_post(self, scene, layer, lineset):
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):
@classmethod
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 []
split = scene.svg_export.split_at_invisible
@ -467,9 +543,8 @@ class SVGPathShaderCallback(ParameterEditorCallback):
@classmethod
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
cls.shader.write()
@ -481,19 +556,33 @@ class SVGFillShaderCallback(ParameterEditorCallback):
# reset the stroke selection (but don't delete the already generated strokes)
Operators.reset(delete_strokes=False)
# shape detection
upred = AndUP1D(QuantitativeInvisibilityUP1D(0), ContourUP1D())
# Unary Predicates: visible and correct edge nature
upred = AndUP1D(
QuantitativeInvisibilityUP1D(0),
OrUP1D(ExternalContourUP1D(),
pyNatureUP1D(Nature.BORDER)),
)
# select the new edges
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(pyZBP1D())
# render and write fills
shader = SVGFillShader(create_path(scene), render_height(scene), layer.name + '_' + lineset.name)
Operators.create(TrueUP1D(), [shader, ])
# 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
shader.write()
def indent_xml(elem, level=0, indentsize=4):
@ -512,6 +601,12 @@ def indent_xml(elem, level=0, indentsize=4):
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 = (
SVGExporterPanel,
SVGExport,
@ -536,9 +631,7 @@ def register():
parameter_editor.callbacks_lineset_post.append(SVGFillShaderCallback.lineset_post)
# 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")
register_namespaces()
def unregister():