Grease Pencil: New Fill Tool Algorithm

Adds a new fill algorithm for the bucket fill tool to create
exact geometry. The new solver is called `Delaunay` in the `Advanced`
options panel. It is set as the new default.
The old pixel-based flood fill method is unchanged and accessible from
the `Advanced` menu.

The solver is an implementation of "Delaunay Painting" [1] with some
additions.
* Adds a new automatic segmentation using the maximum weight that an
  edge can have.
* Adds two new settings to the fill tool.
  * `Internal Gaps`: Enables checking for gaps in internal shapes. This
    enables the following option:
  * `Gap Factor` which is a scale invariant value from 0.0 to 1.0
    (defaulting to 0.4). It's the "sensitivity" of the gap detection.
    Higher values results in more gaps detected and as such can create
    smaller fills

Also, in practice this solver is often faster than the pixel fill.

[1] https://perso.telecom-paristech.fr/parakkat/PDF/CGF22.pdf

Co-authored-by: Falk David <falk@blender.org>
Pull Request: https://projects.blender.org/blender/blender/pulls/156545
This commit is contained in:
Casey Bianco-Davis 2026-05-28 18:11:41 +02:00 • committed by Falk David
parent d1766ab517
commit 3702f7e714
10 changed files with 1113 additions and 177 deletions

View file

@ -1845,12 +1845,20 @@ def brush_basic_grease_pencil_paint_settings(layout, context, brush, props, *, c
row.prop(gp_settings, "fill_direction", expand=True)
row = layout.row(align=True)
row.prop(gp_settings, "fill_factor")
row = layout.row(align=True)
row.prop(gp_settings, "dilate")
row = layout.row(align=True)
row.prop(brush, "size", text="Thickness")
layout.use_property_split = use_property_split_prev
if gp_settings.fill_solver == 'PIXEL':
row = layout.row(align=True)
row.prop(gp_settings, "fill_factor")
row = layout.row(align=True)
row.prop(gp_settings, "dilate")
row = layout.row(align=True)
row.prop(brush, "size", text="Thickness")
layout.use_property_split = use_property_split_prev
else:
size = "size"
if brush.use_locked_size == 'SCENE':
size = "unprojected_size"
row = layout.row(align=True)
row.prop(brush, size, slider=True, text="Size")
elif grease_pencil_brush_type == 'ERASE':
layout.prop(gp_settings, "eraser_mode", expand=True)
layout.prop(gp_settings, "use_active_layer_only")

View file

@ -1924,6 +1924,14 @@ class VIEW3D_PT_tools_grease_pencil_v3_brush_fill_advanced(View3DPanel, Panel):
if brush is None:
return
col.prop(gp_settings, "fill_solver")
col.separator()
if gp_settings.fill_solver == 'DELAUNAY':
row = col.row(align=True)
row.prop(brush, "use_locked_size", expand=True)
col.separator()
row = col.row(align=True)
row.prop(gp_settings, "fill_draw_mode", text="Boundary", text_ctxt=i18n_contexts.id_gpencil)
row.prop(
@ -1937,8 +1945,9 @@ class VIEW3D_PT_tools_grease_pencil_v3_brush_fill_advanced(View3DPanel, Panel):
row = col.row(align=True)
row.prop(gp_settings, "fill_layer_mode", text="Layers")
col.separator()
col.prop(gp_settings, "fill_simplify_level", text="Simplify")
if gp_settings.fill_solver == 'PIXEL':
col.separator()
col.prop(gp_settings, "fill_simplify_level", text="Simplify")
if gp_settings.fill_draw_mode != 'STROKE':
col = layout.column(align=False, heading="Ignore Transparent")
col.use_property_decorate = False
@ -1950,8 +1959,9 @@ class VIEW3D_PT_tools_grease_pencil_v3_brush_fill_advanced(View3DPanel, Panel):
sub.prop(gp_settings, "fill_threshold", text="")
col.separator()
row = col.row(align=True)
row.prop(gp_settings, "use_fill_limit")
if gp_settings.fill_solver == 'PIXEL':
row = col.row(align=True)
row.prop(gp_settings, "use_fill_limit")
row = col.row(align=True)
row.prop(gp_settings, "use_auto_remove_fill_guides")
@ -2311,15 +2321,20 @@ class VIEW3D_PT_tools_grease_pencil_v3_brush_gap_closure(View3DPanel, Panel):
col = layout.column()
col.prop(gp_settings, "extend_stroke_factor", text="Size")
row = col.row(align=True)
row.prop(gp_settings, "fill_extend_mode", text="Mode")
row = col.row(align=True)
row.prop(gp_settings, "show_fill_extend", text="Visual Aids")
if gp_settings.fill_extend_mode == 'EXTEND':
if brush.gpencil_settings.fill_solver == 'PIXEL':
col.prop(gp_settings, "extend_stroke_factor", text="Size")
row = col.row(align=True)
row.prop(gp_settings, "use_collide_strokes")
row.prop(gp_settings, "fill_extend_mode", text="Mode")
row = col.row(align=True)
row.prop(gp_settings, "show_fill_extend", text="Visual Aids")
if gp_settings.fill_extend_mode == 'EXTEND':
row = col.row(align=True)
row.prop(gp_settings, "use_collide_strokes")
else:
col.prop(gp_settings, "fill_internal_gaps")
if gp_settings.fill_internal_gaps:
col.prop(gp_settings, "fill_gap_factor", text="Detection Factor")
classes = (

View file

@ -30,7 +30,7 @@ namespace blender {
/* Blender file format version. */
#define BLENDER_FILE_VERSION BLENDER_VERSION
#define BLENDER_FILE_SUBVERSION 37
#define BLENDER_FILE_SUBVERSION 38
/* Minimum Blender version that supports reading file written with the current
* version. Older Blender versions will test this and cancel loading the file, showing a warning to

View file

@ -780,6 +780,15 @@ void blo_do_versions_520(FileData * /*fd*/, Library * /*lib*/, Main *bmain)
version_text_strip_abs_space_line(*bmain);
}
if (!MAIN_VERSION_FILE_ATLEAST(bmain, 502, 38)) {
for (Brush &brush : bmain->brushes) {
if (brush.gpencil_settings != nullptr) {
brush.gpencil_settings->fill_gap_factor = 0.4f;
brush.gpencil_settings->flag |= GP_BRUSH_FILL_INTERNAL_GAPS;
}
}
}
/**
* Always bump subversion in BKE_blender_version.h when adding versioning
* code here, and wrap it inside a MAIN_VERSION_FILE_ATLEAST check.

View file

@ -636,25 +636,52 @@ struct ExtensionData {
* \param boundary_layers: Layers that are purely for boundaries, regular strokes are not rendered.
* \param src_drawings: Drawings to include as boundary strokes.
* \param invert: Construct boundary around empty areas instead.
* \param alpha_threshold: Render transparent stroke where opacity is below the threshold.
* \param opacity_threshold: Render transparent stroke where opacity is below the threshold.
* \param fill_point: Point from which to start the bucket fill.
* \param fit_method: View fitting method to include all strokes.
* \param stroke_material_index: Material index to use for the new strokes.
* \param keep_images: Keep the image data block after generating curves.
*/
bke::CurvesGeometry fill_strokes(const ViewContext &view_context,
const Brush &brush,
const Scene &scene,
const bke::greasepencil::Layer &layer,
const VArray<bool> &boundary_layers,
Span<DrawingInfo> src_drawings,
bool invert,
const std::optional<float> alpha_threshold,
const float2 &fill_point,
const ExtensionData &extensions,
FillToolFitMethod fit_method,
int stroke_material_index,
bool keep_images);
bke::CurvesGeometry pixel_fill_strokes(const ViewContext &view_context,
const Brush &brush,
const Scene &scene,
const bke::greasepencil::Layer &layer,
const VArray<bool> &boundary_layers,
const Span<DrawingInfo> src_drawings,
bool invert,
const std::optional<float> opacity_threshold,
const float2 &fill_point,
const ExtensionData &extensions,
FillToolFitMethod fit_method,
bool keep_images);
/**
* Fill tool for generating strokes in empty areas.
*
* This uses delaunay triangulation to compute exact fill geometry.
*
* This is based on "Delaunay painting: Perceptual image colouring from raster contours with gaps."
* (Parakkat, Amal Dev, Pooran Memari, and Marie-Paule Cani)
*
* Will return `nullopt` when unable to fill.
*
* \param layer: The layer containing the new stroke, used for projecting the geometry.
* \param boundary_layers: Layers that are purely for boundaries, regular strokes are skipped.
* \param src_drawings: Drawings to include as boundary strokes.
* \param invert: Construct boundary around empty areas instead.
* \param opacity_threshold: Skip transparent stroke where opacity is below the threshold.
* \param gap_factor: Automatically detect gaps using edge ratio.
* \param fill_points: Points from which to start each bucket fill.
*/
std::optional<bke::CurvesGeometry> delaunay_fill_strokes(const ViewContext &view_context,
const Scene &scene,
const bke::greasepencil::Layer &layer,
const VArray<bool> &boundary_layers,
const Span<DrawingInfo> src_drawings,
bool invert,
std::optional<float> opacity_threshold,
bool internal_gaps,
float gap_factor,
const GroupedSpan<float2> &fill_points);
namespace image_render {

View file

@ -714,8 +714,12 @@ struct GreasePencilFillOpData {
const Brush &brush = *BKE_paint_brush(&ts.gp_paint->paint);
const eGP_FillExtendModes extension_mode = eGP_FillExtendModes(
brush.gpencil_settings->fill_extend_mode);
const bool show_boundaries = brush.gpencil_settings->flag & GP_BRUSH_FILL_SHOW_HELPLINES;
const bool show_extension = brush.gpencil_settings->flag & GP_BRUSH_FILL_SHOW_EXTENDLINES;
const bool is_delaunay = brush.gpencil_settings->fill_solver == GP_FILL_SOLVER_DELAUNAY;
const bool show_boundaries = (brush.gpencil_settings->flag & GP_BRUSH_FILL_SHOW_HELPLINES) &&
!is_delaunay;
const bool show_extension = (brush.gpencil_settings->flag & GP_BRUSH_FILL_SHOW_EXTENDLINES) &&
!is_delaunay;
const float extension_length = brush.gpencil_settings->fill_extend_fac *
bke::greasepencil::LEGACY_RADIUS_CONVERSION_FACTOR;
const bool extension_cut = brush.gpencil_settings->flag & GP_BRUSH_FILL_STROKE_COLLIDE;
@ -1433,6 +1437,87 @@ static bke::CurvesGeometry simplify_fixed(bke::CurvesGeometry &curves, const int
return bke::curves_copy_point_selection(curves, points_to_keep, {});
}
static void set_fill_attributes(bke::CurvesGeometry &fill_curves,
const ViewContext &view_context,
const Brush &brush,
const Scene &scene,
const float4x4 &to_world,
const int material_index,
const float hardness)
{
/* Attributes that are defined explicitly and should not be set to default values. */
Set<std::string> skip_curve_attributes = {
"curve_type", "material_index", "cyclic", "hardness", "fill_opacity"};
Set<std::string> skip_point_attributes = {"position", "radius", "opacity"};
bke::MutableAttributeAccessor attributes = fill_curves.attributes_for_write();
const Span<float3> positions = fill_curves.positions();
bke::SpanAttributeWriter<float> radii = attributes.lookup_or_add_for_write_span<float>(
"radius", bke::AttrDomain::Point, bke::AttributeInitValue(0.01f));
bke::SpanAttributeWriter<float> opacities = attributes.lookup_or_add_for_write_span<float>(
"opacity", bke::AttrDomain::Point, bke::AttributeInitValue(1.0f));
for (const int point_i : fill_curves.points_range()) {
/* Calculate radius and opacity for the outline as if it was a user stroke with full
* pressure. */
const float pressure = 1.0f;
radii.span[point_i] = ed::greasepencil::radius_from_input_sample(view_context.rv3d,
view_context.region,
&brush,
pressure,
positions[point_i],
to_world,
brush.gpencil_settings);
opacities.span[point_i] = ed::greasepencil::opacity_from_input_sample(
pressure, &brush, brush.gpencil_settings);
}
radii.finish();
opacities.finish();
attributes.add<int>(
"material_index", bke::AttrDomain::Curve, bke::AttributeInitValue(material_index));
attributes.add<bool>("cyclic", bke::AttrDomain::Curve, bke::AttributeInitValue(true));
attributes.add<float>("hardness", bke::AttrDomain::Curve, bke::AttributeInitValue(hardness));
/* TODO: `fill_opacities` are currently always 1.0f for the new strokes. Maybe this should be a
* parameter. */
attributes.add<float>("fill_opacity", bke::AttrDomain::Curve, bke::AttributeInitValue(1.0f));
const bool use_vertex_color = ed::sculpt_paint::greasepencil::brush_using_vertex_color(
scene.toolsettings->gp_paint, &brush);
if (use_vertex_color) {
ColorGeometry4f vertex_color;
copy_v3_v3(vertex_color, brush.color);
vertex_color.a = brush.gpencil_settings->vertex_factor;
skip_curve_attributes.add("fill_color");
bke::SpanAttributeWriter<ColorGeometry4f> fill_colors =
attributes.lookup_or_add_for_write_span<ColorGeometry4f>("fill_color",
bke::AttrDomain::Curve);
fill_colors.span.fill(vertex_color);
fill_colors.finish();
if (brush.gpencil_settings->flag2 & GP_BRUSH_USE_STROKE) {
skip_point_attributes.add("vertex_color");
bke::SpanAttributeWriter<ColorGeometry4f> vertex_colors =
attributes.lookup_or_add_for_write_span<ColorGeometry4f>("vertex_color",
bke::AttrDomain::Point);
vertex_colors.span.fill(vertex_color);
vertex_colors.finish();
}
}
/* Initialize the rest of the attributes with default values. */
bke::fill_attribute_range_default(attributes,
bke::AttrDomain::Curve,
bke::attribute_filter_from_skip_ref(skip_curve_attributes),
fill_curves.curves_range());
bke::fill_attribute_range_default(attributes,
bke::AttrDomain::Point,
bke::attribute_filter_from_skip_ref(skip_point_attributes),
fill_curves.points_range());
}
static bool grease_pencil_apply_fill(bContext &C, wmOperator &op, const wmEvent &event)
{
using bke::greasepencil::Layer;
@ -1461,13 +1546,14 @@ static bool grease_pencil_apply_fill(bContext &C, wmOperator &op, const wmEvent
Brush &brush = *BKE_paint_brush(&ts.gp_paint->paint);
const float2 mouse_position = float2(event.mval);
const int simplify_levels = brush.gpencil_settings->fill_simplylvl;
const std::optional<float> alpha_threshold =
const std::optional<float> opacity_threshold =
(brush.gpencil_settings->flag & GP_BRUSH_FILL_HIDE) ?
std::nullopt :
std::make_optional(brush.gpencil_settings->fill_threshold);
const bool on_back = (ts.gpencil_flags & GP_TOOL_FLAG_PAINT_ONBACK);
const bool auto_remove_fill_guides = (brush.gpencil_settings->flag &
GP_BRUSH_FILL_AUTO_REMOVE_FILL_GUIDES) != 0;
const bool is_delaunay = brush.gpencil_settings->fill_solver == GP_FILL_SOLVER_DELAUNAY;
if (!grease_pencil.has_active_layer()) {
return false;
@ -1483,22 +1569,65 @@ static bool grease_pencil_apply_fill(bContext &C, wmOperator &op, const wmEvent
for (const FillToolTargetInfo &info : target_drawings) {
const Layer &layer = *grease_pencil.layers()[info.target.layer_index];
const ed::greasepencil::ExtensionData extensions = grease_pencil_fill_get_extension_data(
C, op_data);
std::optional<bke::CurvesGeometry> op_fill_curves;
if (!is_delaunay) {
const ed::greasepencil::ExtensionData extensions = grease_pencil_fill_get_extension_data(
C, op_data);
op_fill_curves = std::make_optional(pixel_fill_strokes(view_context,
brush,
scene,
layer,
boundary_layers,
info.sources,
op_data.invert,
opacity_threshold,
mouse_position,
extensions,
fit_method,
keep_images));
}
else {
/* TODO: For now only create a single point for the source of the fill algorithm. This should
* take multiple points in the future. */
const Array<int> fill_point_offset = {0, 1};
const Array<float2> fill_point_data = {mouse_position};
const GroupedSpan<float2> fill_points = GroupedSpan<float2>(
OffsetIndices<int>(fill_point_offset), fill_point_data);
const bool internal_gaps = (brush.gpencil_settings->flag & GP_BRUSH_FILL_INTERNAL_GAPS) != 0;
const float gap_factor = brush.gpencil_settings->fill_gap_factor;
op_fill_curves = delaunay_fill_strokes(view_context,
scene,
layer,
boundary_layers,
info.sources,
op_data.invert,
opacity_threshold,
internal_gaps,
gap_factor,
fill_points);
}
if (!op_fill_curves) {
continue;
}
bke::CurvesGeometry &fill_curves = *op_fill_curves;
/* TODO should use the same hardness as the paint brush. */
const float stroke_hardness = 1.0f;
set_fill_attributes(fill_curves,
view_context,
brush,
scene,
layer.to_world_space(object),
op_data.material_index,
stroke_hardness);
bke::CurvesGeometry fill_curves = fill_strokes(view_context,
brush,
scene,
layer,
boundary_layers,
info.sources,
op_data.invert,
alpha_threshold,
mouse_position,
extensions,
fit_method,
op_data.material_index,
keep_images);
if (fill_curves.is_empty()) {
continue;
}
@ -1511,10 +1640,12 @@ static bool grease_pencil_apply_fill(bContext &C, wmOperator &op, const wmEvent
/* Only create fills. Users can change the appearance however they please afterwards. */
attributes.add<bool>("hide_stroke", bke::AttrDomain::Curve, bke::AttributeInitValue(true));
smooth_fill_strokes(fill_curves, fill_curves.curves_range());
if (!is_delaunay) {
smooth_fill_strokes(fill_curves, fill_curves.curves_range());
if (simplify_levels > 0) {
fill_curves = simplify_fixed(fill_curves, brush.gpencil_settings->fill_simplylvl);
if (simplify_levels > 0) {
fill_curves = simplify_fixed(fill_curves, brush.gpencil_settings->fill_simplylvl);
}
}
bke::CurvesGeometry &dst_curves = info.target.drawing.strokes_for_write();

File diff suppressed because it is too large Load diff

View file

@ -57,6 +57,8 @@ enum eGPDbrush_Flag : int {
GP_BRUSH_ACTIVE_LAYER_ONLY = (1 << 20),
/* Automatically remove fill guides created with fill tool. */
GP_BRUSH_FILL_AUTO_REMOVE_FILL_GUIDES = (1 << 21),
/* Fill Tool create internal gaps. */
GP_BRUSH_FILL_INTERNAL_GAPS = (1 << 22),
};
ENUM_OPERATORS(eGPDbrush_Flag)
@ -121,6 +123,12 @@ enum eGP_FillLayerModes : short {
GP_FILL_GPLMODE_BELOW = 5,
};
/* BrushGpencilSettings->fill_solver */
enum eGP_FillSolver {
GP_FILL_SOLVER_DELAUNAY = 0,
GP_FILL_SOLVER_PIXEL = 1,
};
/* BrushGpencilSettings->gp_eraser_mode */
enum eGP_BrushEraserMode : int {
GP_BRUSH_ERASER_SOFT = 0,

View file

@ -52,7 +52,7 @@ struct BrushGpencilSettings {
/** Factor for transparency. */
float fill_threshold = 0;
char _pad[2] = {};
short fill_solver = 0;
/* Type of caps: eGPDstroke_Caps. */
int8_t caps_type = 0;
char _pad1[1] = {};
@ -134,6 +134,9 @@ struct BrushGpencilSettings {
/* #CurveType Used for converting. */
int8_t curve_type = 0;
char _pad4[3] = {};
/* Fill Tool gap detection factor. */
float fill_gap_factor = 0;
char _pad5[4] = {};
/* optional link of material to replace default in context */
/** Material. */

View file

@ -370,6 +370,11 @@ static EnumPropertyItem rna_enum_gpencil_fill_direction_items[] = {
{0, nullptr, 0, nullptr, nullptr},
};
static EnumPropertyItem rna_enum_gpencil_fill_solver_items[] = {
{GP_FILL_SOLVER_DELAUNAY, "DELAUNAY", 0, "Delaunay", "Use the exact geometry to create fills"},
{GP_FILL_SOLVER_PIXEL, "PIXEL", 0, "Pixel", "Use pixel based flooding to create fills"},
{0, nullptr, 0, nullptr, nullptr}};
static EnumPropertyItem rna_enum_gpencil_brush_modes_items[] = {
{GP_BRUSH_MODE_ACTIVE, "ACTIVE", 0, "Active", "Use current mode"},
{GP_BRUSH_MODE_MATERIAL, "MATERIAL", 0, "Material", "Use always material mode"},
@ -1932,6 +1937,22 @@ static void rna_def_gpencil_options(BlenderRNA *brna)
RNA_def_property_clear_flag(prop, PROP_ANIMATABLE);
RNA_def_property_update(prop, 0, "rna_BrushGpencilSettings_update");
prop = RNA_def_property(srna, "fill_solver", PROP_ENUM, PROP_NONE);
RNA_def_property_enum_sdna(prop, nullptr, "fill_solver");
RNA_def_property_enum_items(prop, rna_enum_gpencil_fill_solver_items);
RNA_def_property_ui_text(prop, "Fill Solver", "Method used for when filling");
RNA_def_property_clear_flag(prop, PROP_ANIMATABLE);
RNA_def_property_update(prop, 0, "rna_BrushGpencilSettings_update");
prop = RNA_def_property(srna, "fill_gap_factor", PROP_FLOAT, PROP_FACTOR);
RNA_def_property_range(prop, 0.0f, 1.0f);
RNA_def_property_ui_text(prop,
"Gap Detection Factor",
"The sensitivity of the gap detection. Higher values results in more "
"gaps detected and as such can create smaller fills");
RNA_def_property_clear_flag(prop, PROP_ANIMATABLE);
RNA_def_property_update(prop, 0, "rna_BrushGpencilSettings_update");
prop = RNA_def_property(srna, "pin_draw_mode", PROP_BOOLEAN, PROP_NONE);
RNA_def_property_boolean_funcs(
prop, "rna_GPencilBrush_pin_mode_get", "rna_GPencilBrush_pin_mode_set");
@ -2049,6 +2070,13 @@ static void rna_def_gpencil_options(BlenderRNA *brna)
RNA_def_property_clear_flag(prop, PROP_ANIMATABLE);
RNA_def_property_update(prop, 0, "rna_BrushGpencilSettings_update");
prop = RNA_def_property(srna, "fill_internal_gaps", PROP_BOOLEAN, PROP_NONE);
RNA_def_property_boolean_sdna(prop, nullptr, "flag", GP_BRUSH_FILL_INTERNAL_GAPS);
RNA_def_property_boolean_default(prop, false);
RNA_def_property_ui_text(prop, "Internal Gaps", "Stop at internal gaps");
RNA_def_property_clear_flag(prop, PROP_ANIMATABLE);
RNA_def_property_update(prop, 0, "rna_BrushGpencilSettings_update");
prop = RNA_def_property(srna, "curve_type", PROP_ENUM, PROP_NONE);
RNA_def_property_enum_items(prop, rna_enum_curves_type_items);
RNA_def_property_ui_text(prop, "Curve Type", "Type of curves");