diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index c3ef11475a0abf37254b5eef60130fe3dfbb4097..d501e83f223767a186a1fac6c5beccf5e144a4d0 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -14,7 +14,7 @@ testsuite-clang18:
     CXX: clang++
     CC: clang
   before_script:
-    - cd user_manual/examples
+    - cd tests
     - cmake -G Ninja -S . -B build
     - cd build
     - cmake --build . --target SfgTests
@@ -31,7 +31,7 @@ build-examples:
     CXX: clang++
     CC: clang
   script:
-    - cd tests
+    - cd user_manual/examples
     - cmake -G Ninja -S . -B build
     - cd build
     - cmake --build . --target Examples
diff --git a/user_manual/conf.py b/user_manual/conf.py
index 2bc06da4f2d273c00770914d3e7fd0e7555755e0..6dfd366ee11f1cc57897e825c1ac61bcd85f3bd7 100644
--- a/user_manual/conf.py
+++ b/user_manual/conf.py
@@ -21,7 +21,7 @@ extensions = [
 ]
 
 # templates_path = ['_templates']
-exclude_patterns = ["build"]
+exclude_patterns = ["*/build"]
 
 myst_enable_extensions = [
     "colon_fence",
diff --git a/user_manual/examples/FreeSlipBubble/FreeSlipBubble.cpp b/user_manual/examples/FreeSlipBubble/FreeSlipBubble.cpp
index 6b6f6990c89189f4ad6ecab41f4818370ca0b61e..be5f93da51d38ebd445127201ba36a31d74f1900 100644
--- a/user_manual/examples/FreeSlipBubble/FreeSlipBubble.cpp
+++ b/user_manual/examples/FreeSlipBubble/FreeSlipBubble.cpp
@@ -72,7 +72,7 @@ namespace FreeSlipBubble
 
         const BlockDataID flagFieldId = field::addFlagFieldToStorage<FlagField_T>(blocks, "flagField");
         const FlagUID fluidFlagUid{"Fluid"};
-        const FlagUID freeSlipFlagUID{"FreeSlip"};
+        const FlagUID boundaryFlagUID{"FreeSlip"};
 
         const CellInterval allCells{{0, 0, 0}, {blocks->getNumberOfXCellsPerBlock() - 1, blocks->getNumberOfYCellsPerBlock() - 1, blocks->getNumberOfZCellsPerBlock() - 1}};
         const Vector3< real_t > sphereCenter { 16., 16., 16. };
@@ -81,7 +81,7 @@ namespace FreeSlipBubble
         for (auto &block : *blocks)
         {
             FlagField_T &flagField = *block.getData<FlagField_T>(flagFieldId);
-            const uint8_t freeSlipFlag{flagField.getOrRegisterFlag(freeSlipFlagUID)};
+            const uint8_t freeSlipFlag{flagField.getOrRegisterFlag(boundaryFlagUID)};
 
             PdfField_T & pdfField = *block.getData< PdfField_T >(pdfsId);
 
@@ -104,14 +104,25 @@ namespace FreeSlipBubble
         auto flagFieldOutput = field::createVTKOutput<FlagField_T>(flagFieldId, *blocks, "flagField", 1, 0);
         flagFieldOutput();
 
-        auto freeSlip = gen::FreeSlipFactory(blocks, pdfsId).fromFlagField<FlagField_T>(flagFieldId, freeSlipFlagUID, fluidFlagUid);
+        // begin freeSlipFactory
+        gen::FreeSlipFactory freeSlipFactory{ blocks, pdfsId };
+        // end freeSlipFactory
+        // begin freeSlipFromFlagField
+        gen::FreeSlip freeSlip {
+            freeSlipFactory.fromFlagField<FlagField_T>(
+                flagFieldId,
+                boundaryFlagUID,
+                fluidFlagUid
+            )
+        };
+        // end freeSlipFromFlagField
 
         //  Timeloop
         const uint_t numTimesteps{simParams.getParameter<uint_t>("timesteps")};
         SweepTimeloop loop{blocks->getBlockStorage(), numTimesteps};
 
-        loop.add() << BeforeFunction(comm) << Sweep(makeSharedSweep(streamCollide));
-        loop.add() << Sweep(freeSlip);
+        loop.add() << BeforeFunction(comm) << Sweep(freeSlip);
+        loop.add() << Sweep(makeSharedSweep(streamCollide));
 
         RemainingTimeLogger logger{numTimesteps};
         loop.addFuncAfterTimeStep(logger);
diff --git a/user_manual/examples/FreeSlipBubble/LbmAlgorithms.py b/user_manual/examples/FreeSlipBubble/LbmAlgorithms.py
index a40d6b4c1349ef03a585c8a734d81e286dbbb3df..a2975f2617a73d9b129dc7bd55b1a308e9a37b2a 100644
--- a/user_manual/examples/FreeSlipBubble/LbmAlgorithms.py
+++ b/user_manual/examples/FreeSlipBubble/LbmAlgorithms.py
@@ -7,16 +7,15 @@ from lbmpy.macroscopic_value_kernels import macroscopic_values_setter
 
 
 from sfg_walberla import Sweep
-from sfg_walberla.boundaries import FreeSlip
 
 with SourceFileGenerator() as sfg:
     sfg.namespace("FreeSlipBubble::gen")
 
     stencil = LBStencil(Stencil.D3Q19)
     d, q = stencil.D, stencil.Q
-    f: Field
-    f_tmp: Field
-    f, f_tmp, rho, u = fields(
+    pdfs: Field
+    pdfs_tmp: Field
+    pdfs, pdfs_tmp, rho, u = fields(
         f"f({q}), f_tmp({q}), rho, u({d}): double[{d}D]", layout="fzyx"
     )  # type: ignore
 
@@ -25,25 +24,36 @@ with SourceFileGenerator() as sfg:
         output={"density": rho, "velocity": u},
     )
     lbm_opt = LBMOptimisation(
-        symbolic_field=f,
-        symbolic_temporary_field=f_tmp,
+        symbolic_field=pdfs,
+        symbolic_temporary_field=pdfs_tmp,
     )
 
     lb_update = create_lb_update_rule(lbm_config=lbm_config, lbm_optimisation=lbm_opt)
+    lb_method = lb_update.method
     assert lb_update is not None
 
     lb_update_sweep = Sweep("LbStreamCollide", lb_update)
-    lb_update_sweep.swap_fields(f, f_tmp)
+    lb_update_sweep.swap_fields(pdfs, pdfs_tmp)
     sfg.generate(lb_update_sweep)
 
     lb_init = macroscopic_values_setter(
         lb_update.method,
         density=sp.Symbol("density"),
         velocity=sp.symbols(f"velocity_:{d}"),
-        pdfs=f,
+        pdfs=pdfs,
     )
     lb_init_sweep = Sweep("LbInit", lb_init)
     sfg.generate(lb_init_sweep)
 
-    freeslip = FreeSlip("FreeSlip", lb_update.method, f, wall_normal=FreeSlip.IRREGULAR)
+    #   begin irregular-freeslip
+    from sfg_walberla.boundaries import FreeSlip
+
+    freeslip = FreeSlip(
+        "FreeSlip",
+        lb_method,
+        pdfs,
+        wall_normal=FreeSlip.IRREGULAR,
+    )
+
     sfg.generate(freeslip)
+    #   end irregular-freeslip
diff --git a/user_manual/examples/FreeSlipBubble/Periodic.prm b/user_manual/examples/FreeSlipBubble/Periodic.prm
index 8bf1dace34ec190e4b79df77f7daa97927a84c56..2fd9fbd63f7836cce2ffb43723485ca4e01fdbc9 100644
--- a/user_manual/examples/FreeSlipBubble/Periodic.prm
+++ b/user_manual/examples/FreeSlipBubble/Periodic.prm
@@ -8,10 +8,10 @@ DomainSetup
 Parameters
 {
     omega       1.0;
-    timesteps   200;
+    timesteps   2000;
 }
 
 Output
 {
-    vtkWriteFrequency   10;
+    vtkWriteFrequency   100;
 }
diff --git a/user_manual/guides/BoundaryConditions.md b/user_manual/guides/BoundaryConditions.md
new file mode 100644
index 0000000000000000000000000000000000000000..7480c64f5648fd34511320785d1e450dea36bdb2
--- /dev/null
+++ b/user_manual/guides/BoundaryConditions.md
@@ -0,0 +1,56 @@
+(guide-boundary-conditions)=
+# Boundary Conditions
+
+## Sparse Boundary Sweeps for Irregular Geometries
+
+To cover the boundary conditions at surfaces with irregular geometries
+(i.e. surfaces not aligned with the cell grid),
+we need to use a sparse implementation of the boundary sweep.
+
+### Generating Sparse Link-Wise LBM Boundary Sweeps
+
+For link-wise lattice Boltzmann boundary conditions, sparsity is enabled by
+setting the `wall_normal` parameter to `IRREGULAR`:
+
+```{literalinclude} ../examples/FreeSlipBubble/LbmAlgorithms.py
+:language: Python
+:start-after: "#   begin irregular-freeslip"
+:end-before: "#   end irregular-freeslip"
+:linenos:
+:dedent: 4
+:emphasize-lines: 7
+```
+
+The above code will generate two classes:
+ - The `FreeSlip` sweep class, which is a [sparse sweep](#guide-sparse-sweeps) that applies
+   the free-slip boundary condition at each lattice link crossing the boundary surface;
+ - and the `FreeSlipFactory` class; which produces instances of the `FreeSlip` sweep and populates its
+   index vector from user-provided geometry information.
+
+### Populating the Index Vector
+
+Sparse boundary sweeps are created, and their index vectors populated, by their associated factory.
+Create the factory object by passing it any required parameters:
+
+```{literalinclude} ../examples/FreeSlipBubble/FreeSlipBubble.cpp
+:language: C++
+:start-after: "// begin freeSlipFactory"
+:end-before: "// end freeSlipFactory"
+:dedent: 8
+```
+
+#### From a Flag Field
+
+One possibility to populate the sparse sweep is to extract boundary information stored in a *flag field*.
+This requires that each *solid* cell considered by the boundary condition is marked with a *boundary flag*,
+and each valid *fluid* cell is marked with the *fluid* flag.
+Assuming that this information is stored in a flag field of type `FlagField_T` identified by `flagFieldId`,
+and `boundaryFlagUID` and `fluidFlagUID` respectively identify the boundary and fluid flags,
+we can create a sparse boundary sweep using the `fromFlagField` method of the factory:
+
+```{literalinclude} ../examples/FreeSlipBubble/FreeSlipBubble.cpp
+:language: C++
+:start-after: "// begin freeSlipFromFlagField"
+:end-before: "// end freeSlipFromFlagField"
+:dedent: 8
+```
diff --git a/user_manual/guides/SparseSweeps.md b/user_manual/guides/SparseSweeps.md
index 159313f0c8542839d117a651bacd9d8165128bdf..822d92fbc3894a07e09def2e2797b95d0f349e46 100644
--- a/user_manual/guides/SparseSweeps.md
+++ b/user_manual/guides/SparseSweeps.md
@@ -1,3 +1,4 @@
+(guide-sparse-sweeps)=
 # Sparse Sweeps
 
 :::{admonition} Example Code
diff --git a/user_manual/index.md b/user_manual/index.md
index a48e3fe493164d216d069af31192de6f64a37b51..29564ac46045ef4171c75bd23f1ee63d1e19985c 100644
--- a/user_manual/index.md
+++ b/user_manual/index.md
@@ -24,6 +24,7 @@ examples/GeneratorScriptBasics/GeneratorScriptBasics
 :maxdepth: 1
 
 guides/SparseSweeps
+guides/BoundaryConditions
 :::
 
 :::{toctree}