Grease Pencil: Export other curve types to SVG

This adds support for bézier, NURBS and catmull rom curve
types in SVG exporting.

Note: strokes without uniform width will still be exported as
polylines. This is because the outline code currently does not
support bézier curves.

Pull Request: https://projects.blender.org/blender/blender/pulls/141594
This commit is contained in:
Casey Bianco-Davis
2025-08-14 13:51:27 +02:00
committed by Falk David
parent 8c3e81bd9b
commit b056d3c85c
4 changed files with 130 additions and 43 deletions

View File

@@ -37,6 +37,7 @@
#include "grease_pencil_io_intern.hh"
#include <fmt/format.h>
#include <numeric>
#include <optional>
@@ -390,6 +391,9 @@ void GreasePencilExporter::foreach_stroke_in_layer(const Object &object,
"end_cap", bke::AttrDomain::Curve, 0);
/* Point attributes. */
const Span<float3> positions = curves.positions();
const Span<float3> positions_left = curves.handle_positions_left();
const Span<float3> positions_right = curves.handle_positions_right();
const VArray<int8_t> types = curves.curve_types();
const VArraySpan<float> radii = drawing.radii();
const VArraySpan<float> opacities = drawing.opacities();
const VArraySpan<ColorGeometry4f> vertex_colors = drawing.vertex_colors();
@@ -403,6 +407,7 @@ void GreasePencilExporter::foreach_stroke_in_layer(const Object &object,
for (const int i_curve : curves.curves_range()) {
const IndexRange points = points_by_curve[i_curve];
const int8_t type = types[i_curve];
if (points.size() < 2) {
continue;
}
@@ -432,7 +437,10 @@ void GreasePencilExporter::foreach_stroke_in_layer(const Object &object,
const ColorGeometry4f fill_color = math::interpolate(
material_fill_color, fill_colors[i_curve], fill_colors[i_curve].a);
stroke_fn(positions.slice(points),
positions_left.slice_safe(points),
positions_right.slice_safe(points),
is_cyclic,
type,
fill_color,
layer.opacity,
std::nullopt,
@@ -459,7 +467,10 @@ void GreasePencilExporter::foreach_stroke_in_layer(const Object &object,
end_cap == GP_STROKE_CAP_TYPE_ROUND;
stroke_fn(positions.slice(points),
positions_left.slice_safe(points),
positions_right.slice_safe(points),
is_cyclic,
type,
stroke_color,
stroke_opacity,
uniform_width,
@@ -490,12 +501,17 @@ void GreasePencilExporter::foreach_stroke_in_layer(const Object &object,
const OffsetIndices outline_points_by_curve = outline.points_by_curve();
const Span<float3> outline_positions = outline.positions();
const Span<float3> outline_positions_left = curves.handle_positions_left();
const Span<float3> outline_positions_right = curves.handle_positions_right();
for (const int i_outline_curve : outline.curves_range()) {
const IndexRange outline_points = outline_points_by_curve[i_outline_curve];
/* Use stroke color to fill the outline. */
stroke_fn(outline_positions.slice(outline_points),
outline_positions_left.slice_safe(outline_points),
outline_positions_right.slice_safe(outline_points),
true,
type,
stroke_color,
stroke_opacity,
std::nullopt,
@@ -547,4 +563,15 @@ bool GreasePencilExporter::is_selected_frame(const GreasePencil &grease_pencil,
return false;
}
std::string GreasePencilExporter::coord_to_svg_string(const float2 &screen_co) const
{
/* SVG has inverted Y axis. */
if (camera_persmat_) {
return fmt::format("{},{}", screen_co.x, camera_rect_.size().y - screen_co.y);
}
else {
return fmt::format("{},{}", screen_co.x, screen_rect_.size().y - screen_co.y);
}
}
} // namespace blender::io::grease_pencil

View File

@@ -144,7 +144,10 @@ void PDFExporter::export_grease_pencil_layer(const Object &object,
const float4x4 layer_to_world = layer.to_world_space(object);
auto write_stroke = [&](const Span<float3> positions,
const Span<float3> /*positions_left*/,
const Span<float3> /*positions_right*/,
const bool cyclic,
const int8_t /*type*/,
const ColorGeometry4f &color,
const float opacity,
const std::optional<float> width,

View File

@@ -17,6 +17,7 @@
#include "DEG_depsgraph_query.hh"
#include "GEO_resample_curves.hh"
#include "GEO_set_curve_type.hh"
#include "grease_pencil_io_intern.hh"
@@ -129,6 +130,12 @@ class SVGExporter : public GreasePencilExporter {
const float4x4 &transform,
Span<float3> positions,
bool cyclic);
pugi::xml_node write_bezier_path(pugi::xml_node node,
const float4x4 &transform,
Span<float3> positions,
Span<float3> positions_left,
Span<float3> positions_right,
bool cyclic);
bool write_to_file(StringRefNull filepath);
};
@@ -272,20 +279,22 @@ void SVGExporter::export_grease_pencil_objects(pugi::xml_node node, const int fr
layer_node.append_attribute("id").set_value(layer_node_id.c_str());
const bke::CurvesGeometry &curves = drawing->strokes();
/* TODO: Instead of converting all the other curve types to poly curves, export them directly
* as curve paths to the SVG. */
if (curves.has_curve_with_type(
{CURVE_TYPE_CATMULL_ROM, CURVE_TYPE_BEZIER, CURVE_TYPE_NURBS}))
{
/* Convert NURBS and Catmull Rom to bezier then export. */
if (curves.has_curve_with_type({CURVE_TYPE_CATMULL_ROM, CURVE_TYPE_NURBS})) {
IndexMaskMemory memory;
const IndexMask non_poly_selection = curves.indices_for_curve_type(CURVE_TYPE_POLY, memory)
.complement(curves.curves_range(), memory);
Drawing export_drawing;
export_drawing.strokes_for_write() = geometry::resample_to_evaluated(curves,
non_poly_selection);
export_drawing.tag_topology_changed();
geometry::ConvertCurvesOptions options;
options.convert_bezier_handles_to_poly_points = false;
options.convert_bezier_handles_to_catmull_rom_points = false;
options.keep_bezier_shape_as_nurbs = true;
options.keep_catmull_rom_shape_as_nurbs = true;
Drawing export_drawing;
export_drawing.strokes_for_write() = geometry::convert_curves(
curves, non_poly_selection, CURVE_TYPE_BEZIER, {}, options);
export_drawing.tag_topology_changed();
export_grease_pencil_layer(layer_node, *ob_eval, *layer, export_drawing);
}
else {
@@ -305,7 +314,10 @@ void SVGExporter::export_grease_pencil_layer(pugi::xml_node layer_node,
const float4x4 layer_to_world = layer.to_world_space(object);
auto write_stroke = [&](const Span<float3> positions,
const Span<float3> positions_left,
const Span<float3> positions_right,
const bool cyclic,
const int8_t type,
const ColorGeometry4f &color,
const float opacity,
const std::optional<float> width,
@@ -316,10 +328,16 @@ void SVGExporter::export_grease_pencil_layer(pugi::xml_node layer_node,
write_fill_color_attribute(element_node, color, opacity);
}
else {
/* Fill is always exported as polygon because the stroke of the fill is done
* in a different SVG command. */
pugi::xml_node element_node = write_polyline(
layer_node, layer_to_world, positions, cyclic, width);
pugi::xml_node element_node;
if (type == CURVE_TYPE_BEZIER) {
element_node = write_bezier_path(
layer_node, layer_to_world, positions, positions_left, positions_right, cyclic);
}
else {
/* Fill is always exported as polygon because the stroke of the fill is done
* in a different SVG command. */
element_node = write_polyline(layer_node, layer_to_world, positions, cyclic, width);
}
if (width) {
write_stroke_color_attribute(element_node, color, opacity, round_cap);
@@ -417,19 +435,13 @@ pugi::xml_node SVGExporter::write_polygon(pugi::xml_node node,
std::string txt;
for (const int i : positions.index_range()) {
const float2 screen_co = this->project_to_screen(transform, positions[i]);
if (i > 0) {
txt.append(" ");
}
/* SVG has inverted Y axis. */
const float2 screen_co = this->project_to_screen(transform, positions[i]);
if (camera_persmat_) {
txt.append(std::to_string(screen_co.x) + "," +
std::to_string(camera_rect_.size().y - screen_co.y));
}
else {
txt.append(std::to_string(screen_co.x) + "," +
std::to_string(screen_rect_.size().y - screen_co.y));
}
txt.append(coord_to_svg_string(screen_co));
}
element_node.append_attribute("points").set_value(txt.c_str());
@@ -451,19 +463,13 @@ pugi::xml_node SVGExporter::write_polyline(pugi::xml_node node,
std::string txt;
for (const int i : positions.index_range()) {
const float2 screen_co = this->project_to_screen(transform, positions[i]);
if (i > 0) {
txt.append(" ");
}
/* SVG has inverted Y axis. */
const float2 screen_co = this->project_to_screen(transform, positions[i]);
if (camera_persmat_) {
txt.append(std::to_string(screen_co.x) + "," +
std::to_string(camera_rect_.size().y - screen_co.y));
}
else {
txt.append(std::to_string(screen_co.x) + "," +
std::to_string(screen_rect_.size().y - screen_co.y));
}
txt.append(coord_to_svg_string(screen_co));
}
element_node.append_attribute("points").set_value(txt.c_str());
@@ -480,19 +486,13 @@ pugi::xml_node SVGExporter::write_path(pugi::xml_node node,
std::string txt = "M";
for (const int i : positions.index_range()) {
const float2 screen_co = this->project_to_screen(transform, positions[i]);
if (i > 0) {
txt.append("L");
}
const float2 screen_co = this->project_to_screen(transform, positions[i]);
/* SVG has inverted Y axis. */
if (camera_persmat_) {
txt.append(std::to_string(screen_co.x) + "," +
std::to_string(camera_rect_.size().y - screen_co.y));
}
else {
txt.append(std::to_string(screen_co.x) + "," +
std::to_string(screen_rect_.size().y - screen_co.y));
}
txt.append(coord_to_svg_string(screen_co));
}
/* Close patch (cyclic). */
if (cyclic) {
@@ -504,6 +504,58 @@ pugi::xml_node SVGExporter::write_path(pugi::xml_node node,
return element_node;
}
pugi::xml_node SVGExporter::write_bezier_path(pugi::xml_node node,
const float4x4 &transform,
const Span<float3> positions,
const Span<float3> positions_left,
const Span<float3> positions_right,
const bool cyclic)
{
pugi::xml_node element_node = node.append_child("path");
std::string txt = "M";
for (const int i : positions.index_range().drop_back(1)) {
const float2 screen_co = this->project_to_screen(transform, positions[i]);
const float2 screen_co_right = this->project_to_screen(transform, positions_right[i]);
const float2 screen_co_left = this->project_to_screen(transform, positions_left[i + 1]);
txt.append(coord_to_svg_string(screen_co));
txt.append(" C ");
txt.append(coord_to_svg_string(screen_co_right));
txt.append(", ");
txt.append(coord_to_svg_string(screen_co_left));
if (i != positions.size() - 2) {
txt.append(", ");
}
}
{
txt.append(", ");
const float2 screen_co = this->project_to_screen(transform, positions.last());
txt.append(coord_to_svg_string(screen_co));
}
/* Close patch (cyclic). */
if (cyclic) {
const float2 screen_co_right = this->project_to_screen(transform, positions_right.last());
const float2 screen_co_left = this->project_to_screen(transform, positions_left.first());
const float2 screen_co = this->project_to_screen(transform, positions.first());
txt.append(" C ");
txt.append(coord_to_svg_string(screen_co_right));
txt.append(", ");
txt.append(coord_to_svg_string(screen_co_left));
txt.append(", ");
txt.append(coord_to_svg_string(screen_co));
txt.append("z");
}
element_node.append_attribute("d").set_value(txt.c_str());
return element_node;
}
bool SVGExporter::write_to_file(StringRefNull filepath)
{
bool result = true;

View File

@@ -79,7 +79,10 @@ class GreasePencilExporter {
Vector<ObjectInfo> retrieve_objects() const;
using WriteStrokeFn = FunctionRef<void(const Span<float3> positions,
const Span<float3> positions_left,
const Span<float3> positions_right,
bool cyclic,
int8_t type,
const ColorGeometry4f &color,
float opacity,
std::optional<float> width,
@@ -95,6 +98,8 @@ class GreasePencilExporter {
bool is_selected_frame(const GreasePencil &grease_pencil, int frame_number) const;
std::string coord_to_svg_string(const float2 &screen_co) const;
private:
std::optional<Bounds<float2>> compute_screen_space_drawing_bounds(
const RegionView3D &rv3d,