diff --git a/gui/wxpython/xml/toolboxes.xml b/gui/wxpython/xml/toolboxes.xml
index 6070a7bb49b..7859ea56724 100644
--- a/gui/wxpython/xml/toolboxes.xml
+++ b/gui/wxpython/xml/toolboxes.xml
@@ -1214,6 +1214,9 @@
Clean vector map
+
+ Fill holes in areas
+
Smooth or simplify
diff --git a/raster/r.fill.stats/r.fill.stats.html b/raster/r.fill.stats/r.fill.stats.html
index 5b534c9a3cd..6cffdc04182 100644
--- a/raster/r.fill.stats/r.fill.stats.html
+++ b/raster/r.fill.stats/r.fill.stats.html
@@ -504,7 +504,8 @@
SEE ALSO
r.surf.idw ,
v.surf.bspline ,
v.surf.idw ,
-v.surf.rst
+v.surf.rst ,
+v.fill.holes
diff --git a/scripts/r.fillnulls/r.fillnulls.html b/scripts/r.fillnulls/r.fillnulls.html
index 61f16930807..240d3259394 100644
--- a/scripts/r.fillnulls/r.fillnulls.html
+++ b/scripts/r.fillnulls/r.fillnulls.html
@@ -128,7 +128,8 @@
SEE ALSO
r.mapcalc ,
r.resamp.bspline ,
v.surf.bspline ,
-v.surf.rst
+v.surf.rst ,
+v.fill.holes
AUTHORS
diff --git a/vector/Makefile b/vector/Makefile
index 1a8f551a438..f195ce3aafb 100644
--- a/vector/Makefile
+++ b/vector/Makefile
@@ -22,6 +22,7 @@ SUBDIRS = \
v.edit \
v.extract \
v.extrude \
+ v.fill.holes \
v.generalize \
v.hull \
v.info \
diff --git a/vector/v.clean/v.clean.html b/vector/v.clean/v.clean.html
index 8688700e3dc..35d500a7bf8 100644
--- a/vector/v.clean/v.clean.html
+++ b/vector/v.clean/v.clean.html
@@ -375,6 +375,7 @@ SEE ALSO
v.build ,
g.gui.vdigit ,
v.edit ,
+v.fill.holes ,
v.generalize
diff --git a/vector/v.fill.holes/Makefile b/vector/v.fill.holes/Makefile
new file mode 100644
index 00000000000..f9f82be44b4
--- /dev/null
+++ b/vector/v.fill.holes/Makefile
@@ -0,0 +1,12 @@
+MODULE_TOPDIR = ../..
+
+PGM = v.fill.holes
+
+LIBES = $(VECTORLIB) $(DBMILIB) $(GISLIB)
+DEPENDENCIES = $(VECTORDEP) $(DBMIDEP) $(GISDEP)
+EXTRA_INC = $(VECT_INC)
+EXTRA_CFLAGS = $(VECT_CFLAGS)
+
+include $(MODULE_TOPDIR)/include/Make/Module.make
+
+default: cmd
diff --git a/vector/v.fill.holes/examples.ipynb b/vector/v.fill.holes/examples.ipynb
new file mode 100644
index 00000000000..28d86332b0a
--- /dev/null
+++ b/vector/v.fill.holes/examples.ipynb
@@ -0,0 +1,301 @@
+{
+ "cells": [
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "# Graphics for Documentation of _v.fill.holes_\n",
+ "\n",
+ "Requires _pngquant_, _optipng_ and _ImageMagic_ (_mogrify_, _montage_)."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "import os\n",
+ "import sys\n",
+ "\n",
+ "from IPython.display import Image\n",
+ "\n",
+ "import grass.script as gs\n",
+ "import grass.jupyter as gj"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "gj.init(\"~/grassdata/nc_spm_08_grass7/user1\")"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "## Explanation Plots"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "# Use data and import code from tests.\n",
+ "sys.path.append(\"./tests\")\n",
+ "import conftest\n",
+ "from pathlib import Path"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "work_dir = Path(\".\")\n",
+ "conftest.import_data(\n",
+ " path=work_dir,\n",
+ " areas_name=\"data\",\n",
+ " areas_with_space_in_between=\"dissolve_data\",\n",
+ ")"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "!v.fill.holes input=data output=data_filled"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "!g.region vector=data grow=3 res=1\n",
+ "text_position = (75,5)"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "plot = gj.Map(use_region=True, width=700)\n",
+ "plot.d_background(color=\"white\")\n",
+ "plot.d_vect(map=\"data\")\n",
+ "plot.d_text(at=text_position, color=\"black\", text=\"(a) Original\")\n",
+ "plot.save(\"original.png\")\n",
+ "plot.show()"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "plot = gj.Map(use_region=True, width=700)\n",
+ "plot.d_background(color=\"white\")\n",
+ "plot.d_vect(map=\"data_filled\")\n",
+ "plot.d_text(at=text_position, color=\"black\", text=\"(b) Filled\")\n",
+ "plot.save(\"new.png\")\n",
+ "plot.show()"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "example_1 = \"v_fill_holes_filled.png\"\n",
+ "!montage original.png new.png -tile 2x1 -geometry +0+0 {example_1}\n",
+ "Image(example_1)"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "!v.fill.holes input=dissolve_data output=dissolve_data_filled\n",
+ "!v.db.update map=dissolve_data column=name value=\"area\"\n",
+ "!v.dissolve input=dissolve_data output=dissolved_data column=name\n",
+ "!v.fill.holes input=dissolved_data output=dissolved_data_filled"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "!g.region vector=dissolve_data grow=2 res=1\n",
+ "\n",
+ "text_position = (75,5)\n",
+ "\n",
+ "plot = gj.Map(use_region=True, width=700)\n",
+ "plot.d_background(color=\"white\")\n",
+ "plot.d_vect(map=\"dissolve_data\")\n",
+ "plot.d_text(at=text_position, color=\"black\", text=\"(a) Original\")\n",
+ "plot.save(\"original.png\")\n",
+ "\n",
+ "plot = gj.Map(use_region=True, width=700)\n",
+ "plot.d_background(color=\"white\")\n",
+ "plot.d_vect(map=\"dissolve_data_filled\")\n",
+ "plot.d_text(at=text_position, color=\"black\", text=\"() Filled without dissolve\")\n",
+ "plot.save(\"not_working.png\")\n",
+ "\n",
+ "plot = gj.Map(use_region=True, width=700)\n",
+ "plot.d_background(color=\"white\")\n",
+ "plot.d_vect(map=\"dissolved_data\")\n",
+ "plot.d_text(at=text_position, color=\"black\", text=\"(b) Dissloved\")\n",
+ "plot.save(\"dissolved.png\")\n",
+ "\n",
+ "plot = gj.Map(use_region=True, width=700)\n",
+ "plot.d_background(color=\"white\")\n",
+ "plot.d_vect(map=\"dissolved_data_filled\")\n",
+ "plot.d_text(at=text_position, color=\"black\", text=\"(c) Filled\")\n",
+ "plot.save(\"filled.png\")\n",
+ "\n",
+ "example_2 = \"v_fill_holes_filled_with_dissolve.png\"\n",
+ "!montage original.png dissolved.png filled.png -tile 3x1 -geometry +0+0 {example_2}\n",
+ "Image(example_2)"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "## Example"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "!v.extract --overwrite input=lakes where=\"FTYPE != 'ROCK/ISLAND'\" output=lakes_only --qq"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "!g.region n=243300 s=242950 w=647200 e=648000"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "plot = gj.Map(use_region=True, width=700)\n",
+ "plot.d_background(color=\"white\")\n",
+ "plot.d_vect(map=\"lakes_only\", legend_label=\"Original\")\n",
+ "plot.d_legend_vect(flags=\"b\", at=(60,10))\n",
+ "plot.show()"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "!v.fill.holes input=lakes_only output=lakes_filled"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "plot = gj.Map(use_region=True, width=700)\n",
+ "plot.d_background(color=\"white\")\n",
+ "plot.d_vect(map=\"lakes_filled\", legend_label=\"Filled\")\n",
+ "plot.d_legend_vect(flags=\"b\", at=(60,10))\n",
+ "plot.show()"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "!v.dissolve input=lakes_filled column=NAME output=lakes_dissolved --qq"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "plot = gj.Map(use_region=True, width=700)\n",
+ "plot.d_background(color=\"white\")\n",
+ "plot.d_vect(map=\"lakes_dissolved\", legend_label=\"Dissolved\")\n",
+ "plot.d_legend_vect(flags=\"b\", at=(60,10))\n",
+ "plot.show()"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "plot = gj.Map(use_region=True, width=1024)\n",
+ "plot.d_background(color=\"#E28A2B\")\n",
+ "plot.d_vect(map=\"lakes_filled\", color=\"none\", fill_color=\"#384C6B\", legend_label=\"Filled\")\n",
+ "plot.d_vect(map=\"lakes_only\", color=\"#859BBA\", fill_color=\"none\", width=2, legend_label=\"Original\")\n",
+ "plot.d_legend_vect(flags=\"b\", at=(80,85), fontsize=22, symbol_size=35)\n",
+ "filename = \"v_fill_holes.png\"\n",
+ "plot.save(filename)\n",
+ "!mogrify -trim {filename}\n",
+ "!pngquant --ext \".png\" -f {filename}\n",
+ "!optipng -o7 {filename}\n",
+ "Image(filename)"
+ ]
+ }
+ ],
+ "metadata": {
+ "kernelspec": {
+ "display_name": "Python 3 (ipykernel)",
+ "language": "python",
+ "name": "python3"
+ },
+ "language_info": {
+ "codemirror_mode": {
+ "name": "ipython",
+ "version": 3
+ },
+ "file_extension": ".py",
+ "mimetype": "text/x-python",
+ "name": "python",
+ "nbconvert_exporter": "python",
+ "pygments_lexer": "ipython3",
+ "version": "3.8.10"
+ }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 4
+}
diff --git a/vector/v.fill.holes/main.c b/vector/v.fill.holes/main.c
new file mode 100644
index 00000000000..8f8bf20a0bf
--- /dev/null
+++ b/vector/v.fill.holes/main.c
@@ -0,0 +1,212 @@
+
+/****************************************************************
+ *
+ * MODULE: v.fill.holes
+ *
+ * AUTHOR: Vaclav Petras
+ *
+ * PURPOSE: Fill holes in an area, i.e., preserve only its outer boundary
+ *
+ * COPYRIGHT: (C) 2023 by Vaclav Petras and the GRASS Development Team
+ *
+ * This program is free software under the GNU General
+ * Public License (>=v2). Read the file COPYING that
+ * comes with GRASS for details.
+ *
+ ****************************************************************/
+
+#include
+#include
+#include
+#include
+#include
+#include
+
+struct VFillHolesParameters {
+ struct GModule *module;
+ struct Option *input;
+ struct Option *output;
+ struct Option *field;
+ struct Option *cats;
+ struct Option *where;
+};
+
+int main(int argc, char *argv[])
+{
+ struct Map_info input;
+ struct Map_info output;
+ int open3d;
+
+ G_gisinit(argv[0]);
+
+ struct VFillHolesParameters options;
+
+ options.module = G_define_module();
+ G_add_keyword(_("vector"));
+ G_add_keyword(_("geometry"));
+ G_add_keyword(_("fill"));
+ G_add_keyword(_("exterior"));
+ G_add_keyword(_("ring"));
+ G_add_keyword(_("perimeter"));
+ options.module->description =
+ _("Fill holes in areas by keeping only outer boundaries");
+
+ options.input = G_define_standard_option(G_OPT_V_INPUT);
+
+ options.field = G_define_standard_option(G_OPT_V_FIELD);
+
+ options.cats = G_define_standard_option(G_OPT_V_CATS);
+
+ options.where = G_define_standard_option(G_OPT_DB_WHERE);
+
+ options.output = G_define_standard_option(G_OPT_V_OUTPUT);
+
+ if (G_parser(argc, argv))
+ exit(EXIT_FAILURE);
+
+ Vect_check_input_output_name(options.input->answer, options.output->answer,
+ G_FATAL_EXIT);
+ Vect_set_open_level(2);
+
+ if (1 > Vect_open_old2(&input, options.input->answer, "",
+ options.field->answer))
+ G_fatal_error(_("Unable to open vector map <%s>"),
+ options.input->answer);
+
+ /* Check if old vector is 3D. We should preserve 3D data. */
+ if (Vect_is_3d(&input))
+ open3d = WITH_Z;
+ else
+ open3d = WITHOUT_Z;
+
+ /* Set error handler for input vector map */
+ Vect_set_error_handler_io(&input, NULL);
+
+ /* Open new vector for reading/writing */
+ if (0 > Vect_open_new(&output, options.output->answer, open3d)) {
+ G_fatal_error(_("Unable to create vector map <%s>"),
+ options.output->answer);
+ }
+
+ /* Set error handler for output vector map */
+ Vect_set_error_handler_io(NULL, &output);
+
+ int field = Vect_get_field_number(&input, options.field->answer);
+
+ if (field <= 0 && options.cats->answer)
+ G_fatal_error(_("Option %s cannot be combined with %s=%s"),
+ options.cats->key, options.field->key,
+ options.field->answer);
+ if (field <= 0 && options.where->answer)
+ G_fatal_error(_("Option %s cannot be combined with %s=%s"),
+ options.where->key, options.field->key,
+ options.field->answer);
+
+ /* Copy header and history data from old to new map */
+ Vect_copy_head_data(&input, &output);
+ Vect_hist_copy(&input, &output);
+ Vect_hist_command(&output);
+
+ // Set category constraint.
+ struct cat_list *constraint_cat_list = NULL;
+
+ if (field > 0)
+ constraint_cat_list = Vect_cats_set_constraint(
+ &input, field, options.where->answer, options.cats->answer);
+
+ /* Create and initialize struct's where to store points/lines and categories
+ */
+ struct line_pnts *points = Vect_new_line_struct();
+ struct line_cats *area_cats = Vect_new_cats_struct();
+ struct line_cats *boundary_cats = Vect_new_cats_struct();
+
+ struct ilist *all_cats = Vect_new_list();
+ struct ilist *field_cats = Vect_new_list();
+ struct ilist *area_boundaries = Vect_new_list();
+
+ plus_t num_areas = Vect_get_num_areas(&input);
+ plus_t num_lines = Vect_get_num_lines(&input);
+ // Used as index. 0th element is unused.
+ bool *line_written_out = G_calloc(num_lines + 1, sizeof(bool));
+
+ G_percent(0, num_areas, 1);
+ for (plus_t area = 1; area <= num_areas; area++) {
+ G_percent(area, num_areas, 1);
+
+ int centroid = Vect_get_area_centroid(&input, area);
+
+ if (!centroid)
+ continue;
+
+ Vect_read_line(&input, points, area_cats, centroid);
+
+ if (constraint_cat_list &&
+ !Vect_cats_in_constraint(area_cats, field, constraint_cat_list)) {
+ continue;
+ }
+ Vect_write_line(&output, GV_CENTROID, points, area_cats);
+
+ Vect_get_area_boundaries(&input, area, area_boundaries);
+ for (int i = 0; i < area_boundaries->n_values; i++) {
+ int boundary_id = abs(area_boundaries->value[i]);
+ if (line_written_out[boundary_id])
+ continue;
+ Vect_read_line(&input, points, boundary_cats, boundary_id);
+ Vect_write_line(&output, GV_BOUNDARY, points, boundary_cats);
+ line_written_out[boundary_id] = true;
+ }
+
+ if (field > 0) {
+ Vect_field_cat_get(area_cats, field, field_cats);
+ Vect_list_append_list(all_cats, field_cats);
+ }
+ }
+
+ Vect_destroy_cats_struct(boundary_cats);
+ Vect_destroy_cats_struct(area_cats);
+ Vect_destroy_line_struct(points);
+
+ /* Let's get vector layers db connections information */
+ struct field_info *input_info = NULL;
+
+ if (field > 0 && all_cats->n_values)
+ input_info = Vect_get_field2(&input, options.field->answer);
+
+ if (input_info) {
+ G_verbose_message(_("Copying attributes for layer <%s>"),
+ options.field->answer);
+
+ struct field_info *output_info =
+ Vect_default_field_info(&output, field, NULL, GV_1TABLE);
+ /* Create database for new vector map */
+ dbDriver *driver = db_start_driver_open_database(output_info->driver,
+ output_info->database);
+ Vect_map_add_dblink(&output, output_info->number, output_info->name,
+ output_info->table, input_info->key,
+ output_info->database, output_info->driver);
+
+ /* Copy attribute table data */
+ if (db_copy_table_by_ints(
+ input_info->driver, input_info->database, input_info->table,
+ output_info->driver,
+ Vect_subst_var(output_info->database, &output),
+ output_info->table, input_info->key, all_cats->value,
+ all_cats->n_values) == DB_FAILED)
+ G_fatal_error(
+ _("Unable to copy attribute table to vector map <%s>"),
+ options.output->answer);
+ db_close_database_shutdown_driver(driver);
+ }
+
+ Vect_destroy_list(field_cats);
+ Vect_destroy_list(all_cats);
+
+ Vect_build(&output);
+ Vect_close(&input);
+
+ /* Build topology for vector map and close them */
+
+ Vect_close(&output);
+
+ exit(EXIT_SUCCESS);
+}
diff --git a/vector/v.fill.holes/tests/conftest.py b/vector/v.fill.holes/tests/conftest.py
new file mode 100644
index 00000000000..a86a07cc043
--- /dev/null
+++ b/vector/v.fill.holes/tests/conftest.py
@@ -0,0 +1,227 @@
+"""Fixture for v.fill.holes test"""
+
+from types import SimpleNamespace
+
+import pytest
+
+import grass.script as gs
+
+DATA = """\
+VERTI:
+C 1 1
+ 26.4740753 19.30631132
+ 1 1
+B 3
+ 35.7009422 5.30814651
+ 39.02877384 19.25061356
+ 35.75831861 33.47996264
+B 3
+ 35.75831861 33.47996264
+ 26.6354698 35.71764254
+ 20.03718292 32.73406934
+C 1 1
+ 42.19882036 26.59479373
+ 1 3
+B 8 1
+ 31.98581981 29.69311974
+ 27.51046001 31.70129401
+ 26.07604982 27.45543984
+ 29.23175224 23.78334976
+ 33.82186485 23.20958568
+ 35.02676941 26.76692295
+ 34.51038174 29.52099052
+ 31.98581981 29.69311974
+ 1 4
+B 7 1
+ 42.02669114 33.25045701
+ 39.55950561 28.602968
+ 43.00209007 28.37346236
+ 45.64140482 31.6439176
+ 45.18239356 35.48813691
+ 41.96931473 33.99635031
+ 42.02669114 33.25045701
+ 1 5
+B 7 1
+ 23.83836993 17.2998157
+ 21.83019566 21.60304627
+ 18.27285839 20.45551812
+ 17.46958868 17.18506288
+ 21.14167877 12.65232668
+ 24.3547576 14.48837172
+ 23.83836993 17.2998157
+ 1 6
+B 7 1
+ 31.52680855 17.64407414
+ 28.60061176 12.8244559
+ 33.36285359 9.66875349
+ 36.63330882 13.5129728
+ 36.69068523 17.24243929
+ 32.33007825 18.21783822
+ 31.52680855 17.64407414
+ 1 7
+B 7
+ 46.35860991 38.87334496
+ 33.25872825 43.10254145
+ 27.62998922 41.83894698
+ 17.32020703 35.66456488
+ 11.83505828 29.34659251
+ 8.81966238 23.22964653
+ 11.43072178 20.57027093
+B 2
+ 20.03718292 32.73406934
+ 11.43072178 20.57027093
+B 2
+ 35.75831861 33.47996264
+ 46.35860991 38.87334496
+C 1 1
+ 30.9900018 38.79483301
+ 1 9
+B 12 1
+ 20.38144137 3.7589835
+ 2.42754667 6.02567852
+ 0.12531832 16.15548326
+ 2.65776951 31.69552462
+ 12.90268566 49.07734866
+ 30.86006679 52.18535694
+ 51.58012194 49.53779433
+ 57.91124991 36.76042699
+ 58.71702983 19.14838012
+ 50.88945344 6.02567852
+ 41.91076287 0.38521906
+ 38.64148309 4.21799476
+ 1 10
+B 2
+ 11.43072178 20.57027093
+ 20.38144137 3.7589835
+B 2
+ 20.38144137 3.7589835
+ 35.7009422 5.30814651
+B 5
+ 46.35860991 38.87334496
+ 46.81762117 30.95540071
+ 46.2725453 14.68918915
+ 41.45292706 12.10725081
+ 38.64148309 4.21799476
+B 2
+ 38.64148309 4.21799476
+ 35.7009422 5.30814651
+C 1 1
+ 21.00365167 45.50889472
+ 1 11
+"""
+
+AREAS_WITH_SPACE_GEOMETRY = """\
+VERTI:
+B 5 1
+ 8.45818088 33.40191653
+ 0.60764185 25.08168713
+ 0.33924735 9.78320082
+ 10.26984373 1.06037967
+ 26.37351354 0.12099893
+ 1 1
+B 7 1
+ 26.37351354 0.12099893
+ 42.61138059 4.28111363
+ 43.81915583 22.26354492
+ 35.16343331 33.26771928
+ 23.01858233 34.07290277
+ 13.62477494 34.1400014
+ 8.45818088 33.40191653
+ 1 1
+C 1 1
+ 10.40404098 15.75497837
+ 1 3
+C 1 1
+ 28.85616264 22.12934767
+ 1 4
+B 6 1
+ 13.62477494 25.282983
+ 22.95148371 26.42365962
+ 28.05097915 18.90861371
+ 31.8085021 10.38708843
+ 30.26523374 2.93914115
+ 25.03154106 3.74432464
+ 1 5
+B 2 1
+ 8.45818088 33.40191653
+ 13.62477494 25.282983
+ 1 2
+B 3 1
+ 13.62477494 25.282983
+ 22.68308921 15.0168935
+ 25.03154106 3.74432464
+ 1 2
+B 2 1
+ 25.03154106 3.74432464
+ 26.37351354 0.12099893
+ 1 2
+"""
+
+AREAS_WITH_SPACE_ATTRIBUTES = """\
+cat,name
+3,"Left plot"
+4,"Right plot"
+"""
+
+AREAS_WITH_SPACE_ATTRIBUTE_TYPES = """\
+"Integer","String"
+"""
+
+
+def import_data(path, areas_name, areas_with_space_in_between):
+ gs.write_command(
+ "v.in.ascii", input="-", output=areas_name, stdin=DATA, format="standard"
+ )
+ attributes = path / "test.csv"
+ attributes.write_text(AREAS_WITH_SPACE_ATTRIBUTES)
+ attribute_types = path / "test.csvt"
+ attribute_types.write_text(AREAS_WITH_SPACE_ATTRIBUTE_TYPES)
+ # Attributes need to be created first because no vector map of the same name
+ # can exist when table is imported (interally using v.in.ogr and vector part
+ # is deleted).
+ gs.run_command("db.in.ogr", input=attributes, output=areas_with_space_in_between)
+ gs.write_command(
+ "v.in.ascii",
+ input="-",
+ output=areas_with_space_in_between,
+ stdin=AREAS_WITH_SPACE_GEOMETRY,
+ format="standard",
+ )
+ # Our old cat column is now called cat_, so we need to rename it to cat,
+ # but that's possible only on vector map level, so connect, rename, and
+ # reconnect to create indices.
+ gs.run_command(
+ "v.db.connect",
+ map=areas_with_space_in_between,
+ table=areas_with_space_in_between,
+ )
+ gs.run_command(
+ "v.db.renamecolumn", map=areas_with_space_in_between, column=("cat_", "cat")
+ )
+ gs.run_command(
+ "v.db.connect",
+ map=areas_with_space_in_between,
+ table=areas_with_space_in_between,
+ flags="o",
+ )
+
+
+@pytest.fixture(scope="module")
+def area_dataset(tmp_path_factory):
+ """Create a session and fill mapset with data"""
+ tmp_path = tmp_path_factory.mktemp("area_dataset")
+ location = "test"
+
+ areas_name = "test_areas"
+ areas_with_space_in_between = "areas_with_space_in_between"
+
+ gs.core._create_location_xy(tmp_path, location) # pylint: disable=protected-access
+ with gs.setup.init(tmp_path / location):
+ import_data(
+ path=tmp_path,
+ areas_name=areas_name,
+ areas_with_space_in_between=areas_with_space_in_between,
+ )
+ yield SimpleNamespace(
+ name=areas_name, areas_with_space_in_between=areas_with_space_in_between
+ )
diff --git a/vector/v.fill.holes/tests/v_fill_holes_test.py b/vector/v.fill.holes/tests/v_fill_holes_test.py
new file mode 100644
index 00000000000..5bac9c6519b
--- /dev/null
+++ b/vector/v.fill.holes/tests/v_fill_holes_test.py
@@ -0,0 +1,52 @@
+"""Test v.fill.holes outputs"""
+
+import json
+
+import grass.script as gs
+
+
+def test_removal(area_dataset):
+ """Check that holes are removed"""
+ output = "test"
+ gs.run_command("v.fill.holes", input=area_dataset.name, output=output)
+ original_info = gs.vector_info(area_dataset.name)
+ info = gs.vector_info(output)
+ removed = 4
+ assert info["nodes"] == original_info["nodes"] - removed
+ assert info["points"] == original_info["points"]
+ assert info["lines"] == original_info["lines"]
+ assert info["boundaries"] == original_info["boundaries"] - removed
+ assert info["centroids"] == original_info["centroids"]
+ assert info["areas"] == original_info["areas"] - removed
+ assert info["islands"] == original_info["islands"] - removed
+ assert info["primitives"] == original_info["primitives"] - removed
+
+
+def test_no_change(area_dataset):
+ """Check that space in between is not changed including attributes"""
+ output = "no_change"
+ gs.run_command(
+ "v.fill.holes", input=area_dataset.areas_with_space_in_between, output=output
+ )
+ original_info = gs.vector_info(area_dataset.areas_with_space_in_between)
+ info = gs.vector_info(output)
+ for item in [
+ "nodes",
+ "points",
+ "lines",
+ "boundaries",
+ "centroids",
+ "areas",
+ "islands",
+ "primitives",
+ ]:
+ assert info[item] == original_info[item], item
+
+ records = json.loads(gs.read_command("v.db.select", map=output, format="json"))[
+ "records"
+ ]
+ assert len(records) == 2
+ assert records[0]["cat"] == 3
+ assert records[0]["name"] == "Left plot"
+ assert records[1]["cat"] == 4
+ assert records[1]["name"] == "Right plot"
diff --git a/vector/v.fill.holes/v.fill.holes.html b/vector/v.fill.holes/v.fill.holes.html
new file mode 100644
index 00000000000..7bb7ef1cbad
--- /dev/null
+++ b/vector/v.fill.holes/v.fill.holes.html
@@ -0,0 +1,119 @@
+DESCRIPTION
+
+v.fill.holes fills empty spaces inside areas, specifically
+it preserves areas with centroids while areas without centroids,
+which typically represent holes, are removed.
+
+v.fill.holes goes over all areas in a vector map
+and it preserves only outer boundaries of each area
+while removing inner boundaries which are considered holes.
+The holes become part of the area which contained them.
+No boundaries of these holes are preserved.
+
+
+
+
+
+
+ Figure: Holes inside areas are removed. (a) Original areas with holes and (b) the same areas but with holes filled.
+
+
+
+In case areas have empty space in between them,
+i.e., there are holes in the overall coverage, but not in the areas themselves,
+v.fill.holes can't assign this empty space to either of these areas
+because it does not know which area this empty space should belong to.
+If the space needs to be filled, this can be resolved by merging the areas
+around the empty space into one by dissolving their common boundaries.
+This turns the empty space into a hole inside one single area
+which turns the situation into a case of one area with a hole.
+
+
+
+
+
+
+ Figure: Empty space in between two areas does not belong to either area,
+ so it is filled only after the boundaries between areas are dissolved,
+ i.e., areas merged into one.
+ (a) Original areas with space in between,
+ (b) one area with a hole after dissolving the common boundary, and
+ (c) hole filled.
+
+
+
+Topology
+
+Strictly speaking, in the GRASS topological model, an area is a closed boundary
+(or a series of connected closed boundaries) which may have a centroid.
+If it has a centroid, it is rendered as a filled area in displays and
+this is what is usually considered an area from the user perspective.
+These are the areas where v.fill.holes preserves the associated outer boundary (or boundaries).
+Other closed boundaries, i.e., those without a centroid, are not carried over to the output.
+All other features are removed including points and lines.
+
+Attributes
+
+If a specific layer is selected, attributes for that layer are preserved
+for the areas based on the category or categories associated with each area.
+By default, layer number 1 is selected.
+In case there are attribute tables associated with other layers or attributes
+associated with categories of other features than areas with centroids,
+this attribute data is not carried over to the output just like the
+corresponding geometries.
+
+EXAMPLE
+
+The lakes vector map in the North Carolina sample dataset
+represents islands inside lakes as areas distinguished by attributes.
+To demonstrate v.fill.holes , we will first extract only the
+lakes which will create holes where the islands were located.
+Then, we will fill the holes created in the lakes to get
+the whole perimeter of the lakes including islands.
+
+Remove the islands by extracting everything else (results in holes):
+
+
+v.extract input=lakes where="FTYPE != 'ROCK/ISLAND'" output=lakes_only
+
+
+Remove the holes:
+
+
+v.fill.holes input=lakes_only output=lakes_filled
+
+
+
+
+
+
+
+ Figure: The filled lake (blue) and borders of the original lakes with islands removed (light blue).
+ Figure shows a smaller area in the north of the data extent.
+
+
+
+SEE ALSO
+
+
+
+ v.dissolve
+ for removing common boundaries based on attributes,
+
+
+ v.clean
+ for removing topological issues,
+
+
+ r.fillnulls
+ for filling empty spaces in raster maps using interpolation,
+
+
+ r.fill.stats
+ for filling empty spaces in raster maps using statistics.
+
+
+
+AUTHOR
+
+Vaclav Petras, NCSU Center for Geospatial Analytics, GeoForAll Lab
diff --git a/vector/v.fill.holes/v_fill_holes.png b/vector/v.fill.holes/v_fill_holes.png
new file mode 100644
index 00000000000..03ecdddcb87
Binary files /dev/null and b/vector/v.fill.holes/v_fill_holes.png differ
diff --git a/vector/v.fill.holes/v_fill_holes_filled.png b/vector/v.fill.holes/v_fill_holes_filled.png
new file mode 100644
index 00000000000..1826d392826
Binary files /dev/null and b/vector/v.fill.holes/v_fill_holes_filled.png differ
diff --git a/vector/v.fill.holes/v_fill_holes_filled_with_dissolve.png b/vector/v.fill.holes/v_fill_holes_filled_with_dissolve.png
new file mode 100644
index 00000000000..75e9688930d
Binary files /dev/null and b/vector/v.fill.holes/v_fill_holes_filled_with_dissolve.png differ