Skip to content

Selection — the unified .select() idiom

One fluent, daisy-chainable selection idiom across all four levels — geometry, the live mesh, the FEM broker, and results. Every .select() returns a chain whose spatial verbs (in_box, in_sphere, on_plane, crossing_plane, nearest_to, where) and set algebra (|, &, -, ^) compose, ending in a level-appropriate terminal.

v2 — BREAKING: the legacy selection surface was removed

The pre-v2 selectors — fem.nodes.get/fem.elements.get/ .resolve, the results.*.select(...).values() chain path, g.mesh_selection.add_nodes/add_elements/from_geometric, g.model.queries.select/queries.line/select_all*, g.model.selection (SelectionComposite), and the four legacy *Chain classes + GeometryChain — have been removed with no deprecation shim (project-owner-ratified full removal; no backward-compat). .select() is now the only idiom. See Migration and pending v2 successors below. The classes core._selection.Selection and viz.Selection are retained by architecture (terminal-payload / viewer-pick-result types) — but only as internal payloads, not user entry points; their package exports were dropped.

Entry points

.select() is available at six entry points across the four levels. The first call seeds the chain (by name, ids, or the full universe); subsequent verbs refine it.

Entry point Level Returns Atoms
g.model.select(...) Geometry EntitySelection (dim, tag) dimtags
g.mesh_selection.select(...) Live mesh MeshSelection node / element ids
fem.nodes.select(...) FEM broker MeshSelection node ids
fem.elements.select(...) FEM broker MeshSelection element ids
results.nodes.select(...) Results MeshSelection node ids
results.elements.select(...) Results MeshSelection element ids

fem is the FEMData snapshot from g.mesh.queries.get_fem_data(dim); results is a Results container. There are two concrete terminals — EntitySelection (entity family) and MeshSelection (point family); the five point-level .select() entry points all return MeshSelection.

The verb surface

Every chain — regardless of level — exposes the same verb names with the same signatures (the lone exception is the entity family's in_box, see Two families). Each refining verb returns a new chain of the same concrete type, so calls daisy-chain:

Verb Signature Keeps atoms that…
in_box in_box(lo, hi, *, inclusive=False) lie in the box [lo, hi) (half-open; point family)
in_sphere in_sphere(center, radius) lie within the closed ball of radius about center
on_plane on_plane(point, normal, *, tol) lie within tol of the plane (tol is required, keyword-only)
crossing_plane crossing_plane(spec, *, tol=1e-6, mode="crossing") (entity family) straddle/on test of the entity bbox vs a plane/line spec
nearest_to nearest_to(point, *, count=1) are the count nearest to point (deterministic, lowest-index tie-break)
where where(predicate) satisfy predicate(xyz) on their coordinate row

lo / hi / center / point / normal are 3-sequences. crossing_plane is an entity-family predicate; on the point family it raises TypeError (a node/element id has no bounding box — the straddle test is inexpressible and is rejected loudly, never silently empty).

on_plane has no default tolerance

tol is keyword-only with no default — always pass it explicitly, e.g. .on_plane((0, 0, 0), (0, 0, 1), tol=1e-6).

Set algebra

Two chains of the same type bound to the same engine combine with set operators or their named-method equivalents. The dedup law is insertion-order-preserving: a's order first, then b's new atoms, each atom once.

Operator Method Meaning
a \| b a.union(b) atoms in either
a & b a.intersect(b) atoms in both
a - b a.difference(b) in a, not in b
a ^ b a.symmetric_difference(b) in exactly one

Combining two different terminal types (an EntitySelection with a MeshSelection), or two MeshSelections bound to different engines (two different FEMData / Results), raises TypeError — set algebra is loud across incompatible spaces. Pair selections from one engine.

Terminals

Level Terminal Returns
g.model.select(...) .result() / .to_label(name) / .to_physical(name) / .to_dataframe() .result() → the retained-by-architecture Selection payload (.to_label/.to_physical/.tags()); .to_label/.to_physical register a Tier-1 label / Tier-2 physical group
g.mesh_selection.select(...) .result() / .ids / .coords / .save_as(name) .result() → the same-shape dict MeshSelectionSet.get_nodes/get_elements return; .ids the raw ids; .coords node coords (node level) or element centroids (element level, fail-loud); .save_as persists a named set (live-mesh engine only)
fem.nodes.select(...) .result() / .ids / .coords a NodeResult (id/coord parity with the pre-v2 broker read)
fem.elements.select(...) .result() / .groups() a GroupResult (per-type element payload; the GroupResult itself exposes .resolve() / .groups())
results.<nodes\|elements>.select(...) .values(*, component, time=None, stage=None) the existing NodeSlab / ElementSlab — the chain forwards verbatim onto the retained typed reader results.<level>.get(component=, ids=, pg=, label=, selection=)

Results selections need a component

A results chain identifies where to read; a slab read still needs what. results.<level>.select(...).values(component=...) is the terminal. Calling .result() on a results chain raises RuntimeError (a bare results selection is meaningless without a component). The typed reader results.<level>.get(component=...) is retained and is itself the successor to the removed chain .values()/ResultChain.get path.

Two families: point vs entity

The two terminals have honestly different spatial contracts. The CI contract test asserts per-family laws; it never claims cross-family identical behavior.

Point family — MeshSelection

fem.*, results.*, g.mesh_selection. Spatial verbs test node coordinates (or element centroids for element-level chains; the centroid is fail-loud — a connectivity id absent from the node set raises KeyError, never a silent row-0 substitution):

  • in_box is half-open [lo, hi) by default — an atom exactly on an upper face is excluded.
  • in_box(..., inclusive=True) switches to the closed box [lo, hi] — upper-face atoms are kept.
  • in_sphere is a closed ball; on_plane keeps atoms within tol of the plane (|(c - point)·n̂| <= tol; the normal is normalised by the caller).

Entity family — EntitySelection

g.model.select(). Atoms are (dim, tag) CAD dimtags; there is no single coordinate per entity, so the contract is entity-typed:

  • in_box delegates to gmsh.model.getEntitiesInBoundingBox — BRep bounding-box CONTAINMENT (the whole entity bbox must lie inside the query box, expanded by Geometry.Tolerance ≈ 1e-8). It is closed-ish, not an intersect and not half-open: a query box exactly equal to an entity's own extent will not contain it — enclose it comfortably.
  • in_box therefore cannot honor the half-open / inclusive= knob. Passing inclusive= (or any keyword) raises TypeError — the knob is inexpressible here and is rejected loudly, never silently ignored.
  • in_sphere / nearest_to / where use the entity bbox centre; on_plane keeps an entity iff all 8 bbox corners are within tol. For exact straddle / on semantics use crossing_plane(spec, mode="on"|"crossing") — the v2 successor to the removed g.model.queries.select(on=/crossing=) predicate (it folds the same Plane / 2-point Line / 3-point Plane spec parsing).

Examples per level

Geometry — g.model.select()

from apeGmsh import apeGmsh

with apeGmsh(model_name="frame") as g:
    g.model.geometry.add_box(0, 0, 0, 1, 1, 1, label="box")
    g.model.sync()
    faces = g.model.queries.boundary("box", dim=3, oriented=False)
    g.physical.add_surface([int(t) for _d, t in faces], name="Faces")

    # Seed by PG name (tiered name resolution, contract-locked),
    # refine with entity-family spatial verbs, register a Tier-2 PG.
    (g.model.select("Faces")                          # -> EntitySelection
        .in_box((-0.1, -0.1, -0.1), (1.1, 1.1, 1.1))  # gmsh BRep containment
        .on_plane((0, 0, 0), (0, 0, 1), tol=1e-6)
        .to_physical("Base"))

    # Exact straddle predicate (the v2 successor to queries.select):
    g.model.select("box", dim=3).crossing_plane(
        {"point": (0, 0, 0.5), "normal": (0, 0, 1)}, mode="crossing")

select(target=None, *, dim=None) accepts anything the locked geometry resolver accepts — a label / PG / part name, a bare int tag, a (dim, tag) pair, or a list. dim is the resolver's default_dim (used for bare ints and target=None), not a post-filter. .result() returns the retained Selection payload (.to_label / .to_physical / .to_dataframe are also direct terminals).

Live mesh — g.mesh_selection.select()

# After meshing, still in the live session
node_set = (g.mesh_selection.select()                  # node level
    .in_box((0, 0, 0), (1, 1, 1))                      # half-open [lo, hi)
    .on_plane((0, 0, 0), (0, 0, 1), tol=1e-9)
    .result())                                         # {'tags', 'coords'}

# Element level — atoms are element ids of `dim`, verbs use centroids
hexes = (g.mesh_selection.select(level="element", dim=3)
    .in_box((0, 0, 0), (1, 1, 1), inclusive=True)      # closed box
    .ids)

# Named, round-tripping persistence (v2): build via the idiom and
# .save_as (live-mesh engine only), or register explicit ids with the
# retained g.mesh_selection.add(dim, ids, name=).
g.mesh_selection.select().in_box((0, 0, 0), (1, 1, 1)).save_as("base")
shell = (g.mesh_selection.select(name="base")          # id-for-id the set
    .in_sphere((0.5, 0.5, 0.5), 0.4)
    .result())

select(*, level="node", dim=2, ids=None, name=None)level="element" uses dim; ids= seeds an explicit list. name= seeds id-for-id from an existing g.mesh_selection set (its node ids for level="node", its element ids for level="element"); it only reads the set store, and an unknown name fails loud. ids= and name= are mutually exclusive; with neither, the full live-mesh universe is seeded. The retained registrars g.mesh_selection.add / from_physical / filter_set / sort_set / set ops remain.

FEM broker — fem.nodes.select() / fem.elements.select()

fem = g.mesh.queries.get_fem_data(dim=3)

# Selectors (target / pg / label / tag / partition / dim) plus ids=;
# .result() is a NodeResult, .ids / .coords the raw arrays.
top = (fem.nodes.select(pg="Body")
    .in_box((0, 0, 0), (1, 1, 1))                      # half-open
    .on_plane((0, 0, 1), (0, 0, 1), tol=1e-6)
    .result())                                         # -> NodeResult

# Set algebra across two node chains on the same FEMData
a = fem.nodes.select(ids=[1, 2, 3])
b = fem.nodes.select(ids=[2, 3, 4])
both = (a | b).result()                                # union, deduped

# Elements — atoms are element ids; spatial verbs use centroids;
# .groups() / .result() yield the per-type GroupResult.
core = (fem.elements.select(pg="Body")
    .in_box((0.0, 0.0, 0.0), (0.75, 0.75, 0.75))
    .groups())                                         # -> GroupResult

Results — results.nodes.select() / results.elements.select()

# results bound to a fem (Results(..., fem=...) or results.bind(fem))
slab = (results.nodes.select(pg="Base")
    .in_box(lo, hi)                                    # half-open
    .on_plane((0, 0, 0), (0, 0, 1), tol=1e-6)
    .values(component="displacement_x"))               # -> NodeSlab

# Element results — spatial verbs operate on element centroids
forces = (results.elements.select(pg="Beams")
    .in_box(lo, hi)
    .values(component="globalForce"))                  # -> ElementSlab

results.nodes.select accepts pg / label / selection / ids; results.elements.select adds element_type=. .values(component=...) forwards verbatim onto the retained typed reader results.<level>.get(component=, ids=, pg=, label=, selection=), so it is id/value parity with that reader.

Migration from the legacy surface (v2, BREAKING)

The legacy surface was removed with no shim (owner-ratified full removal). Map every old call to its v2 successor:

Removed v2 successor
fem.nodes.get(...) / .get_ids(...) / .get_coords(...) fem.nodes.select(...).result() / .ids / .coords
fem.elements.get(...) / fem.elements.resolve(...) fem.elements.select(...).groups() / .result() → a GroupResult (whose .resolve() / .groups() replace the old fem.elements.resolve)
g.model.queries.select(target, on=/crossing=/not_*), queries.line(...), select_all* g.model.select(target).crossing_plane(spec, mode="on"\|"crossing") (same Plane / 2-pt Line / 3-pt Plane spec)
g.model.queries.select(...).result().to_label/.to_physical g.model.select(...).to_label(name) / .to_physical(name) / .to_dataframe() / .result()
results.<level>.select(...).values() chain path / ResultChain.get the retained typed reader results.<level>.get(component=, ids=, pg=, label=, selection=) — the chain's .values(component=) now forwards onto it
g.mesh_selection.add_nodes(in_box=/on_plane=/…, name=) / add_elements(...) g.mesh_selection.select(...).<spatial>.save_as(name) (live-mesh engine only) or the retained explicit-ids registrar g.mesh_selection.add(dim, ids, name=)
g.model.selection / SelectionComposite; the Selection / SelectionComposite package exports g.model.select(...) → EntitySelection. The core._selection.Selection (.result() payload) and viz.Selection (viewer pick-result) classes are retained by architecture — internal payload types, not user entry points; only their exports were dropped.
fem.nodes.get(...) for nid, xyz in … iteration for nid, xyz in fem.nodes.select(...): (the MeshSelection iterates (id, payload)); .result() preserves the documented terminal shape
Retained (NOT removed — do not migrate) g.mesh_selection.add / from_physical / filter_set / sort_set / union / intersection / difference; the typed results.<sub>.get(component=) reader; the core._selection.Selection / viz.Selection classes

Incomplete unification — pending v2 successors

v2's mandate was unification (collapse the divergent selection surface into one idiom), not capability reduction. Two capabilities came out of the removal without a v2-idiom equivalent. They are not accepted permanent gaps — they are incomplete unification and are owed v2-native successors (form / scope / priority planned; see ADR 0017 (src/apeGmsh/opensees/architecture/decisions/0017-selection-gaps-are-incomplete-unification.md) and docs/plans/selection-gaps-v3.md). The earlier "SC-12 accepted gap" framing was an over-application of a precedent meant for redundant removals to a unique-capability removal; corrected here by owner decision (2026-05-19).

  1. Geometric-selection → named mesh-selection (g.mesh_selection.from_geometric + viz.Selection.to_mesh_*). Both ends were removed. The capability is not lost — it survives as two retained calls (g.model.select(...).to_physical(name) then g.mesh_selection.from_physical(dim, name, ms_name=), or g.mesh_selection.add(dim, ids, name=)). What was lost is the one-call "arbitrary geometric pick → named persistent mesh-selection with no physical group in between" (.save_as(name) is live-mesh-engine only and persists the current chain's ids, not a geometry round-trip). Open question: whether that one-call ergonomic is worth a v2-idiom shorthand on the entity terminal — ergonomics, not a functionality loss.
  2. The SelectionComposite filter grammar (g.model.selection.select_*(labels=fnmatch / kinds= / length|area|volume_range= / predicate= / exclude_tags= / physical= / at_point=)). This is a genuine unique-capability loss: EntitySelection (g.model.select(...)) exposes only spatial verbs
  3. set algebra + to_label/to_physical/to_dataframe/result and has no declarative entity-attribute filter equivalent. The filter engine still exists (viz.Selection.filter()), but only on the viewer pick-result path — that programmatic-vs-interactive asymmetry is itself the kind of inconsistency v2 exists to eliminate, so a v2-native successor on EntitySelection (composing with the existing verbs/set-algebra; not a resurrected SelectionComposite) is planned, not declined. The deleted tests/test_selection_filters.py (33 tests, recoverable from git) is its behavioural floor.

Behavior changes (already shipped)

Two pre-existing, intentionally-pinned behavior changes shipped earlier in the program (P3-R); they are end-state here, not introduced by this docs page. Full migration text is in the changelog.

S2 — g.mesh_selection box default is half-open

The point-family in_box is half-open [lo, hi) by default (matching results); pass inclusive=True for the closed box [lo, hi].

S5 — formerly-silent paths fail loud

results with selection= on an import-origin FEMData raises RuntimeError; element-centroid computation raises KeyError on an unknown connectivity node (never a silent row-0 centroid); a loads/masses __ms__ target with no info raises KeyError instead of binding to zero nodes.

Reference

The shared base mixin (chaining + set-algebra + definition-time verb-name enforcement) and the two concrete v2 terminals. The legacy *Chain classes + GeometryChain were removed in v2; the per-family spatial behavior now lives on EntitySelection / MeshSelection over the one apeGmsh._kernel.spatial mask kernel.

apeGmsh._kernel.chain.SelectionChain

SelectionChain(atoms: Iterable[Any] = (), *, _engine: Any = None)

Mixin: chaining + set-algebra + name-enforcement.

A chain carries an ordered, de-duplicated tuple of opaque atoms (node ids, element ids, or (dim, tag) dimtags — all hashable) and an opaque _engine back-reference the subclass uses to fetch coordinates and to materialise a terminal value.

Source code in src/apeGmsh/_kernel/chain.py
def __init__(self, atoms: Iterable[Any] = (), *, _engine: Any = None) -> None:
    self._items: tuple = self._dedupe(atoms)
    self._engine = _engine

crossing_plane

crossing_plane(spec, *, tol: float = 1e-06, mode: str = 'crossing') -> 'TSelf'

Refine by a geometric straddle predicate (entity family only).

Folds the legacy queries.select(on=/crossing=/not_on=/ not_crossing=) + queries.line surface into the unified idiom. spec is the legacy _parse_primitive grammar — a dict ({'z': 0} → axis-aligned plane), two points ([(x1,y1,z1), (x2,y2,z2)] → infinite Line, the legacy queries.line 2-point path), three points (infinite plane through 3 points), or a Plane / Line instance passed through unchanged. mode selects the legacy predicate:

  • "on" — entirely on the primitive (all 8 bounding-box corners within tol);
  • "crossing" — straddles it (corners on both sides);
  • "not_on" — negation of on;
  • "not_crossing" — negation of crossing.

tol defaults to 1e-6 — byte-identical to the legacy queries.select / _select_impl default.

This is an entity-only predicate: it tests a CAD entity's bounding box for straddle, which has no meaning for the point family (a node / element id has no bounding box to straddle). The point family therefore fails loud (the in_box(inclusive=)TypeError precedent — never a silent []); only the entity family overrides :meth:_spatial_crossing.

Source code in src/apeGmsh/_kernel/chain.py
def crossing_plane(
    self: "TSelf",
    spec,
    *,
    tol: float = 1e-6,
    mode: str = "crossing",
) -> "TSelf":
    """Refine by a geometric *straddle* predicate (entity family only).

    Folds the legacy ``queries.select(on=/crossing=/not_on=/
    not_crossing=)`` + ``queries.line`` surface into the unified
    idiom.  ``spec`` is the legacy ``_parse_primitive`` grammar — a
    dict (``{'z': 0}`` → axis-aligned plane), **two** points
    (``[(x1,y1,z1), (x2,y2,z2)]`` → infinite ``Line``, the legacy
    ``queries.line`` 2-point path), **three** points (infinite plane
    through 3 points), or a ``Plane`` / ``Line`` instance passed
    through unchanged.  ``mode`` selects the legacy predicate:

    * ``"on"``           — entirely on the primitive (all 8
      bounding-box corners within ``tol``);
    * ``"crossing"``     — straddles it (corners on both sides);
    * ``"not_on"``       — negation of ``on``;
    * ``"not_crossing"`` — negation of ``crossing``.

    ``tol`` defaults to ``1e-6`` — byte-identical to the legacy
    ``queries.select`` / ``_select_impl`` default.

    This is an **entity-only** predicate: it tests a CAD entity's
    bounding box for straddle, which has no meaning for the point
    family (a node / element id has no bounding box to straddle).
    The point family therefore **fails loud** (the
    ``in_box(inclusive=)``→``TypeError`` precedent — never a silent
    ``[]``); only the entity family overrides
    :meth:`_spatial_crossing`.
    """
    return self._wrap(
        self._spatial_crossing(self._items, spec, tol=tol, mode=mode)
    )

where

where(predicate) -> 'TSelf'

Keep atoms whose coordinate row satisfies predicate.

Source code in src/apeGmsh/_kernel/chain.py
def where(self: "TSelf", predicate) -> "TSelf":
    """Keep atoms whose coordinate row satisfies ``predicate``."""
    coords = self._coords_of(self._items)
    keep = [a for a, xyz in zip(self._items, coords) if predicate(xyz)]
    return self._wrap(keep)

result

result()

Materialise the domain-specific terminal value.

Source code in src/apeGmsh/_kernel/chain.py
def result(self):
    """Materialise the domain-specific terminal value."""
    return self._materialize()

EntitySelection — entity family (geometry, (dim, tag))

apeGmsh.core._selection.EntitySelection

EntitySelection(atoms: Iterable[Any] = (), *, _engine: Any = None)

Bases: SelectionChain

Daisy-chainable + terminal CAD-entity selection (entity family).

Atoms are (dim, tag) dimtags. Constructed by the g.model.select(...) host hook (see :meth:core.Model.Model.select), which delegates all name resolution to the existing, contract-locked geometry resolver — this class never re-implements tier logic.

Every inherited verb / set-algebra / spatial hook composes; the direct terminals are .to_label / .to_physical / .to_dataframe, and .result() is a zero-cost identity alias to the :class:Selection payload (retained by architecture as the entity-side terminal type).

Example

::

(g.model.select("BottomFaces")
    .in_box((0, 0, 0), (1, 1, 0.5))
    .to_physical("lower_faces"))      # Tier-2, direct terminal
Source code in src/apeGmsh/_kernel/chain.py
def __init__(self, atoms: Iterable[Any] = (), *, _engine: Any = None) -> None:
    self._items: tuple = self._dedupe(atoms)
    self._engine = _engine

in_box

in_box(lo, hi, **kw) -> 'EntitySelection'

Refine to entities whose BRep bounding box lies in [lo, hi].

Delegates to gmsh.model.getEntitiesInBoundingBox (BRep CONTAINMENT, closed, Geometry.Tolerance ~1e-8 expanded). The point-family inclusive= half-open knob is inexpressible for the entity family and is rejected loudly (R3 / ADR precedent) — never silently ignored.

Raises

TypeError If inclusive= (or any keyword) is passed.

Source code in src/apeGmsh/core/_selection.py
def in_box(self, lo, hi, **kw) -> "EntitySelection":
    """Refine to entities whose BRep bounding box lies in ``[lo, hi]``.

    Delegates to ``gmsh.model.getEntitiesInBoundingBox`` (BRep
    CONTAINMENT, closed, ``Geometry.Tolerance`` ~1e-8 expanded).
    The point-family ``inclusive=`` half-open knob is inexpressible
    for the entity family and is rejected **loudly** (R3 / ADR
    precedent) — never silently ignored.

    Raises
    ------
    TypeError
        If ``inclusive=`` (or any keyword) is passed.
    """
    if kw:
        raise TypeError(
            "EntitySelection.in_box() does not accept "
            f"{sorted(kw)!r}. The entity family uses "
            "gmsh.model.getEntitiesInBoundingBox (BRep "
            "bbox-intersect), which is inherently closed — the "
            "half-open / 'inclusive=' knob is point-family only "
            "and inexpressible here (selection-unification R3). "
            "Drop the keyword; use .on_plane(...) / "
            ".crossing_plane(...) for an exact geometric predicate."
        )
    return self._wrap(self._spatial_box(self._items, lo, hi))

to_label

to_label(name: str) -> 'EntitySelection'

Register every entity as a Tier-1 label (_label:).

Per-dim session.labels.add(d, tags, name=name) — the boolean-op-stable, _label:-prefixed registry (ADR 0015, distinct from :meth:to_physical's raw Tier-2 PG). Behaves identically to Selection.to_label, including its multi-dim warning suppression (re-using one name across dims is the documented intent here, not a mistake). Returns self for chaining.

Source code in src/apeGmsh/core/_selection.py
def to_label(self, name: str) -> "EntitySelection":
    """Register every entity as a **Tier-1 label** (``_label:``).

    Per-dim ``session.labels.add(d, tags, name=name)`` — the
    boolean-op-stable, ``_label:``-prefixed registry (ADR 0015,
    distinct from :meth:`to_physical`'s raw Tier-2 PG).  Behaves
    identically to ``Selection.to_label``, including its multi-dim
    warning suppression (re-using one name across dims is the
    documented intent here, not a mistake).  Returns ``self`` for
    chaining.
    """
    import warnings
    session = self._session()
    dims = sorted({d for d, _ in self._items})
    with warnings.catch_warnings():
        if len(dims) > 1:
            warnings.filterwarnings(
                "ignore", message=r".*already exists at dim.*",
            )
        for d in dims:
            tags = [t for dim, t in self._items if dim == d]
            session.labels.add(d, tags, name=name)
    return self

to_physical

to_physical(name: str) -> 'EntitySelection'

Register every entity as a Tier-2 physical group (raw).

Per-dim session.physical.add(d, tags, name=name) — the raw gmsh-PG registry (ADR 0015, distinct from :meth:to_label's Tier-1 _label: registry; the two are never merged). Behaves identically to Selection.to_physical. Returns self for chaining.

Source code in src/apeGmsh/core/_selection.py
def to_physical(self, name: str) -> "EntitySelection":
    """Register every entity as a **Tier-2 physical group** (raw).

    Per-dim ``session.physical.add(d, tags, name=name)`` — the raw
    gmsh-PG registry (ADR 0015, distinct from :meth:`to_label`'s
    Tier-1 ``_label:`` registry; the two are never merged).
    Behaves identically to ``Selection.to_physical``.  Returns
    ``self`` for chaining.
    """
    session = self._session()
    for d in sorted({d for d, _ in self._items}):
        tags = [t for dim, t in self._items if dim == d]
        session.physical.add(d, tags, name=name)
    return self

to_dataframe

to_dataframe()

Return a DataFrame dim, tag, kind, label, x, y, z, mass.

Direct terminal — the core/_selection.Selection payload has no to_dataframe (only viz/Selection does). Implemented locally (no viz import — keeps the import-DAG polarity intact, R8): kind from the session's model._metadata entity registry, label from the session label reverse-map (Tier-1), x/y/z from the gmsh bounding-box centre, mass from gmsh.model.occ.getMass (length/area/volume; 0.0 for points). Mirrors viz/Selection.py's column set without importing it.

Source code in src/apeGmsh/core/_selection.py
def to_dataframe(self):
    """Return a DataFrame ``dim, tag, kind, label, x, y, z, mass``.

    Direct terminal — the ``core/_selection.Selection`` payload has
    no ``to_dataframe`` (only ``viz/Selection`` does).  Implemented
    **locally** (no ``viz`` import — keeps the import-DAG polarity
    intact, R8): ``kind`` from the session's
    ``model._metadata`` entity registry, ``label`` from the
    session label reverse-map (Tier-1), ``x/y/z`` from the gmsh
    **bounding-box centre**, ``mass`` from ``gmsh.model.occ.getMass``
    (length/area/volume; ``0.0`` for points).  Mirrors
    ``viz/Selection.py``'s column set without importing it.
    """
    import pandas as pd

    session = self._session()
    reg = getattr(session.model, "_metadata", {}) or {}
    label_map: dict = {}
    labels_comp = getattr(session, "labels", None)
    if labels_comp is not None:
        try:
            label_map = labels_comp.reverse_map()
        except Exception:
            label_map = {}

    rows = []
    for d, t in self._items:
        xmin, ymin, zmin, xmax, ymax, zmax = gmsh.model.getBoundingBox(
            int(d), int(t)
        )
        if int(d) == 0:
            mass = 0.0
        else:
            try:
                mass = float(gmsh.model.occ.getMass(int(d), int(t)))
            except Exception:
                mass = float("nan")
        info = reg.get((int(d), int(t)), {})
        rows.append({
            "dim": int(d),
            "tag": int(t),
            "kind": info.get("kind"),
            "label": label_map.get((int(d), int(t)), ""),
            "x": 0.5 * (xmin + xmax),
            "y": 0.5 * (ymin + ymax),
            "z": 0.5 * (zmin + zmax),
            "mass": mass,
        })
    return pd.DataFrame(
        rows,
        columns=["dim", "tag", "kind", "label", "x", "y", "z", "mass"],
    )

MeshSelection — point family (FEM / results / live mesh, ids)

apeGmsh.mesh._mesh_selection.MeshSelection

MeshSelection(atoms: Iterable[Any] = (), *, _engine: Any = None)

Bases: SelectionChain

Daisy-chainable + terminal point-family selection (FEM ids).

Engine-polymorphic across the four point host contexts (broker node / broker element / results / live mesh); the engine-specific bodies are relocated verbatim from the four legacy chains (P3-K), so behaviour per context is byte-faithful (the selection-unification-v2 P2-I invisibility contract, carried through the P3-K collapse). The unified surface (pair-view __iter__, .ids/.coords/.connectivity/.groups()/.values()/ .result()/.save_as) lives here.

Source code in src/apeGmsh/_kernel/chain.py
def __init__(self, atoms: Iterable[Any] = (), *, _engine: Any = None) -> None:
    self._items: tuple = self._dedupe(atoms)
    self._engine = _engine

ids property

ids: list[int]

The selected ids (node ids or element ids) as Python ints.

coords property

coords: ndarray

(N, 3) float64 coordinates of the selected ids.

Node level → node coordinates; element level → element centroids (the same fail-loud centroid the legacy element / results / live chains compute — never a silent row-0).

connectivity property

connectivity: ndarray

Connectivity of the selected elements (element level).

Reuses the materialised element payload, so the shape / homogeneous-vs-mixed behaviour is byte-identical to the legacy chain's .result() for that engine:

  • broker / results element → GroupResult.connectivity (raises TypeError for a mixed-type result, by design — use .groups() / iterate);
  • live-mesh element → the live connectivity ndarray.

groups

groups()

The per-type element blocks for an element selection.

Returns the GroupResult (broker / results element) — list(sel.groups()) yields the ElementGroup blocks, preserving per-type element_type (needed by the OpenSees emitter / beam viewer; R3-B / R-v2-4). For the live-mesh element engine (whose terminal is a flat dict, not a GroupResult) returns that same dict — byte-identical to the legacy MeshSelectionChain.result().

Source code in src/apeGmsh/mesh/_mesh_selection.py
def groups(self):
    """The per-type element blocks for an element selection.

    Returns the ``GroupResult`` (broker / results element) —
    ``list(sel.groups())`` yields the ``ElementGroup`` blocks,
    preserving per-type ``element_type`` (needed by the OpenSees
    emitter / beam viewer; R3-B / R-v2-4).  For the live-mesh
    element engine (whose terminal is a flat dict, not a
    ``GroupResult``) returns that same dict — byte-identical to the
    legacy ``MeshSelectionChain.result()``.
    """
    if self._level != "element":
        raise TypeError(
            "groups() is element-level only; this selection is "
            "node-level."
        )
    return self._materialize()

values

values(*, component: str, time=None, stage=None, **extra)

Read the result slab for the selected ids (results engine).

Verbatim behaviour of the legacy ResultChain.get — it forwards host.get(ids=list(self._items), component=, time=, stage=, **extra) to the spawning sub-composite's retained .get (the typed results reader → Results._reader.read_* + _resolve_*_ids; SC-2 — only the chain .values() path is the P3-R removal target, the composite reader stays). **extra is forwarded opaquely (gp_indices= / layer_indices= for the fibers / layers sub-composites); this method never names gp_indices / layer_indices — the spawning .get signature stays the single source of truth (R5; the locked test_result_chain_subcomposites fail-loud invariant — an unknown kwarg fails loud there, not silently dropped here).

Only valid on the results engine; on the broker / live engines a results read is meaningless (no component reader) → fail loud, exactly as the legacy ResultChain vs broker / live-mesh terminals differ.

Source code in src/apeGmsh/mesh/_mesh_selection.py
def values(self, *, component: str, time=None, stage=None, **extra):
    """Read the result slab for the selected ids (results engine).

    **Verbatim** behaviour of the legacy ``ResultChain.get`` — it
    forwards ``host.get(ids=list(self._items), component=,
    time=, stage=, **extra)`` to the spawning sub-composite's
    **retained** ``.get`` (the typed results reader →
    ``Results._reader.read_*`` + ``_resolve_*_ids``; SC-2 — only the
    *chain* ``.values()`` path is the P3-R removal target, the
    composite reader stays).  ``**extra`` is forwarded opaquely
    (``gp_indices=`` / ``layer_indices=`` for the fibers / layers
    sub-composites); this method **never names** ``gp_indices`` /
    ``layer_indices`` — the spawning ``.get`` signature stays the
    single source of truth (R5; the locked
    ``test_result_chain_subcomposites`` fail-loud invariant — an
    unknown kwarg fails loud *there*, not silently dropped here).

    Only valid on the results engine; on the broker / live engines
    a results read is meaningless (no component reader) → fail
    loud, exactly as the legacy ``ResultChain`` vs broker /
    live-mesh terminals differ.
    """
    if _engine_kind(self._engine) != "result":
        raise RuntimeError(
            ".values(component=...) reads a RESULT slab and is only "
            "valid on a results selection "
            "(results.<nodes|elements|...>.select(...)). This "
            "selection is over a "
            f"{_engine_kind(self._engine)!r} engine — use "
            ".result() / .ids / .coords for the broker / live-mesh "
            "terminal instead."
        )
    host = self._engine.host
    return host.get(
        ids=list(self._items),
        component=component,
        time=time,
        stage=stage,
        **extra,
    )

save_as

save_as(name: str) -> 'MeshSelection'

Register the current id set into the mesh-selection store.

Reuses the existing registration surface MeshSelectionSet.add(dim, ids, name=name) (no reinvented store): that writes _sets_snapshot()MeshSelectionStore → FEMData HDF5, so the named set round-trips and becomes addressable as selection= (the docs/plans/selection-unification-v2.md §6 P2-I .save_as contract). Returns self for chaining.

Reachability (source-proven; see ADR 0015 / the v2 plan): the mutable mesh-selection store is the live g.mesh_selection (MeshSelectionSet). Only the live-mesh engine carries it (_LiveMeshEngine.ms). The broker-node / broker-element / results engines hold no mutable MeshSelectionSet — a FEMData carries only the immutable, read-only MeshSelectionStore snapshot (no .add), and is routinely a detached / import-origin object with no live gmsh session at all. There is no non-reinventing way to register from those engines, so .save_as is present-but-loud there (the in_box inclusive=TypeError precedent: explicit fail, never a silent no-op or a fake parallel store). The legacy MeshSelectionChain had no .save_as at all, so this is strictly additive and breaks no P2-I parity.

Source code in src/apeGmsh/mesh/_mesh_selection.py
def save_as(self, name: str) -> "MeshSelection":
    """Register the current id set into the mesh-selection store.

    Reuses the **existing** registration surface
    ``MeshSelectionSet.add(dim, ids, name=name)`` (no reinvented
    store): that writes ``_sets`` → ``_snapshot()`` →
    ``MeshSelectionStore`` → FEMData HDF5, so the named set
    round-trips and becomes addressable as ``selection=`` (the
    ``docs/plans/selection-unification-v2.md`` §6 P2-I
    ``.save_as`` contract).  Returns ``self`` for chaining.

    Reachability (source-proven; see ADR 0015 / the v2 plan): the
    mutable mesh-selection store is the live ``g.mesh_selection``
    (``MeshSelectionSet``).  Only the **live-mesh** engine carries
    it (``_LiveMeshEngine.ms``).  The broker-node / broker-element
    / results engines hold no mutable ``MeshSelectionSet`` — a
    ``FEMData`` carries only the *immutable, read-only*
    ``MeshSelectionStore`` snapshot (no ``.add``), and is routinely
    a detached / import-origin object with no live gmsh session at
    all.  There is no non-reinventing way to register from those
    engines, so ``.save_as`` is **present-but-loud** there (the
    ``in_box`` ``inclusive=``→``TypeError`` precedent: explicit
    fail, never a silent no-op or a fake parallel store).  The
    legacy ``MeshSelectionChain`` had no ``.save_as`` at all, so
    this is strictly additive and breaks no P2-I parity.
    """
    kind = _engine_kind(self._engine)
    if kind != "live":
        raise RuntimeError(
            ".save_as(name) registers into the live mesh-selection "
            "store (g.mesh_selection / MeshSelectionSet), which "
            f"only the live-mesh engine carries. This selection is "
            f"over a {kind!r} engine: a FEMData / Results holds "
            "only the immutable read-only MeshSelectionStore "
            "snapshot (no registration surface) and may have no "
            "live gmsh session. Build the selection via "
            "g.mesh_selection.select(...) to use .save_as, or "
            "register through the existing g.mesh_selection "
            "surface (add / from_geometric) before snapshotting."
        )
    ms = self._engine.ms
    dim = 0 if self._level == "node" else int(self._engine.dim)
    ms.add(dim, self.ids, name=name)
    return self