How the HKUDS/CLI-Anything Layout Engine Computes Positions for Nested Group Layers

The CLI-Anything layout engine computes positions for nested group layers by leveraging QGIS's built-in mapLayers() API, which automatically flattens all layers—including deeply nested group children—into a single enumerable collection, then merges their spatial extents into a unified bounding rectangle.

The HKUDS/CLI-Anything repository provides a powerful command-line interface for automating QGIS workflows. A critical component of this system is the layout engine in cli_anything/qgis/core/layouts.py, which handles the spatial positioning of map items when layers are organized into complex group hierarchies. Understanding how this engine computes positions for nested group layers is essential for building reliable, automated cartographic pipelines.

How Nested Group Layers Are Enumerated

The layout engine does not implement custom recursion to traverse group hierarchies. Instead, it relies on QGIS's native project API to handle nested structures.

In _combined_project_extent(), the engine calls project_mod.current_project().mapLayers().values() to retrieve all layers:


# From cli_anything/qgis/core/layouts.py

# Lines 62-75: Layer collection and extent merging

def _combined_project_extent():
    project = project_mod.current_project()
    extent = None
    
    for layer in project.mapLayers().values():
        if layer.type() not in (QgsMapLayer.VectorLayer, QgsMapLayer.RasterLayer):
            continue
        
        layer_extent = layer.extent()
        if extent is None:
            extent = QgsRectangle(layer_extent)
        else:
            extent.combineExtentWith(layer_extent)
    
    return extent

The key insight: project.mapLayers() returns a flat dictionary containing every layer in the project, regardless of whether it sits at the root level, inside a group, or nested multiple levels deep within subgroups. This eliminates the need for manual tree traversal.

Computing the Combined Extent for Layout Positioning

Once layers are collected, the engine derives a spatial bounding box that encompasses all geographic data. This computed extent directly determines how map items are positioned and scaled within the layout.

The Extent Merging Algorithm

For each eligible layer (vector or raster), the engine:

  1. Retrieves the layer's extent() — a QgsRectangle defining its spatial bounds
  2. Merges it into a running aggregate rectangle via combineExtentWith()

The result is a single QgsRectangle representing the union of all layer extents. This bounding box becomes the default spatial view when no explicit extent is provided to add_map_item().

User-Provided Extents vs. Automatic Computation

The layout engine supports two modes for determining map item boundaries:

Mode Method Use Case
Automatic _combined_project_extent() Show all project layers, including nested groups
Manual _parse_extent() Precise control over the visible map region

When a user supplies an extent string, _parse_extent() handles the conversion:


# Lines 88-97: Parsing extent strings

def _parse_extent(extent_str):
    """Parse 'xmin,ymin,xmax,ymax' into QgsRectangle."""
    parts = extent_str.split(',')
    if len(parts) != 4:
        raise ValueError(f"Invalid extent format: {extent_str}")
    
    xmin, ymin, xmax, ymax = map(float, parts)
    return QgsRectangle(xmin, ymin, xmax, ymax)

Positioning Map Items in the Layout

After determining the spatial extent, add_map_item() positions the map item using layout coordinates:


# Lines 64-68: Map item placement

map_item = QgsLayoutItemMap(layout)
map_item.attemptMove(QgsLayoutPoint(x, y, QgsUnitTypes.LayoutMillimeters))
map_item.resizeToContents(QgsLayoutSize(width, height, QgsUnitTypes.LayoutMillimeters))
map_item.setExtent(extent)  # From _combined_project_extent() or _parse_extent()

The positioning workflow:

  • Spatial coordinates (x, y, width, height) are specified in millimeters using QgsLayoutPoint and QgsLayoutSize
  • The extent (computed or parsed) defines what geographic area the map displays
  • layout_summary(layout) returns a stable representation of the updated layout state (lines 69-70)

Why Nested Groups Require No Special Handling

The CLI-Anything layout engine's elegance lies in its delegation to QGIS's core APIs. Because mapLayers() abstracts away the project hierarchy, the engine treats nested group layers identically to root-level layers:

  • No recursive group traversal logic
  • No special cases for subgroup depth
  • Consistent extent computation regardless of layer organization

This design ensures robust behavior across arbitrarily complex project structures while minimizing code complexity in cli_anything/qgis/core/layouts.py.

Summary

  • Layer enumeration: project.mapLayers().values() automatically flattens nested group hierarchies
  • Extent computation: _combined_project_extent() merges all layer bounds into a unified QgsRectangle
  • Positioning: Map items are placed at specified (x, y) coordinates with millimeter precision using QgsLayoutPoint
  • Fallback behavior: When no extent is provided, the engine defaults to showing all project layers
  • No custom recursion: The engine relies on QGIS's native APIs rather than implementing group-tree traversal

Frequently Asked Questions

How does CLI-Anything handle layers nested five or more levels deep?

The engine handles arbitrarily deep nesting because project.mapLayers() is implemented within QGIS itself, which recursively enumerates all layer tree nodes. CLI-Anything inherits this capability without additional code—layers at any depth in the group hierarchy are included in the extent calculation just like top-level layers.

Can I exclude specific group layers from the automatic extent calculation?

Not through _combined_project_extent() directly, which collects all project layers indiscriminately. To exclude groups, you must either provide an explicit extent string via _parse_extent(), or modify the layer visibility in the QGIS project before calling the layout engine.

What units does the layout engine use for positioning map items?

All positional parameters (x, y, width, height) use millimeters as the unit of measurement, specified through QgsUnitTypes.LayoutMillimeters when constructing QgsLayoutPoint and QgsLayoutSize objects.

Does the engine recompute extents when layers are added or removed during automation?

The extent is computed at the moment add_map_item() is called. Each invocation triggers a fresh call to _combined_project_extent() (if using automatic mode), which reflects the current state of project.mapLayers(). For dynamic workflows, ensure layer modifications are committed to the project before invoking the layout engine.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →