Skip to content
GitLab
Explore
Sign in
Primary navigation
Search or go to…
Project
B
blender-addons
Manage
Activity
Members
Labels
Plan
Issues
Issue boards
Milestones
Wiki
Code
Merge requests
Repository
Branches
Commits
Tags
Repository graph
Compare revisions
Snippets
Build
Pipelines
Jobs
Pipeline schedules
Artifacts
Deploy
Releases
Container registry
Model registry
Operate
Environments
Monitor
Incidents
Analyze
Value stream analytics
Contributor analytics
CI/CD analytics
Repository analytics
Model experiments
Help
Help
Support
GitLab documentation
Compare GitLab plans
Community forum
Contribute to GitLab
Provide feedback
Keyboard shortcuts
?
Snippets
Groups
Projects
Show more breadcrumbs
blender
blender-addons
Commits
1aaf1d7b
Commit
1aaf1d7b
authored
9 years ago
by
Folkert de Vries
Browse files
Options
Downloads
Patches
Plain Diff
Freestyle SVG Exporter: more robust filling
parent
83b911f4
No related branches found
No related tags found
No related merge requests found
Changes
1
Hide whitespace changes
Inline
Side-by-side
Showing
1 changed file
render_freestyle_svg.py
+170
-77
170 additions, 77 deletions
render_freestyle_svg.py
with
170 additions
and
77 deletions
render_freestyle_svg.py
+
170
−
77
View file @
1aaf1d7b
...
@@ -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
():
...
...
This diff is collapsed.
Click to expand it.
Preview
0%
Loading
Try again
or
attach a new file
.
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Save comment
Cancel
Please
register
or
sign in
to comment