Skip to content

Model — g.model

OCC geometry composite. Five focused sub-composites: geometry, boolean, transforms, io, queries.

g.model

apeGmsh.core.Model.Model

Model(parent: '_SessionBase')

Bases: _HasLogging

Geometry composite attached to an apeGmsh instance as g.model. Owns five focused sub-composites:

  • g.model.geometry — point / curve / surface / solid primitives
  • g.model.boolean — fuse, cut, intersect, fragment
  • g.model.transforms — translate, rotate, scale, mirror, copy, extrude, revolve, sweep, thru_sections
  • g.model.io — load/save STEP, IGES, DXF, MSH, heal_shapes
  • g.model.queries — bounding_box, center_of_mass, mass, boundary, adjacencies, entities_in_bounding_box, registry

Plus entity selection:

  • g.model.select(...) — fluent spatial entity selection

And top-level utilities on the Model itself:

  • g.model.sync() — flush the OCC kernel
  • g.model.viewer() — open the interactive Qt viewer
  • g.model.render(path) — offscreen BRep still (ADR 0094 S4)
  • g.model.gui() / g.model.launch_picker() — native Gmsh viewers

Example

::

# Solid boolean workflow
box  = g.model.geometry.add_box(0, 0, 0, 10, 10, 10)
hole = g.model.geometry.add_cylinder(5, 5, 0, 0, 0, 10, 2)
part = g.model.boolean.cut(box, hole)

# Wire-frame -> surface workflow
p1   = g.model.geometry.add_point(0, 0, 0)
p2   = g.model.geometry.add_point(10, 0, 0)
p3   = g.model.geometry.add_point(10, 5, 0)
p4   = g.model.geometry.add_point(0, 5, 0)
l1   = g.model.geometry.add_line(p1, p2)
l2   = g.model.geometry.add_line(p2, p3)
l3   = g.model.geometry.add_line(p3, p4)
l4   = g.model.geometry.add_line(p4, p1)
loop = g.model.geometry.add_curve_loop([l1, l2, l3, l4])
surf = g.model.geometry.add_plane_surface(loop)

Parameters

parent : _SessionBase Owning session — used to read _verbose and name.

Source code in src/apeGmsh/core/Model.py
def __init__(self, parent: "_SessionBase") -> None:
    self._parent = parent
    # (dim, tag) -> {kind, ...}  (labels live in g.labels, not here)
    self._metadata: dict[DimTag, dict] = {}

    # Five focused sub-composites — each one holds a reference to self
    self.geometry = _Geometry(self)
    self.boolean = _Boolean(self)
    self.transforms = _Transforms(self)
    self.io = _IO(self)
    self.queries = _Queries(self)

sync

sync() -> 'Model'

Synchronise the OCC kernel with the gmsh model topology.

Call this explicitly when you have been batching operations with sync=False. Returns self for chaining.

Source code in src/apeGmsh/core/Model.py
def sync(self) -> "Model":
    """
    Synchronise the OCC kernel with the gmsh model topology.

    Call this explicitly when you have been batching operations with
    ``sync=False``.  Returns ``self`` for chaining.
    """
    gmsh.model.occ.synchronize()
    self._log("OCC kernel synchronised")
    return self

select

select(target=None, *, dim: int | None = None)

Select geometry entities (faces, curves, volumes, points) to label or group them before meshing.

Use this to identify geometry for physical groups, boundary conditions, or mesh sizing. Results are consumed with .to_label() / .to_physical() / .to_dataframe()::

# mark all bottom faces as a label for later use
(g.model.select("BottomFaces")
    .in_box((0, 0, 0), (10, 10, 0.01))
    .to_label("base"))

# all surfaces that the z=1.5 plane crosses
(g.model.select(None, dim=2)
    .crossing_plane({'z': 1.5}))

# all surfaces that straddle a plane through 3 points
(g.model.select(None, dim=2)
    .crossing_plane([(0,0,0), (1,0,0), (0,1,0)]))

Returns an :class:~apeGmsh.core._selection.EntitySelection (entity family) that chains spatial-refinement verbs and terminates at .to_label() / .to_physical() / .to_dataframe(). .result() is an alias that yields the payload directly.

.. note:: Entity family.in_box tests BRep bounding-box containment (always closed, ~1e-8 tolerance), not centroids. Passing inclusive= raises TypeError. Use .on_plane(...) or .crossing_plane(...) for exact boundary predicates. For mesh-level centroid-based selection use :meth:fem.nodes.select / :meth:fem.elements.select.

Parameters

target : Label name, physical group name, part name, (dim, tag) pair, raw int tag, or a list thereof. A string resolves through label → PG → part name in that order. Pass None (with dim=) to select every entity at that dimension. dim : Topological dimension for bare int tags and target=None (0=point, 1=curve, 2=surface, 3=volume). A label / physical group / part enumerates every dimension it occupies; dim is not a post-filter. When dim is given explicitly and the target has no entity at that dimension, select raises (rather than silently returning another dimension) — chain .boundary() to walk a volume down to its faces. Defaults to 3 when omitted (bare int tags / target=None).

Refining verbs

Each returns a new EntitySelection and composes freely.

  • .in_box(lo, hi) — entities whose BRep bbox falls inside the query box (always closed, ~1e-8 expanded). No inclusive= kwarg.
  • .in_sphere(center, radius)
  • .on_plane(point, normal, *, tol) — entities entirely on the plane within tol.
  • .crossing_plane(spec, *, tol=1e-6, mode="crossing") — entities that straddle, lie on, or avoid a geometric primitive.

spec accepts:

.. code-block:: python

  {'z': 0}                          # axis-aligned plane
  {'x': 3.5}                        # axis-aligned plane
  [(0,0,0), (1,0,0), (0,1,0)]       # plane through 3 pts
  [(0,0,0), (0,0,1)]                # infinite line, 2 pts

mode:

  • "crossing" (default) — straddles the primitive (corners on both sides).
  • "on" — lies entirely on the primitive (all corners within tol).
  • "not_crossing" / "not_on" — negations.

  • .nearest_to(point, *, count=1)

  • .where(predicate)
  • | & - ^ (set algebra).
Terminals
  • .to_label(name) — assign the selection as a label.
  • .to_physical(name) — assign as a physical group.
  • .to_dataframe()DataFrame with dim/tag columns.
  • .result() — raw :class:~apeGmsh.core._selection.Selection payload (also exposes .tags() / .to_label() / .to_physical()).
Source code in src/apeGmsh/core/Model.py
def select(self, target=None, *, dim: int | None = None):
    """Select geometry entities (faces, curves, volumes, points)
    to label or group them before meshing.

    Use this to identify geometry for physical groups, boundary
    conditions, or mesh sizing.  Results are consumed with
    ``.to_label()`` / ``.to_physical()`` / ``.to_dataframe()``::

        # mark all bottom faces as a label for later use
        (g.model.select("BottomFaces")
            .in_box((0, 0, 0), (10, 10, 0.01))
            .to_label("base"))

        # all surfaces that the z=1.5 plane crosses
        (g.model.select(None, dim=2)
            .crossing_plane({'z': 1.5}))

        # all surfaces that straddle a plane through 3 points
        (g.model.select(None, dim=2)
            .crossing_plane([(0,0,0), (1,0,0), (0,1,0)]))

    Returns an :class:`~apeGmsh.core._selection.EntitySelection`
    (entity family) that chains spatial-refinement verbs and
    terminates at ``.to_label()`` / ``.to_physical()`` /
    ``.to_dataframe()``.  ``.result()`` is an alias that yields
    the payload directly.

    .. note::
        **Entity family** — ``.in_box`` tests BRep **bounding-box
        containment** (always closed, ~1e-8 tolerance), *not*
        centroids.  Passing ``inclusive=`` raises ``TypeError``.
        Use ``.on_plane(...)`` or ``.crossing_plane(...)`` for
        exact boundary predicates.  For mesh-level centroid-based
        selection use :meth:`fem.nodes.select` /
        :meth:`fem.elements.select`.

    Parameters
    ----------
    target :
        Label name, physical group name, part name,
        ``(dim, tag)`` pair, raw int tag, or a list thereof.
        A string resolves through label → PG → part name in
        that order.  Pass ``None`` (with ``dim=``) to select
        every entity at that dimension.
    dim :
        Topological dimension for bare int tags and
        ``target=None`` (0=point, 1=curve, 2=surface,
        3=volume).  A label / physical group / part enumerates
        every dimension it occupies; ``dim`` is **not** a
        post-filter.  When ``dim`` is given explicitly and the
        target has **no** entity at that dimension, ``select``
        **raises** (rather than silently returning another
        dimension) — chain ``.boundary()`` to walk a volume down
        to its faces.  Defaults to 3 when omitted (bare int tags /
        ``target=None``).

    Refining verbs
    --------------
    Each returns a new ``EntitySelection`` and composes freely.

    - ``.in_box(lo, hi)`` — entities whose BRep bbox falls inside
      the query box (always closed, ~1e-8 expanded).  No
      ``inclusive=`` kwarg.
    - ``.in_sphere(center, radius)``
    - ``.on_plane(point, normal, *, tol)`` — entities entirely on
      the plane within ``tol``.
    - ``.crossing_plane(spec, *, tol=1e-6, mode="crossing")`` —
      entities that straddle, lie on, or avoid a geometric
      primitive.

      ``spec`` accepts:

      .. code-block:: python

          {'z': 0}                          # axis-aligned plane
          {'x': 3.5}                        # axis-aligned plane
          [(0,0,0), (1,0,0), (0,1,0)]       # plane through 3 pts
          [(0,0,0), (0,0,1)]                # infinite line, 2 pts

      ``mode``:

      - ``"crossing"`` *(default)* — straddles the primitive
        (corners on both sides).
      - ``"on"`` — lies entirely on the primitive (all corners
        within ``tol``).
      - ``"not_crossing"`` / ``"not_on"`` — negations.

    - ``.nearest_to(point, *, count=1)``
    - ``.where(predicate)``
    - ``|`` ``&`` ``-`` ``^`` (set algebra).

    Terminals
    ---------
    - ``.to_label(name)`` — assign the selection as a label.
    - ``.to_physical(name)`` — assign as a physical group.
    - ``.to_dataframe()`` — ``DataFrame`` with dim/tag columns.
    - ``.result()`` — raw :class:`~apeGmsh.core._selection.Selection`
      payload (also exposes ``.tags()`` / ``.to_label()`` /
      ``.to_physical()``).
    """
    # Deferred import — the established idiom (mirrors
    # mesh/_mesh_structured.py).  ``_selection`` is same-package, so
    # this adds no eager cross-package edge
    # (tests/test_import_dag_polarity.py stays green with the
    # baseline unchanged).
    from ._helpers import resolve_to_dimtags
    from ._selection import EntitySelection

    if target is None and dim is None:
        raise ValueError(
            "model.select(): pass a target (label / PG / part / "
            "(dim, tag) / int / list) or a dim= to select every "
            "entity at that dimension."
        )
    dimtags = resolve_to_dimtags(
        target,
        default_dim=3 if dim is None else dim,
        session=self._parent,
    )
    # ``dim=`` is not a post-filter: a label / PG / part resolves to
    # the dimension(s) it occupies, ignoring ``dim=``.  When an
    # explicit ``dim=`` matches none of them, returning the other
    # dimension silently is the reported footgun (``select("soil",
    # dim=2)`` on a volume yields the volume, so ``.to_physical(...)``
    # registers a *volume* group).  Fail loud instead.
    if dim is not None and dimtags:
        present = sorted({d for d, _ in dimtags})
        if dim not in present:
            raise ValueError(
                f"model.select({target!r}, dim={dim}): the target "
                f"resolves to geometry at dimension(s) {present}, none "
                f"at dim={dim}. `dim=` is not a post-filter — a label / "
                f"physical group / part enumerates the dimension(s) it "
                f"occupies. To get the boundary faces of a volume chain "
                f"`.boundary()` (e.g. "
                f"`select({target!r}).boundary().on_plane(...)`); to "
                f"select every entity at a dimension pass "
                f"`target=None` (e.g. `select(None, dim={dim})`)."
            )
    # selection-unification-v2: the host hook returns the v2
    # terminal ``EntitySelection`` (the entity-family
    # chain==terminal). Same deferred-import idiom; no new eager
    # cross-package edge.
    return EntitySelection(dimtags, _engine=self.queries)

viewer

viewer(**kwargs)

Open the interactive Qt model viewer.

Displays BRep geometry with selectable entities, parts, physical groups, and labels. This is a geometry-only viewer — loads, constraints, and masses are mesh-resolved concepts and live on g.mesh.viewer() instead.

Parameters

**kwargs : Forwarded to :class:~apeGmsh.viewers.model_viewer.ModelViewer (e.g. physical_group, dims, point_size, line_width, surface_opacity).

Source code in src/apeGmsh/core/Model.py
def viewer(self, **kwargs):
    """Open the interactive Qt model viewer.

    Displays BRep geometry with selectable entities, parts,
    physical groups, and labels.  This is a **geometry-only**
    viewer — loads, constraints, and masses are mesh-resolved
    concepts and live on ``g.mesh.viewer()`` instead.

    Parameters
    ----------
    **kwargs :
        Forwarded to
        :class:`~apeGmsh.viewers.model_viewer.ModelViewer`
        (e.g. ``physical_group``, ``dims``, ``point_size``,
        ``line_width``, ``surface_opacity``).
    """
    # selection-unification v2 P3-R / §6.3 §4 SC-7: inline the
    # former ``self.selection.picker(**kwargs)`` (SelectionComposite
    # removed by M-STOP-2).  ``model=self`` is the identical object
    # the deleted ``picker`` passed as ``model=self._model``.
    from apeGmsh.viewers.model_viewer import ModelViewer
    p = ModelViewer(parent=self._parent, model=self, **kwargs)
    p.show()
    return p

render

render(path: 'str | Path', *, camera: str = 'iso', window_size: tuple[int, int] = (1280, 720)) -> 'Path | None'

Write one offscreen BRep still (ADR 0094 S4).

Live session only. Uses build_brep_scene (same tessellation as :meth:viewer, including a throwaway coarse 2-D mesh when none exists). VTK offscreen — no Qt window, no event loop.

Returns the written :class:~pathlib.Path, or None (and prints the [skip viewer] notice) under APEGMSH_SKIP_VIEWER=1 or with no GL. Raises RuntimeError on a from_h5 / closed session.

Source code in src/apeGmsh/core/Model.py
def render(
    self,
    path: "str | Path",
    *,
    camera: str = "iso",
    window_size: tuple[int, int] = (1280, 720),
) -> "Path | None":
    """Write one offscreen BRep still (ADR 0094 S4).

    Live session only. Uses ``build_brep_scene`` (same tessellation
    as :meth:`viewer`, including a throwaway coarse 2-D mesh when
    none exists). VTK offscreen — no Qt window, no event loop.

    Returns the written :class:`~pathlib.Path`, or ``None`` (and
    prints the ``[skip viewer]`` notice) under
    ``APEGMSH_SKIP_VIEWER=1`` or with no GL. Raises
    ``RuntimeError`` on a ``from_h5`` / closed session.
    """
    from apeGmsh.viewers.render import render_model

    return render_model(
        self._parent, path, camera=camera, window_size=window_size,
    )

preview

preview(*, dims: list[int] | None = None, browser: bool = False, return_fig: bool = False)

Interactive WebGL preview of the BRep geometry.

Zero Qt dependency — works inline in Jupyter / VS Code / Colab, or in a dedicated browser tab when browser=True. Hover over a cell to see its dim and tag.

Parameters

dims : list of int, optional BRep dimensions to render. Defaults to [0, 1, 2, 3]. browser : bool If True, open in a new browser tab (temp HTML file) instead of rendering inline. Useful when the notebook output is cluttered or you want a dedicated window. return_fig : bool If True, skip display and return the raw :class:plotly.graph_objects.Figure for saving with fig.write_html('path.html') or composing a notebook layout.

Source code in src/apeGmsh/core/Model.py
def preview(
    self,
    *,
    dims: list[int] | None = None,
    browser: bool = False,
    return_fig: bool = False,
):
    """Interactive WebGL preview of the BRep geometry.

    Zero Qt dependency — works inline in Jupyter / VS Code / Colab,
    or in a dedicated browser tab when ``browser=True``. Hover over
    a cell to see its ``dim`` and ``tag``.

    Parameters
    ----------
    dims : list of int, optional
        BRep dimensions to render. Defaults to ``[0, 1, 2, 3]``.
    browser : bool
        If ``True``, open in a new browser tab (temp HTML file)
        instead of rendering inline. Useful when the notebook
        output is cluttered or you want a dedicated window.
    return_fig : bool
        If ``True``, skip display and return the raw
        :class:`plotly.graph_objects.Figure` for saving with
        ``fig.write_html('path.html')`` or composing a notebook
        layout.
    """
    from apeGmsh.viz.NotebookPreview import preview_model
    return preview_model(
        self._parent,
        dims=dims,
        browser=browser,
        return_fig=return_fig,
    )

gui

gui() -> None

Open the interactive Gmsh FLTK GUI window.

Source code in src/apeGmsh/core/Model.py
def gui(self) -> None:
    """Open the interactive Gmsh FLTK GUI window."""
    gmsh.fltk.run()

launch_picker

launch_picker(*, show_points: bool = True, show_curves: bool = True, show_surfaces: bool = True, show_volumes: bool = False, verbose: bool = True) -> None

Open Gmsh's native FLTK viewer with entity labels pre-enabled.

Source code in src/apeGmsh/core/Model.py
def launch_picker(
    self,
    *,
    show_points: bool = True,
    show_curves: bool = True,
    show_surfaces: bool = True,
    show_volumes: bool = False,
    verbose: bool = True,
) -> None:
    """Open Gmsh's native FLTK viewer with entity labels pre-enabled."""
    gmsh.model.occ.synchronize()
    gmsh.option.setNumber("Geometry.PointLabels",   int(show_points))
    gmsh.option.setNumber("Geometry.CurveLabels",   int(show_curves))
    gmsh.option.setNumber("Geometry.SurfaceLabels", int(show_surfaces))
    gmsh.option.setNumber("Geometry.VolumeLabels",  int(show_volumes))
    gmsh.option.setNumber("Geometry.Points",   1)
    gmsh.option.setNumber("Geometry.Curves",   1)
    gmsh.option.setNumber("Geometry.Surfaces", 1)
    if verbose:
        print("[launch_picker] Opening Gmsh FLTK window.")
        print("  Labels visible — read tags off the 3D view.")
        print("  Close the window to return here.")
    gmsh.fltk.run()

Sub-composites

g.model.geometry

apeGmsh.core._model_geometry._Geometry

_Geometry(model: 'Model')

Points, curves, surfaces, and solid primitive creation methods.

Source code in src/apeGmsh/core/_model_geometry.py
def __init__(self, model: "Model") -> None:
    self._model = model

add_point

add_point(x: float, y: float, z: float, *, mesh_size: float = 0.0, lc: float | None = None, label: str | None = None, sync: bool = True) -> Tag

Add a single point.

Parameters

x, y, z : coordinates mesh_size : target element size at this point (0 = use global size) lc : alias for mesh_size (Gmsh characteristic length)

Returns

int tag of the new point.

Source code in src/apeGmsh/core/_model_geometry.py
def add_point(
    self,
    x: float, y: float, z: float,
    *,
    mesh_size: float      = 0.0,
    lc       : float | None = None,
    label    : str | None = None,
    sync     : bool       = True,
) -> Tag:
    """
    Add a single point.

    Parameters
    ----------
    x, y, z   : coordinates
    mesh_size : target element size at this point (0 = use global size)
    lc        : alias for *mesh_size* (Gmsh characteristic length)

    Returns
    -------
    int tag of the new point.
    """
    if lc is not None:
        mesh_size = lc
    tag = gmsh.model.occ.addPoint(x, y, z, meshSize=mesh_size)
    if sync:
        gmsh.model.occ.synchronize()
    self._model._log(f"add_point({x}, {y}, {z}) -> tag {tag}")
    return self._model._register(0, tag, label, 'point')

add_line

add_line(start: EntityRef, end: EntityRef, *, label: str | None = None, sync: bool = True) -> Tag

Add a straight line segment between two existing points.

Parameters

start, end : point references — raw tag, label name, physical group, part label, or (dim, tag). Each must resolve to exactly one point.

Source code in src/apeGmsh/core/_model_geometry.py
def add_line(
    self,
    start: EntityRef,
    end  : EntityRef,
    *,
    label: str | None = None,
    sync : bool       = True,
) -> Tag:
    """
    Add a straight line segment between two existing points.

    Parameters
    ----------
    start, end : point references — raw tag, label name, physical
        group, part label, or ``(dim, tag)``.  Each must resolve to
        exactly one point.
    """
    start = self._resolve_entity_tag(start, dim=0, what="point")
    end   = self._resolve_entity_tag(end,   dim=0, what="point")
    tag = gmsh.model.occ.addLine(start, end)
    if sync:
        gmsh.model.occ.synchronize()
    self._model._log(f"add_line({start} -> {end}) -> tag {tag}")
    return self._model._register(1, tag, label, 'line')

add_imperfect_line

add_imperfect_line(start: EntityRef, end: EntityRef, *, magnitude: float = 0.0, direction: tuple[float, float, float], shape: Literal['kink', 'sine', 'multi_mode'] = 'kink', n_segments: int = 8, modes: list[tuple[int, float]] | None = None, label: str | None = None, sync: bool = True) -> list[Tag]

Add a line with a built-in geometric imperfection.

Used for seeding initial out-of-straightness on columns, struts, or braces before running a corotational / nonlinear buckling analysis. The imperfection is baked into the geometry as a polyline through intermediate points — there is no solver-side perturbation. The resulting line segments are a drop-in replacement for a single :meth:add_line call and can be grouped into a single physical group via m.physical.add_curve(tags=[...]).

Parameters

start, end : point tags Endpoints of the imperfect line (straight-line length L). magnitude : float Peak perpendicular offset of the imperfection envelope. Typical engineering choices are L/500 to L/1000. Ignored when shape='multi_mode' — amplitudes come from the per-mode entries of modes. direction : (dx, dy, dz) Direction hint for the offset. The vector is projected onto the plane perpendicular to the line axis and normalized. Only the perpendicular component matters; e.g. for a diagonal brace you can pass (0, 1, 0) to request an out-of-plane-Y offset and the method takes care of orthogonality. Raises ValueError if the vector is parallel to the line axis. shape : str * 'kink' — single midspan intermediate point, two line segments. Produces a triangular bent; pedagogically clean but not physically smooth. * 'sine' — half-sine envelope y(s) = magnitude · sin(π·s/L) discretized into n_segments pieces. Matches the first Euler buckling mode exactly; recommended for quantitative work. * 'multi_mode' — superposition of multiple sinusoidal modes, y(s) = Σ_k a_k · sin(k·π·s/L) where (k, a_k) pairs come from modes. Used to seed more than one buckling mode at once. n_segments : int Number of line pieces the imperfect line is split into. Only meaningful for 'sine' (default 8) and 'multi_mode' (default 8). 'kink' always uses exactly 2 segments regardless of this value. modes : list[(int, float)], optional Required when shape='multi_mode'. Each entry is a (mode_number, amplitude) pair. The mode number k must be a positive integer; the amplitude is absolute (not relative to magnitude). label : str, optional Label applied to all resulting line segments. The intermediate interior points remain anonymous (no labels). sync : bool Whether to synchronise the OCC kernel after creation.

Returns

list[Tag] Line tags in geometric order from start to end. Pass the list directly to m.physical.add_curve.

Examples

Kinked brace with an L/1000 midspan offset in the global-Y direction::

tags = m.model.geometry.add_imperfect_line(
    p_base, p_top,
    magnitude=L_brace/1000,
    direction=(0, 1, 0),
    shape='kink',
    label='brace',
)

Half-sine imperfection discretised into 16 segments::

tags = m.model.geometry.add_imperfect_line(
    p1, p2,
    magnitude=L/500,
    direction=(1, 0, 0),
    shape='sine',
    n_segments=16,
    label='column',
)

First + third mode seeding::

tags = m.model.geometry.add_imperfect_line(
    p1, p2,
    direction=(0, 0, 1),
    shape='multi_mode',
    modes=[(1, L/1000), (3, L/5000)],
    n_segments=24,
)
Source code in src/apeGmsh/core/_model_geometry.py
def add_imperfect_line(
    self,
    start: EntityRef,
    end  : EntityRef,
    *,
    magnitude : float = 0.0,
    direction : tuple[float, float, float],
    shape     : Literal['kink', 'sine', 'multi_mode'] = 'kink',
    n_segments: int = 8,
    modes     : list[tuple[int, float]] | None = None,
    label     : str | None = None,
    sync      : bool = True,
) -> list[Tag]:
    """
    Add a line with a built-in geometric imperfection.

    Used for seeding initial out-of-straightness on columns, struts,
    or braces before running a corotational / nonlinear buckling
    analysis. The imperfection is baked into the **geometry** as a
    polyline through intermediate points — there is no solver-side
    perturbation. The resulting line segments are a drop-in
    replacement for a single :meth:`add_line` call and can be
    grouped into a single physical group via
    ``m.physical.add_curve(tags=[...])``.

    Parameters
    ----------
    start, end : point tags
        Endpoints of the imperfect line (straight-line length L).
    magnitude : float
        Peak perpendicular offset of the imperfection envelope.
        Typical engineering choices are ``L/500`` to ``L/1000``.
        Ignored when ``shape='multi_mode'`` — amplitudes come from
        the per-mode entries of ``modes``.
    direction : (dx, dy, dz)
        Direction hint for the offset. The vector is projected onto
        the plane perpendicular to the line axis and normalized.
        Only the perpendicular component matters; e.g. for a
        diagonal brace you can pass ``(0, 1, 0)`` to request an
        out-of-plane-Y offset and the method takes care of
        orthogonality. Raises ``ValueError`` if the vector is
        parallel to the line axis.
    shape : str
        * ``'kink'``   — single midspan intermediate point, two
          line segments. Produces a triangular bent; pedagogically
          clean but not physically smooth.
        * ``'sine'``   — half-sine envelope
          ``y(s) = magnitude · sin(π·s/L)`` discretized into
          ``n_segments`` pieces. Matches the first Euler buckling
          mode exactly; recommended for quantitative work.
        * ``'multi_mode'`` — superposition of multiple sinusoidal
          modes, ``y(s) = Σ_k  a_k · sin(k·π·s/L)`` where
          ``(k, a_k)`` pairs come from ``modes``. Used to seed
          more than one buckling mode at once.
    n_segments : int
        Number of line pieces the imperfect line is split into.
        Only meaningful for ``'sine'`` (default 8) and
        ``'multi_mode'`` (default 8). ``'kink'`` always uses
        exactly 2 segments regardless of this value.
    modes : list[(int, float)], optional
        Required when ``shape='multi_mode'``. Each entry is a
        ``(mode_number, amplitude)`` pair. The mode number ``k``
        must be a positive integer; the amplitude is absolute (not
        relative to ``magnitude``).
    label : str, optional
        Label applied to *all* resulting line segments. The
        intermediate interior points remain anonymous (no labels).
    sync : bool
        Whether to synchronise the OCC kernel after creation.

    Returns
    -------
    list[Tag]
        Line tags in geometric order from ``start`` to ``end``.
        Pass the list directly to ``m.physical.add_curve``.

    Examples
    --------
    Kinked brace with an L/1000 midspan offset in the global-Y
    direction::

        tags = m.model.geometry.add_imperfect_line(
            p_base, p_top,
            magnitude=L_brace/1000,
            direction=(0, 1, 0),
            shape='kink',
            label='brace',
        )

    Half-sine imperfection discretised into 16 segments::

        tags = m.model.geometry.add_imperfect_line(
            p1, p2,
            magnitude=L/500,
            direction=(1, 0, 0),
            shape='sine',
            n_segments=16,
            label='column',
        )

    First + third mode seeding::

        tags = m.model.geometry.add_imperfect_line(
            p1, p2,
            direction=(0, 0, 1),
            shape='multi_mode',
            modes=[(1, L/1000), (3, L/5000)],
            n_segments=24,
        )
    """
    # ── Resolve endpoint references to point tags ───────────
    start = self._resolve_entity_tag(start, dim=0, what="point")
    end   = self._resolve_entity_tag(end,   dim=0, what="point")

    # ── Resolve endpoint coordinates ────────────────────────
    p0 = np.asarray(
        gmsh.model.getValue(0, int(start), []), dtype=float)
    p1 = np.asarray(
        gmsh.model.getValue(0, int(end),   []), dtype=float)
    axis_vec = p1 - p0
    L = float(np.linalg.norm(axis_vec))
    if L <= 0.0:
        raise ValueError(
            f"add_imperfect_line: zero-length axis "
            f"({start} -> {end}); endpoints coincide.")
    axis_unit = axis_vec / L

    # ── Project direction perpendicular to axis ─────────────
    dir_arr = np.asarray(direction, dtype=float)
    if dir_arr.shape != (3,):
        raise ValueError(
            f"direction must be a length-3 vector, got shape "
            f"{dir_arr.shape}")
    perp = dir_arr - float(np.dot(dir_arr, axis_unit)) * axis_unit
    perp_mag = float(np.linalg.norm(perp))
    if perp_mag < 1e-12 * max(L, 1.0):
        raise ValueError(
            f"direction {tuple(direction)} is (nearly) parallel to "
            f"the line axis {tuple(axis_unit)}; pick a direction "
            f"with a perpendicular component.")
    perp_unit = perp / perp_mag

    # ── Build the (arc-length fraction, offset) pairs ───────
    if shape == 'kink':
        s_list     = [0.5]
        offset_list = [magnitude]
        n_interior = 1
    elif shape == 'sine':
        if n_segments < 2:
            raise ValueError(
                f"n_segments must be >= 2 for shape='sine', "
                f"got {n_segments}")
        s_list = [i / n_segments for i in range(1, n_segments)]
        offset_list = [
            magnitude * math.sin(math.pi * s) for s in s_list
        ]
        n_interior = n_segments - 1
    elif shape == 'multi_mode':
        if not modes:
            raise ValueError(
                "shape='multi_mode' requires a non-empty 'modes' "
                "list of (mode_number, amplitude) pairs.")
        if n_segments < 2:
            raise ValueError(
                f"n_segments must be >= 2 for shape='multi_mode', "
                f"got {n_segments}")
        s_list = [i / n_segments for i in range(1, n_segments)]
        offset_list = []
        for s in s_list:
            total = 0.0
            for k, a in modes:
                if k <= 0 or int(k) != k:
                    raise ValueError(
                        f"mode number must be a positive integer, "
                        f"got {k}")
                total += float(a) * math.sin(int(k) * math.pi * s)
            offset_list.append(total)
        n_interior = n_segments - 1
    else:
        raise ValueError(
            f"shape must be 'kink', 'sine', or 'multi_mode'; "
            f"got {shape!r}")

    # ── Create intermediate points + segment lines ──────────
    line_tags: list[Tag] = []
    prev = int(start)
    for s, off in zip(s_list, offset_list):
        pos = p0 + s * (p1 - p0) + off * perp_unit
        pt = gmsh.model.occ.addPoint(
            float(pos[0]), float(pos[1]), float(pos[2]))
        ln = gmsh.model.occ.addLine(prev, pt)
        line_tags.append(ln)
        prev = pt

    # Final segment from last interior point to the end.
    ln = gmsh.model.occ.addLine(prev, int(end))
    line_tags.append(ln)

    if sync:
        gmsh.model.occ.synchronize()

    # Register metadata for every segment (kind='imperfect_line').
    # Labels are skipped here: if we passed ``label`` to ``_register``
    # it would call ``labels.add`` once per segment and emit a
    # "duplicate label" warning on every call after the first. We
    # batch them in a single ``labels.add`` below so the label PG
    # contains all segments from the start.
    for ln in line_tags:
        self._model._register(1, ln, None, 'imperfect_line')

    # Batch-label all segments under one name (if requested).
    if label and getattr(self._model._parent, '_auto_pg_from_label', False):
        labels_comp = getattr(self._model._parent, 'labels', None)
        if labels_comp is not None:
            try:
                labels_comp.add(1, list(line_tags), name=label)
            except Exception as exc:
                import warnings as _warn
                _warn.warn(
                    f"Label {label!r} (dim=1, tags={line_tags}) could "
                    f"not be created: {exc}",
                    stacklevel=2,
                )

    self._model._log(
        f"add_imperfect_line({start} -> {end}, shape={shape!r}, "
        f"n_interior={n_interior}, magnitude={magnitude}) "
        f"-> {len(line_tags)} segment(s) {line_tags}")
    return list(line_tags)

replace_line

replace_line(line_tag: Tag, *, magnitude: float = 0.0, direction: tuple[float, float, float], shape: Literal['kink', 'sine', 'multi_mode'] = 'kink', n_segments: int = 8, modes: list[tuple[int, float]] | None = None, sync: bool = True) -> list[Tag]

Retrofit an existing straight line with a geometric imperfection.

Use this when you built the frame with plain :meth:add_line calls and then want to introduce an imperfection on a specific member without rebuilding the whole geometry. The method:

  1. Validates that line_tag points to a straight line (kind='line' in the model metadata; arcs, splines, and already-imperfect lines are rejected).
  2. Looks up the two endpoint points from the line's boundary.
  3. Records every physical group (user-facing PGs and label PGs of the form _label:…) that contains the line.
  4. Deletes the old curve — endpoints are preserved because other geometry likely references them.
  5. Calls :meth:add_imperfect_line between the same endpoints to build the new polyline.
  6. Re-wires every recorded physical group: the old line tag is swapped out and the new segment tags are spliced in, so any PG that used to reference the straight line now references the full imperfect polyline.
Parameters

line_tag : int Tag of the existing straight line to replace. magnitude, direction, shape, n_segments, modes : Same semantics as :meth:add_imperfect_line. sync : bool Whether to synchronise the OCC kernel at the end.

Returns

list[Tag] New line tags in geometric order. Same layout as :meth:add_imperfect_line would return.

Source code in src/apeGmsh/core/_model_geometry.py
def replace_line(
    self,
    line_tag  : Tag,
    *,
    magnitude : float = 0.0,
    direction : tuple[float, float, float],
    shape     : Literal['kink', 'sine', 'multi_mode'] = 'kink',
    n_segments: int = 8,
    modes     : list[tuple[int, float]] | None = None,
    sync      : bool = True,
) -> list[Tag]:
    """
    Retrofit an existing straight line with a geometric imperfection.

    Use this when you built the frame with plain :meth:`add_line`
    calls and then want to introduce an imperfection on a specific
    member without rebuilding the whole geometry. The method:

    1. Validates that ``line_tag`` points to a straight line
       (``kind='line'`` in the model metadata; arcs, splines, and
       already-imperfect lines are rejected).
    2. Looks up the two endpoint points from the line's boundary.
    3. Records every physical group (user-facing PGs **and** label
       PGs of the form ``_label:…``) that contains the line.
    4. Deletes the old curve — endpoints are preserved because
       other geometry likely references them.
    5. Calls :meth:`add_imperfect_line` between the same endpoints
       to build the new polyline.
    6. Re-wires every recorded physical group: the old line tag is
       swapped out and the new segment tags are spliced in, so any
       PG that used to reference the straight line now references
       the full imperfect polyline.

    Parameters
    ----------
    line_tag : int
        Tag of the existing straight line to replace.
    magnitude, direction, shape, n_segments, modes :
        Same semantics as :meth:`add_imperfect_line`.
    sync : bool
        Whether to synchronise the OCC kernel at the end.

    Returns
    -------
    list[Tag]
        New line tags in geometric order. Same layout as
        :meth:`add_imperfect_line` would return.
    """
    line_tag = int(line_tag)

    # 1. Reject anything that isn't a plain straight line.
    meta = self._model._metadata.get((1, line_tag), {})
    kind = meta.get('kind')
    if kind != 'line':
        raise ValueError(
            f"replace_line only works on straight lines created via "
            f"add_line (kind='line'), got kind={kind!r} for tag "
            f"{line_tag}. Rebuild the geometry explicitly if you "
            f"need to replace an arc, spline, or already-imperfect "
            f"line.")

    # 2. Recover endpoints via the curve's boundary.
    try:
        bnd = gmsh.model.getBoundary(
            [(1, line_tag)], oriented=False)
    except Exception as exc:
        raise ValueError(
            f"Line {line_tag} does not exist in the OCC kernel: "
            f"{exc}") from exc
    point_tags = [int(t) for (d, t) in bnd if d == 0]
    if len(point_tags) != 2:
        raise ValueError(
            f"Line {line_tag} has {len(point_tags)} boundary "
            f"point(s); expected exactly 2.")
    start_tag, end_tag = point_tags

    # 3. Record every dim-1 PG that contains this line. Capture
    # both the PG tag (for removal) and its name + entity list
    # (for re-creation) in a single scan so we don't walk the PG
    # table twice.
    old_pgs: list[tuple[int, str, list[int]]] = []
    for (pg_dim, pg_tag) in gmsh.model.getPhysicalGroups(dim=1):
        ents = [
            int(e) for e in
            gmsh.model.getEntitiesForPhysicalGroup(pg_dim, pg_tag)
        ]
        if line_tag not in ents:
            continue
        try:
            name = gmsh.model.getPhysicalName(pg_dim, pg_tag)
        except Exception:
            name = ''
        other = [e for e in ents if e != line_tag]
        old_pgs.append((int(pg_tag), name, other))

    # 4. Delete the old physical groups FIRST, then the line.
    # Removing the PGs first prevents Gmsh's internal synchronise
    # from seeing a dangling entity reference when the line is
    # deleted — otherwise it fires "Unknown entity ... in physical
    # group" warnings.
    if old_pgs:
        gmsh.model.removePhysicalGroups(
            [(1, pg_tag) for (pg_tag, _n, _o) in old_pgs])

    # Delete the line itself (recursive=False keeps the endpoints).
    gmsh.model.occ.remove([(1, line_tag)], recursive=False)
    gmsh.model.occ.synchronize()

    # Drop the old line from model metadata.
    self._model._metadata.pop((1, line_tag), None)

    # 5. Build the new polyline. Pass ``label=None`` so no label
    # PGs are auto-created — we re-create the captured PGs
    # (including the ``_label:…`` one if present) below in a
    # single pass. ``sync=True`` is important: the new line tags
    # must be committed into Gmsh's model state before we add
    # physical groups referencing them, otherwise Gmsh accepts
    # the PG at the model level but warns about "Unknown entity"
    # on the next OCC synchronise.
    new_tags = self.add_imperfect_line(
        start_tag, end_tag,
        magnitude=magnitude,
        direction=direction,
        shape=shape,
        n_segments=n_segments,
        modes=modes,
        label=None,
        sync=True,
    )

    # 6. Re-create every captured PG with (other_ents ∪ new_tags)
    # and the original name. We pass ``name=`` to ``addPhysicalGroup``
    # in one atomic call (rather than ``addPhysicalGroup`` + a
    # separate ``setPhysicalName``) to avoid a brief inconsistent-
    # -state window that some Gmsh builds complain about after a
    # recent ``removePhysicalGroups``.
    new_tag_set = set(int(t) for t in new_tags)
    for (_old_pg_tag, name, other_ents) in old_pgs:
        merged = sorted(set(other_ents) | new_tag_set)
        try:
            gmsh.model.addPhysicalGroup(
                1, merged, tag=-1, name=name or "")
        except Exception as exc:
            import warnings as _warn
            _warn.warn(
                f"replace_line: could not re-create PG "
                f"{name!r}: {exc}",
                stacklevel=2,
            )

    if sync:
        gmsh.model.occ.synchronize()

    self._model._log(
        f"replace_line({line_tag}, shape={shape!r}, "
        f"magnitude={magnitude}) -> {len(new_tags)} segment(s) "
        f"{new_tags}, re-wired {len(old_pgs)} PG(s)")
    return list(new_tags)

sweep

sweep(profile_face: Tag, path_curves: list[Tag], *, label: str | None = None, cleanup: bool = True, sync: bool = True) -> dict

Sweep a planar profile face along a chain of curves (a polyline path) to produce a 3-D solid volume.

Wraps gmsh.model.occ.addWire + gmsh.model.occ.addPipe and — optionally — cleans up the intermediate geometry that would otherwise cause trouble downstream:

  • The profile face that was used as the input to the pipe. It persists as an orphan at the first station of the sweep, which means when you try to identify the start cap by bbox you pick up two coincident surfaces and their mesh nodes double up.
  • The path curves themselves. These live along the centroid line of the swept solid — i.e. inside the volume — and Gmsh happily meshes them into line2 elements whose nodes sit interior to the tet mesh and are not shared with any tet4 element. Those become floating null-space DOFs if you emit them to OpenSees.

With cleanup=True (the default) both are removed after the pipe has produced the volume, so the only surfaces left in the model are the ones that actually bound the solid (the two end caps + the n_path_segments * n_profile_edges ruled side surfaces).

Parameters

profile_face : int Tag of a planar surface that serves as the cross-section. Most commonly built with addCurveLoop + addPlaneSurface on a closed polyline of vertices. Must be perpendicular — at least roughly — to the start of the path; Gmsh orients the local frame automatically using the Frenet trihedron. path_curves : list[int] Ordered list of curve tags that form the path the profile is swept along. Typically the return value of :meth:add_imperfect_line or :meth:replace_line. label : str, optional If given, the resulting volume is labelled. End caps and side surfaces remain unlabelled — run the usual select_surfaces(in_box=…) + to_physical pass to group them explicitly. cleanup : bool Remove the original profile face and path curves after the pipe is built. Defaults to True. Set to False if you want to preserve the profile/path for downstream use (e.g. another sweep on a branched path). sync : bool Whether to synchronise the OCC kernel at the end.

Returns

dict {'volume': tag, 'start_cap': tag, 'end_cap': tag}. The caps are identified by scanning every new dim-2 entity's bounding box for one whose x_min == x_max == path_endpoint_x. If either cap cannot be identified its entry is None.

Examples

Swept solid I-beam with a half-sine imperfection in the weak-axis direction::

# 1. Imperfect path
p0 = g.model.geometry.add_point(0, 0, 0, lc=200)
p1 = g.model.geometry.add_point(L, 0, 0, lc=200)
path = g.model.geometry.replace_line(
    g.model.geometry.add_line(p0, p1),
    magnitude=L/1000, direction=(0, 1, 0),
    shape='sine', n_segments=16,
)

# 2. Rectangular profile at x = 0
corners = [
    (0, -t/2, -h/2), (0, +t/2, -h/2),
    (0, +t/2, +h/2), (0, -t/2, +h/2),
]
pts = [gmsh.model.occ.addPoint(*c) for c in corners]
lns = [gmsh.model.occ.addLine(pts[i], pts[(i+1) % 4])
       for i in range(4)]
loop = gmsh.model.occ.addCurveLoop(lns)
profile = gmsh.model.occ.addPlaneSurface([loop])
gmsh.model.occ.synchronize()

# 3. Sweep
swept = g.model.geometry.sweep(profile, path, label='beam')
# swept['volume']    — the solid tag
# swept['start_cap'] — surface tag at path start
# swept['end_cap']   — surface tag at path end
Source code in src/apeGmsh/core/_model_geometry.py
def sweep(
    self,
    profile_face  : Tag,
    path_curves   : list[Tag],
    *,
    label         : str | None = None,
    cleanup       : bool       = True,
    sync          : bool       = True,
) -> dict:
    """
    Sweep a planar profile face along a chain of curves (a polyline
    path) to produce a 3-D solid volume.

    Wraps ``gmsh.model.occ.addWire`` + ``gmsh.model.occ.addPipe``
    and — optionally — cleans up the intermediate geometry that
    would otherwise cause trouble downstream:

    * **The profile face** that was used as the input to the pipe.
      It persists as an orphan at the first station of the sweep,
      which means when you try to identify the start cap by bbox
      you pick up two coincident surfaces and their mesh nodes
      double up.
    * **The path curves** themselves. These live along the
      centroid line of the swept solid — i.e. **inside** the
      volume — and Gmsh happily meshes them into ``line2``
      elements whose nodes sit interior to the tet mesh and are
      *not* shared with any tet4 element. Those become floating
      null-space DOFs if you emit them to OpenSees.

    With ``cleanup=True`` (the default) both are removed after the
    pipe has produced the volume, so the only surfaces left in the
    model are the ones that actually bound the solid (the two end
    caps + the ``n_path_segments * n_profile_edges`` ruled side
    surfaces).

    Parameters
    ----------
    profile_face : int
        Tag of a planar surface that serves as the cross-section.
        Most commonly built with ``addCurveLoop`` + ``addPlaneSurface``
        on a closed polyline of vertices. Must be perpendicular —
        at least roughly — to the start of the path; Gmsh orients
        the local frame automatically using the Frenet trihedron.
    path_curves : list[int]
        Ordered list of curve tags that form the path the profile
        is swept along. Typically the return value of
        :meth:`add_imperfect_line` or :meth:`replace_line`.
    label : str, optional
        If given, the resulting volume is labelled. End caps and
        side surfaces remain unlabelled — run the usual
        ``select_surfaces(in_box=…)`` + ``to_physical`` pass to
        group them explicitly.
    cleanup : bool
        Remove the original profile face and path curves after
        the pipe is built. Defaults to True. Set to False if you
        want to preserve the profile/path for downstream use
        (e.g. another sweep on a branched path).
    sync : bool
        Whether to synchronise the OCC kernel at the end.

    Returns
    -------
    dict
        ``{'volume': tag, 'start_cap': tag, 'end_cap': tag}``.
        The caps are identified by scanning every new dim-2
        entity's bounding box for one whose ``x_min == x_max ==
        path_endpoint_x``. If either cap cannot be identified its
        entry is ``None``.

    Examples
    --------
    Swept solid I-beam with a half-sine imperfection in the
    weak-axis direction::

        # 1. Imperfect path
        p0 = g.model.geometry.add_point(0, 0, 0, lc=200)
        p1 = g.model.geometry.add_point(L, 0, 0, lc=200)
        path = g.model.geometry.replace_line(
            g.model.geometry.add_line(p0, p1),
            magnitude=L/1000, direction=(0, 1, 0),
            shape='sine', n_segments=16,
        )

        # 2. Rectangular profile at x = 0
        corners = [
            (0, -t/2, -h/2), (0, +t/2, -h/2),
            (0, +t/2, +h/2), (0, -t/2, +h/2),
        ]
        pts = [gmsh.model.occ.addPoint(*c) for c in corners]
        lns = [gmsh.model.occ.addLine(pts[i], pts[(i+1) % 4])
               for i in range(4)]
        loop = gmsh.model.occ.addCurveLoop(lns)
        profile = gmsh.model.occ.addPlaneSurface([loop])
        gmsh.model.occ.synchronize()

        # 3. Sweep
        swept = g.model.geometry.sweep(profile, path, label='beam')
        # swept['volume']    — the solid tag
        # swept['start_cap'] — surface tag at path start
        # swept['end_cap']   — surface tag at path end
    """
    # Snapshot the set of existing volumes + surfaces so we can
    # identify the ones the pipe creates.
    before_vols  = set(int(t) for (_d, t) in gmsh.model.getEntities(3))
    before_surfs = set(int(t) for (_d, t) in gmsh.model.getEntities(2))

    # Start position: centroid of the profile face (it sits at the
    # start of the path). End position: walk the path curves and
    # collect every endpoint's 3-D coord, then pick the one
    # farthest from the start. This avoids having to know how
    # Gmsh orients the boundary points of each curve.
    prof_com = np.asarray(
        gmsh.model.occ.getCenterOfMass(2, int(profile_face)),
        dtype=float)
    start_xyz = prof_com

    all_path_pts: list[np.ndarray] = []
    for t in path_curves:
        try:
            bnd = gmsh.model.getBoundary(
                [(1, int(t))], oriented=False)
        except Exception:
            continue
        for (d, pt) in bnd:
            if d == 0:
                all_path_pts.append(np.asarray(
                    gmsh.model.getValue(0, int(pt), []),
                    dtype=float))
    if all_path_pts:
        # End of path = point farthest from start_xyz.
        end_xyz = max(
            all_path_pts,
            key=lambda p: float(np.linalg.norm(p - start_xyz)))
    else:
        end_xyz = start_xyz

    # Build the wire + pipe.
    wire = gmsh.model.occ.addWire(
        [int(t) for t in path_curves], checkClosed=False)
    gmsh.model.occ.addPipe(
        [(2, int(profile_face))], wire)
    gmsh.model.occ.synchronize()

    # Cleanup — remove the original profile face (recursive, so
    # its boundary lines/points go too) and the path curves that
    # live along the volume centroid.
    if cleanup:
        try:
            gmsh.model.occ.remove(
                [(2, int(profile_face))], recursive=True)
        except Exception:
            pass
        try:
            gmsh.model.occ.remove(
                [(1, int(t)) for t in path_curves], recursive=False)
        except Exception:
            pass
        gmsh.model.occ.synchronize()

    # Find the new volume and end caps.
    after_vols = [
        int(t) for (_d, t) in gmsh.model.getEntities(3)
        if int(t) not in before_vols
    ]
    volume_tag = after_vols[0] if after_vols else None

    # Find the caps by projecting each new surface centroid onto
    # the path direction and taking the extremes. The "new"
    # surfaces that are actual end caps will project to approx.
    # 0 (start) and |end - start| (end); the ruled side surfaces
    # will project to values in between.
    TOL = 0.1
    start_cap = None
    end_cap   = None
    path_vec  = end_xyz - start_xyz
    path_len  = float(np.linalg.norm(path_vec))
    if path_len > TOL:
        path_unit = path_vec / path_len
        cands: list[tuple[float, int]] = []
        for (d, t) in gmsh.model.getEntities(2):
            if int(t) in before_surfs:
                continue
            try:
                com = np.asarray(
                    gmsh.model.occ.getCenterOfMass(2, int(t)),
                    dtype=float)
            except Exception:
                continue
            proj = float(np.dot(com - start_xyz, path_unit))
            cands.append((proj, int(t)))
        if cands:
            cands.sort()
            start_cap = cands[0][1]
            end_cap   = cands[-1][1]

    if volume_tag is not None and label:
        self._model._register(3, volume_tag, label, 'swept_solid')

    if sync:
        gmsh.model.occ.synchronize()

    self._model._log(
        f"sweep(profile={profile_face}, n_path={len(path_curves)}) "
        f"-> volume={volume_tag}, start_cap={start_cap}, "
        f"end_cap={end_cap}")

    return {
        'volume':    volume_tag,
        'start_cap': start_cap,
        'end_cap':   end_cap,
    }

add_arc

add_arc(start: EntityRef, center: EntityRef, end: EntityRef, *, through_point: bool = False, label: str | None = None, sync: bool = True) -> Tag

Add a circular arc defined by three existing points.

Parameters

start : point reference — start of the arc center : point reference — interpretation depends on through_point:

* ``through_point=False`` (default) — the **centre of the
  circle** (not on the arc).  All three points must be
  equidistant from this centre.
* ``through_point=True`` — a point the arc **passes
  through** (e.g. the apex of an arch).  The circle is
  fitted through ``start``, this point, and ``end``.

end : point reference — end of the arc through_point : bool Switches the meaning of center as above. Use True for the common "arc through 3 points" case — add_arc(left, apex, right, through_point=True) — where you know a point on the arc but not the circle centre.

Each point accepts a raw tag, label name, physical group, part label, or (dim, tag) and must resolve to exactly one point.

Note

With through_point=False the arc is the shorter of the two possible arcs unless you reverse the start/end order.

See Also

:meth:apeGmsh.core._model_queries._Queries.make_conformal — when an arc joins straight lines to form a closed wire, OCC may create duplicate endpoint vertices at the arc-line junctions instead of welding to the existing point tags. The wire then meshes as disjoint pieces with no moment continuity at the corners. Call g.model.queries.make_conformal(dims=[1]) after assembling the wire to weld the topology.

Source code in src/apeGmsh/core/_model_geometry.py
def add_arc(
    self,
    start : EntityRef,
    center: EntityRef,
    end   : EntityRef,
    *,
    through_point: bool       = False,
    label        : str | None = None,
    sync         : bool       = True,
) -> Tag:
    """
    Add a circular arc defined by three existing points.

    Parameters
    ----------
    start  : point reference — start of the arc
    center : point reference — interpretation depends on
        ``through_point``:

        * ``through_point=False`` (default) — the **centre of the
          circle** (not on the arc).  All three points must be
          equidistant from this centre.
        * ``through_point=True`` — a point the arc **passes
          through** (e.g. the apex of an arch).  The circle is
          fitted through ``start``, this point, and ``end``.

    end    : point reference — end of the arc
    through_point : bool
        Switches the meaning of ``center`` as above.  Use
        ``True`` for the common "arc through 3 points" case —
        ``add_arc(left, apex, right, through_point=True)`` — where
        you know a point on the arc but not the circle centre.

    Each point accepts a raw tag, label name, physical group, part
    label, or ``(dim, tag)`` and must resolve to exactly one point.

    Note
    ----
    With ``through_point=False`` the arc is the *shorter* of the
    two possible arcs unless you reverse the start/end order.

    See Also
    --------
    :meth:`apeGmsh.core._model_queries._Queries.make_conformal` — when
    an arc joins straight lines to form a closed wire, OCC may create
    duplicate endpoint vertices at the arc-line junctions instead of
    welding to the existing point tags. The wire then meshes as
    disjoint pieces with no moment continuity at the corners. Call
    ``g.model.queries.make_conformal(dims=[1])`` after assembling the
    wire to weld the topology.
    """
    start  = self._resolve_entity_tag(start,  dim=0, what="point")
    center = self._resolve_entity_tag(center, dim=0, what="point")
    end    = self._resolve_entity_tag(end,    dim=0, what="point")
    tag = gmsh.model.occ.addCircleArc(
        start, center, end, center=not through_point)
    if sync:
        gmsh.model.occ.synchronize()
    mid_role = "through" if through_point else "centre"
    self._model._log(
        f"add_arc(start={start}, {mid_role}={center}, "
        f"end={end}) -> tag {tag}")
    return self._model._register(1, tag, label, 'arc')

add_arch

add_arch(start: EntityRef, apex: EntityRef, end: EntityRef, *, label: str | None = None, sync: bool = True) -> list[Tag]

Add a circular arch through three points as two arcs that share the apex as a topological vertex.

Unlike :meth:add_arc with through_point=True — which fits a single circular arc through start/apex/end and leaves the apex as a floating construction point that the mesher discards (so a physical group placed on the apex resolves to a node that never makes it into the mesh) — add_arch builds the arch as two arcs start -> apex and apex -> end of the same circle. The apex is then a real vertex, guaranteeing a conforming mesh node exactly at the crown — the node you want for a crown load, a monitoring point, or a midspan physical group.

Both halves lie on one circle, so they are tangent-continuous at the apex (no geometric kink). The circle centre is computed from the three points, used to construct the arcs, then removed so it leaves no stray node at the centre of curvature.

Parameters

start, apex, end : point references — raw tag, label name, physical group, part label, or (dim, tag). Each must resolve to exactly one point. apex is a point on the arch (the crown), not the circle centre. label : str, optional Label applied to both arc halves, so g.labels.entities(label) (and any physical group promoted from it) covers the whole arch. sync : bool Whether to synchronise the OCC kernel after creation.

Returns

list[Tag] [arc_start_apex, arc_apex_end] in geometric order. Pass the list directly to g.physical.add_curve.

Raises

ValueError If the three points are collinear or coincident (no circle is defined).

Note

Like :meth:add_arc, when the arch joins straight lines to form a closed wire, OCC may create duplicate endpoint vertices at the arc-line junctions. Call g.model.queries.make_conformal(dims=[1]) after assembling the wire to weld the topology.

See Also

:meth:add_arc — single-curve circular arc (apex not preserved as a vertex).

Source code in src/apeGmsh/core/_model_geometry.py
def add_arch(
    self,
    start: EntityRef,
    apex : EntityRef,
    end  : EntityRef,
    *,
    label: str | None = None,
    sync : bool       = True,
) -> list[Tag]:
    """
    Add a circular arch through three points as **two** arcs that
    share the apex as a topological vertex.

    Unlike :meth:`add_arc` with ``through_point=True`` — which fits
    a *single* circular arc through ``start``/``apex``/``end`` and
    leaves the apex as a floating construction point that the mesher
    discards (so a physical group placed on the apex resolves to a
    node that never makes it into the mesh) — ``add_arch`` builds the
    arch as two arcs ``start -> apex`` and ``apex -> end`` of the
    *same* circle.  The apex is then a real vertex, guaranteeing a
    conforming mesh node exactly at the crown — the node you want for
    a crown load, a monitoring point, or a midspan physical group.

    Both halves lie on one circle, so they are tangent-continuous at
    the apex (no geometric kink).  The circle centre is computed from
    the three points, used to construct the arcs, then removed so it
    leaves no stray node at the centre of curvature.

    Parameters
    ----------
    start, apex, end : point references — raw tag, label name,
        physical group, part label, or ``(dim, tag)``.  Each must
        resolve to exactly one point.  ``apex`` is a point **on** the
        arch (the crown), not the circle centre.
    label : str, optional
        Label applied to **both** arc halves, so
        ``g.labels.entities(label)`` (and any physical group promoted
        from it) covers the whole arch.
    sync : bool
        Whether to synchronise the OCC kernel after creation.

    Returns
    -------
    list[Tag]
        ``[arc_start_apex, arc_apex_end]`` in geometric order.  Pass
        the list directly to ``g.physical.add_curve``.

    Raises
    ------
    ValueError
        If the three points are collinear or coincident (no circle
        is defined).

    Note
    ----
    Like :meth:`add_arc`, when the arch joins straight lines to form
    a closed wire, OCC may create duplicate endpoint vertices at the
    arc-line junctions.  Call
    ``g.model.queries.make_conformal(dims=[1])`` after assembling the
    wire to weld the topology.

    See Also
    --------
    :meth:`add_arc` — single-curve circular arc (apex *not* preserved
    as a vertex).
    """
    start_t = self._resolve_entity_tag(start, dim=0, what="point")
    apex_t  = self._resolve_entity_tag(apex,  dim=0, what="point")
    end_t   = self._resolve_entity_tag(end,   dim=0, what="point")

    a = np.asarray(gmsh.model.getValue(0, int(start_t), []), dtype=float)
    b = np.asarray(gmsh.model.getValue(0, int(apex_t),  []), dtype=float)
    c = np.asarray(gmsh.model.getValue(0, int(end_t),   []), dtype=float)

    # Circumcentre of the three points (centre of the unique circle
    # through start/apex/end).
    ab  = b - a
    ac  = c - a
    ab2 = float(np.dot(ab, ab))
    ac2 = float(np.dot(ac, ac))
    n   = np.cross(ab, ac)
    n2  = float(np.dot(n, n))
    if ab2 <= 0.0 or ac2 <= 0.0 or n2 <= 1e-18 * ab2 * ac2:
        raise ValueError(
            f"add_arch: points {tuple(a)}, {tuple(b)}, {tuple(c)} are "
            f"collinear or coincident — no circular arch is defined. "
            f"Use add_line / add_polyline for a straight member.")
    to_centre = (
        np.cross(n, ab) * ac2 + np.cross(ac, n) * ab2
    ) / (2.0 * n2)
    centre = a + to_centre

    centre_t = gmsh.model.occ.addPoint(
        float(centre[0]), float(centre[1]), float(centre[2]))
    arc1 = gmsh.model.occ.addCircleArc(
        int(start_t), centre_t, int(apex_t), center=True)
    arc2 = gmsh.model.occ.addCircleArc(
        int(apex_t), centre_t, int(end_t), center=True)
    # The centre is a pure construction point — drop it so it does
    # not become a stray mesh node at the centre of curvature.
    gmsh.model.occ.remove([(0, centre_t)], recursive=False)
    if sync:
        gmsh.model.occ.synchronize()

    self._model._log(
        f"add_arch(start={start_t}, apex={apex_t}, end={end_t}) "
        f"-> arcs {arc1}, {arc2} (apex kept as vertex)")

    arc_tags: list[Tag] = [arc1, arc2]
    # Register both halves with no label, then batch-label under one
    # name (mirrors add_imperfect_line) so the label PG holds both
    # arcs from the start and we avoid a duplicate-label warning.
    for t in arc_tags:
        self._model._register(1, t, None, 'arc')
    if label and getattr(self._model._parent, '_auto_pg_from_label', False):
        labels_comp = getattr(self._model._parent, 'labels', None)
        if labels_comp is not None:
            try:
                labels_comp.add(1, list(arc_tags), name=label)
            except Exception as exc:
                import warnings as _warn
                _warn.warn(
                    f"Label {label!r} (dim=1, tags={arc_tags}) could "
                    f"not be created: {exc}",
                    stacklevel=2,
                )
    return arc_tags

add_circle

add_circle(cx: float, cy: float, cz: float, radius: float, *, angle1: float = 0.0, angle2: float = 2 * math.pi, label: str | None = None, sync: bool = True) -> Tag

Add a full circle (or arc sector) as a single curve entity.

Unlike add_arc, this does not require pre-existing point tags — it creates the circle directly from centre + radius.

Parameters

cx, cy, cz : centre radius : radius angle1 : start angle in radians (default 0) angle2 : end angle in radians (default 2π = full circle)

Note

Partial arcs (angle2 - angle1 < 2π) joined to other curves in a closed wire are a classic source of disjoint topology — OCC does not weld arc endpoints to existing point tags. After assembling such a wire call :meth:apeGmsh.core._model_queries._Queries.make_conformal (g.model.queries.make_conformal(dims=[1])) to fragment-weld the arc-line junctions.

Source code in src/apeGmsh/core/_model_geometry.py
def add_circle(
    self,
    cx: float, cy: float, cz: float,
    radius: float,
    *,
    angle1: float     = 0.0,
    angle2: float     = 2 * math.pi,
    label : str | None = None,
    sync  : bool       = True,
) -> Tag:
    """
    Add a full circle (or arc sector) as a single curve entity.

    Unlike ``add_arc``, this does **not** require pre-existing point
    tags — it creates the circle directly from centre + radius.

    Parameters
    ----------
    cx, cy, cz : centre
    radius     : radius
    angle1     : start angle in radians (default 0)
    angle2     : end angle in radians   (default 2π = full circle)

    Note
    ----
    Partial arcs (``angle2 - angle1 < 2π``) joined to other curves
    in a closed wire are a classic source of disjoint topology — OCC
    does not weld arc endpoints to existing point tags. After
    assembling such a wire call
    :meth:`apeGmsh.core._model_queries._Queries.make_conformal`
    (``g.model.queries.make_conformal(dims=[1])``) to fragment-weld
    the arc-line junctions.
    """
    tag = gmsh.model.occ.addCircle(cx, cy, cz, radius,
                                    angle1=angle1, angle2=angle2)
    if sync:
        gmsh.model.occ.synchronize()
    self._model._log(
        f"add_circle(centre=({cx},{cy},{cz}), r={radius}, "
        f"[{math.degrees(angle1):.1f}°->{math.degrees(angle2):.1f}°]) -> tag {tag}"
    )
    return self._model._register(1, tag, label, 'circle')

add_ellipse

add_ellipse(cx: float, cy: float, cz: float, r_major: float, r_minor: float, *, angle1: float = 0.0, angle2: float = 2 * math.pi, label: str | None = None, sync: bool = True) -> Tag

Add a full ellipse (or elliptic arc) as a single curve entity.

Parameters

cx, cy, cz : centre r_major : semi-major axis (along X before any rotation) r_minor : semi-minor axis angle1 : start angle in radians angle2 : end angle in radians

Note

Partial ellipses (angle2 - angle1 < 2π) joined to other curves in a closed wire are a classic source of disjoint topology — OCC does not weld ellipse endpoints to existing point tags. After assembling such a wire call :meth:apeGmsh.core._model_queries._Queries.make_conformal (g.model.queries.make_conformal(dims=[1])) to fragment-weld the arc-line junctions, or use :meth:apeGmsh.mesh.FEMData.InspectComposite.find_coincident_node_pairs on the resulting FEM to surface unbridged coincident nodes.

Source code in src/apeGmsh/core/_model_geometry.py
def add_ellipse(
    self,
    cx: float, cy: float, cz: float,
    r_major: float, r_minor: float,
    *,
    angle1: float      = 0.0,
    angle2: float      = 2 * math.pi,
    label : str | None = None,
    sync  : bool       = True,
) -> Tag:
    """
    Add a full ellipse (or elliptic arc) as a single curve entity.

    Parameters
    ----------
    cx, cy, cz : centre
    r_major    : semi-major axis (along X before any rotation)
    r_minor    : semi-minor axis
    angle1     : start angle in radians
    angle2     : end angle in radians

    Note
    ----
    Partial ellipses (``angle2 - angle1 < 2π``) joined to other curves
    in a closed wire are a classic source of disjoint topology — OCC
    does not weld ellipse endpoints to existing point tags. After
    assembling such a wire call
    :meth:`apeGmsh.core._model_queries._Queries.make_conformal`
    (``g.model.queries.make_conformal(dims=[1])``) to fragment-weld
    the arc-line junctions, or use
    :meth:`apeGmsh.mesh.FEMData.InspectComposite.find_coincident_node_pairs`
    on the resulting FEM to surface unbridged coincident nodes.
    """
    tag = gmsh.model.occ.addEllipse(cx, cy, cz, r_major, r_minor,
                                     angle1=angle1, angle2=angle2)
    if sync:
        gmsh.model.occ.synchronize()
    self._model._log(
        f"add_ellipse(centre=({cx},{cy},{cz}), a={r_major}, b={r_minor}) -> tag {tag}"
    )
    return self._model._register(1, tag, label, 'ellipse')

add_spline

add_spline(point_tags: list[EntityRef], *, label: str | None = None, sync: bool = True) -> Tag

Add a C2-continuous spline curve through the given points (interpolating spline).

Parameters

point_tags : ordered list of point references the spline passes through (raw tag, label, PG, part, or (dim, tag); each must resolve to one point). Minimum 2 points; for a closed spline repeat the first reference at the end.

Example

::

p1 = g.model.geometry.add_point(0, 0, 0)
p2 = g.model.geometry.add_point(1, 1, 0)
p3 = g.model.geometry.add_point(2, 0, 0)
s  = g.model.geometry.add_spline([p1, p2, p3])
Source code in src/apeGmsh/core/_model_geometry.py
def add_spline(
    self,
    point_tags: list[EntityRef],
    *,
    label: str | None = None,
    sync : bool       = True,
) -> Tag:
    """
    Add a C2-continuous spline curve **through** the given points
    (interpolating spline).

    Parameters
    ----------
    point_tags : ordered list of point references the spline passes
                 through (raw tag, label, PG, part, or ``(dim, tag)``;
                 each must resolve to one point).  Minimum 2 points;
                 for a closed spline repeat the first reference at the
                 end.

    Example
    -------
    ::

        p1 = g.model.geometry.add_point(0, 0, 0)
        p2 = g.model.geometry.add_point(1, 1, 0)
        p3 = g.model.geometry.add_point(2, 0, 0)
        s  = g.model.geometry.add_spline([p1, p2, p3])
    """
    if len(point_tags) < 2:
        raise ValueError("add_spline requires at least 2 point tags.")
    point_tags = [
        self._resolve_entity_tag(p, dim=0, what="point")
        for p in point_tags
    ]
    tag = gmsh.model.occ.addSpline(point_tags)
    if sync:
        gmsh.model.occ.synchronize()
    self._model._log(f"add_spline({point_tags}) -> tag {tag}")
    return self._model._register(1, tag, label, 'spline')

add_bspline

add_bspline(point_tags: list[EntityRef], *, degree: int = 3, weights: list[float] | None = None, knots: list[float] | None = None, multiplicities: list[int] | None = None, label: str | None = None, sync: bool = True) -> Tag

Add a B-spline curve with explicit control points.

Control points are not interpolated (the curve is attracted to them, not forced through them), which is different from add_spline.

Parameters

point_tags : control-point tags degree : polynomial degree (default 3 = cubic) weights : optional rational weights (len = len(point_tags)) knots : optional knot vector multiplicities : optional knot multiplicities

Source code in src/apeGmsh/core/_model_geometry.py
def add_bspline(
    self,
    point_tags   : list[EntityRef],
    *,
    degree        : int         = 3,
    weights       : list[float] | None = None,
    knots         : list[float] | None = None,
    multiplicities: list[int]   | None = None,
    label         : str | None  = None,
    sync          : bool        = True,
) -> Tag:
    """
    Add a B-spline curve with explicit control points.

    Control points are **not** interpolated (the curve is attracted to
    them, not forced through them), which is different from
    ``add_spline``.

    Parameters
    ----------
    point_tags     : control-point tags
    degree         : polynomial degree (default 3 = cubic)
    weights        : optional rational weights (len = len(point_tags))
    knots          : optional knot vector
    multiplicities : optional knot multiplicities
    """
    if len(point_tags) < 2:
        raise ValueError("add_bspline requires at least 2 point tags.")
    point_tags = [
        self._resolve_entity_tag(p, dim=0, what="point")
        for p in point_tags
    ]
    tag = gmsh.model.occ.addBSpline(
        point_tags,
        degree=degree,
        weights=weights        or [],
        knots=knots            or [],
        multiplicities=multiplicities or [],
    )
    if sync:
        gmsh.model.occ.synchronize()
    self._model._log(f"add_bspline(ctrl_pts={point_tags}, degree={degree}) -> tag {tag}")
    return self._model._register(1, tag, label, 'bspline')

add_bezier

add_bezier(point_tags: list[EntityRef], *, label: str | None = None, sync: bool = True) -> Tag

Add a Bézier curve.

Parameters

point_tags : control-point tags. The curve starts at the first point and ends at the last; intermediate points are control handles (not interpolated).

Source code in src/apeGmsh/core/_model_geometry.py
def add_bezier(
    self,
    point_tags: list[EntityRef],
    *,
    label: str | None = None,
    sync : bool       = True,
) -> Tag:
    """
    Add a Bézier curve.

    Parameters
    ----------
    point_tags : control-point tags.  The curve starts at the first
                 point and ends at the last; intermediate points are
                 control handles (not interpolated).
    """
    if len(point_tags) < 2:
        raise ValueError("add_bezier requires at least 2 point tags.")
    point_tags = [
        self._resolve_entity_tag(p, dim=0, what="point")
        for p in point_tags
    ]
    tag = gmsh.model.occ.addBezier(point_tags)
    if sync:
        gmsh.model.occ.synchronize()
    self._model._log(f"add_bezier({point_tags}) -> tag {tag}")
    return self._model._register(1, tag, label, 'bezier')

add_polyline

add_polyline(points, *, closed: bool = False, fillet: dict[int, float] | None = None, chamfer: dict[int, float] | None = None, label: str | None = None, sync: bool = True) -> list[Tag]

Add a polyline through 3-D control points, optionally closed, with per-vertex fillet or chamfer.

This is the missing builder named by :meth:add_arch's error text (ADR 0097). Returns persistent curve tags — lines plus any inserted fillet arcs / chamfer segments — suitable as a sweep path (:meth:add_wire + :meth:sweep) or a closed profile (:meth:add_curve_loop + :meth:add_plane_surface). The OCC wire itself stays a transient construction object and is not returned or labelled.

Parameters

points : sequence of (x, y, z) Control vertices in order. A repeated closing vertex on a closed polyline is dropped. closed : bool If True, connect the last vertex back to the first. fillet : dict[int, float], optional Vertex index → fillet radius. Trims both adjacent legs and inserts a circular arc. Open-polyline endpoints cannot be filleted. The same vertex cannot also appear in chamfer. chamfer : dict[int, float], optional Vertex index → setback distance. Trims both adjacent legs and inserts a straight chamfer. Same endpoint / collision rules as fillet. label : str, optional Label applied to all resulting curves as a group, so g.labels.entities(label) covers the whole polyline. sync : bool Synchronise the OCC kernel after creation (default True).

Returns

list[Tag] Ordered curve tags (lines and arcs) along the polyline.

Raises

ValueError Degenerate input, fillet+chamfer on the same vertex, setback larger than an adjacent segment, or a collinear / U-turn corner asked to take a fillet.

Notes

Interior vertices of an open polyline whose turning angle exceeds 30° with no fillet or chamfer emit :class:WarnGeomSharpPolylineCorner — OCC addPipe kinks at sharp path corners. Closed profiles do not warn (they are not pipe paths).

Source code in src/apeGmsh/core/_model_geometry.py
def add_polyline(
    self,
    points,
    *,
    closed : bool = False,
    fillet : dict[int, float] | None = None,
    chamfer: dict[int, float] | None = None,
    label  : str | None = None,
    sync   : bool = True,
) -> list[Tag]:
    """
    Add a polyline through 3-D control points, optionally closed,
    with per-vertex fillet or chamfer.

    This is the missing builder named by :meth:`add_arch`'s error
    text (ADR 0097).  Returns **persistent curve tags** — lines plus
    any inserted fillet arcs / chamfer segments — suitable as a
    sweep path (:meth:`add_wire` + :meth:`sweep`) or a closed
    profile (:meth:`add_curve_loop` + :meth:`add_plane_surface`).
    The OCC wire itself stays a transient construction object and
    is *not* returned or labelled.

    Parameters
    ----------
    points : sequence of (x, y, z)
        Control vertices in order.  A repeated closing vertex on a
        closed polyline is dropped.
    closed : bool
        If True, connect the last vertex back to the first.
    fillet : dict[int, float], optional
        Vertex index → fillet radius.  Trims both adjacent legs and
        inserts a circular arc.  Open-polyline endpoints cannot be
        filleted.  The same vertex cannot also appear in *chamfer*.
    chamfer : dict[int, float], optional
        Vertex index → setback distance.  Trims both adjacent legs
        and inserts a straight chamfer.  Same endpoint / collision
        rules as *fillet*.
    label : str, optional
        Label applied to **all** resulting curves as a group, so
        ``g.labels.entities(label)`` covers the whole polyline.
    sync : bool
        Synchronise the OCC kernel after creation (default True).

    Returns
    -------
    list[Tag]
        Ordered curve tags (lines and arcs) along the polyline.

    Raises
    ------
    ValueError
        Degenerate input, fillet+chamfer on the same vertex, setback
        larger than an adjacent segment, or a collinear / U-turn
        corner asked to take a fillet.

    Notes
    -----
    Interior vertices of an *open* polyline whose turning angle
    exceeds 30° with no fillet or chamfer emit
    :class:`WarnGeomSharpPolylineCorner` — OCC ``addPipe`` kinks at
    sharp path corners.  Closed profiles do not warn (they are not
    pipe paths).
    """
    from ._compose_errors import chain_phase_guard
    from ._geometry_errors import WarnGeomSharpPolylineCorner
    from ._polyline import (
        expand_polyline,
        normalize_polyline_points,
        sharp_untreated_vertices,
    )
    import warnings as _warn

    chain_phase_guard(self._model._parent, "g.model.geometry.add_polyline")
    pts = normalize_polyline_points(points, closed=closed)
    treated = set(fillet or {}) | set(chamfer or {})
    # Sharp-corner warning is for sweep *paths* (open polylines).
    # A closed profile's 90° corners become longitudinal edges of
    # the solid; they do not kink OCC addPipe.
    if not closed:
        for idx, deg in sharp_untreated_vertices(
            pts, closed=closed, treated=treated,
        ):
            _warn.warn(
                f"add_polyline: vertex {idx} turns {deg:.1f}° with no "
                f"fillet/chamfer — OCC sweep (addPipe) may kink. Pass "
                f"fillet={{{idx}: R}} or chamfer={{{idx}: D}}.",
                WarnGeomSharpPolylineCorner,
                stacklevel=2,
            )
    segments = expand_polyline(
        pts, closed=closed, fillet=fillet, chamfer=chamfer,
    )

    def _pt_tag(xyz: np.ndarray) -> int:
        key = tuple(round(float(c), 12) for c in xyz)
        tag = point_tags.get(key)
        if tag is None:
            tag = gmsh.model.occ.addPoint(key[0], key[1], key[2])
            point_tags[key] = tag
        return tag

    point_tags: dict[tuple[float, float, float], int] = {}
    curve_tags: list[Tag] = []
    centre_tags: list[int] = []
    for seg in segments:
        s = _pt_tag(seg.start)
        e = _pt_tag(seg.end)
        if seg.kind == "arc":
            c = _pt_tag(seg.center)
            centre_tags.append(c)
            tag = gmsh.model.occ.addCircleArc(s, c, e, center=True)
        else:
            tag = gmsh.model.occ.addLine(s, e)
        curve_tags.append(tag)

    for c in centre_tags:
        try:
            gmsh.model.occ.remove([(0, int(c))], recursive=False)
        except Exception:
            pass
        key = next((k for k, t in point_tags.items() if t == c), None)
        if key is not None:
            point_tags.pop(key, None)

    if sync:
        gmsh.model.occ.synchronize()

    for t in curve_tags:
        self._model._register(1, t, None, "polyline")
    if label and getattr(self._model._parent, "_auto_pg_from_label", False):
        labels_comp = getattr(self._model._parent, "labels", None)
        if labels_comp is not None:
            try:
                labels_comp.add(1, list(curve_tags), name=label)
            except Exception as exc:
                import warnings as _warn2
                _warn2.warn(
                    f"Label {label!r} (dim=1, tags={curve_tags}) could "
                    f"not be created: {exc}",
                    stacklevel=2,
                )
    self._model._log(
        f"add_polyline(n={len(pts)}, closed={closed}, "
        f"fillet={fillet or {}}, chamfer={chamfer or {}}) "
        f"-> {curve_tags}"
    )
    return list(curve_tags)

add_wire

add_wire(curve_tags: list[EntityRef], *, check_closed: bool = False, label: str | None = None, sync: bool = True) -> Tag

Assemble an ordered list of curve tags into an OpenCASCADE wire (open or closed). Wires are the path input for sweep operations (:meth:sweep) and the section input for lofted volumes (:meth:thru_sections).

Unlike :meth:add_curve_loop, a wire does not need to be closed. This is what makes it suitable as a sweep path.

Parameters

curve_tags : ordered curve references (raw tag, label, PG, part, or (dim, tag); each must resolve to one curve). A leading '-' on a label name — or a negative tag — reverses that curve's orientation. Curves must be connected end-to-end but may share only geometrically identical endpoints (OCC allows topologically distinct but coincident points). check_closed : if True, the underlying OCC call verifies that the wire forms a closed loop and raises otherwise. label : not supported — passing a non-None value raises ValueError (see Note).

Returns

int tag of the new OCC wire. This is not a persistent, meshable model entity — use it immediately as the path argument to :meth:sweep or an element of wires for :meth:thru_sections.

Note

An OpenCASCADE wire is a transient construction object, not a model entity. Its tag is allocated in the curve tag-space and, after synchronize(), it does not appear as its own dim=1 entity — the tag instead aliases one of the member curves. Consequently the wire is not added to the entity registry, and label= is rejected: a label would silently attach the name to an unrelated curve. To name the member curves as a group, group the curves themselves, e.g. m.model.select([c1, c2, c3]).to_physical(name=...).

Example

::

p0 = g.model.geometry.add_point(0, 0, 0, sync=False)
p1 = g.model.geometry.add_point(1, 0, 0, sync=False)
p2 = g.model.geometry.add_point(1, 1, 0, sync=False)
p3 = g.model.geometry.add_point(1, 1, 2, sync=False)
l1 = g.model.geometry.add_line(p0, p1, sync=False)
l2 = g.model.geometry.add_line(p1, p2, sync=False)
l3 = g.model.geometry.add_line(p2, p3, sync=False)
path = g.model.geometry.add_wire([l1, l2, l3])
g.model.transforms.sweep(section, path)
Source code in src/apeGmsh/core/_model_geometry.py
def add_wire(
    self,
    curve_tags: list[EntityRef],
    *,
    check_closed: bool       = False,
    label       : str | None = None,
    sync        : bool       = True,
) -> Tag:
    """
    Assemble an ordered list of curve tags into an OpenCASCADE wire
    (open or closed).  Wires are the path input for sweep operations
    (:meth:`sweep`) and the section input for lofted volumes
    (:meth:`thru_sections`).

    Unlike :meth:`add_curve_loop`, a wire does **not** need to be
    closed.  This is what makes it suitable as a sweep path.

    Parameters
    ----------
    curve_tags : ordered curve references (raw tag, label, PG, part,
        or ``(dim, tag)``; each must resolve to one curve).  A
        leading ``'-'`` on a label name — or a negative tag —
        reverses that curve's orientation.  Curves must be connected
        end-to-end but may share only geometrically identical
        endpoints (OCC allows topologically distinct but coincident
        points).
    check_closed : if True, the underlying OCC call verifies that
        the wire forms a closed loop and raises otherwise.
    label : **not supported** — passing a non-None value raises
        ``ValueError`` (see Note).

    Returns
    -------
    int tag of the new OCC wire.  This is **not** a persistent,
    meshable model entity — use it immediately as the ``path``
    argument to :meth:`sweep` or an element of ``wires`` for
    :meth:`thru_sections`.

    Note
    ----
    An OpenCASCADE wire is a transient construction object, not a
    model entity.  Its tag is allocated in the curve tag-space and,
    after ``synchronize()``, it does **not** appear as its own
    ``dim=1`` entity — the tag instead aliases one of the member
    curves.  Consequently the wire is **not** added to the entity
    registry, and ``label=`` is rejected: a label would silently
    attach the name to an unrelated curve.  To name the member
    curves as a group, group the curves themselves, e.g.
    ``m.model.select([c1, c2, c3]).to_physical(name=...)``.

    Example
    -------
    ::

        p0 = g.model.geometry.add_point(0, 0, 0, sync=False)
        p1 = g.model.geometry.add_point(1, 0, 0, sync=False)
        p2 = g.model.geometry.add_point(1, 1, 0, sync=False)
        p3 = g.model.geometry.add_point(1, 1, 2, sync=False)
        l1 = g.model.geometry.add_line(p0, p1, sync=False)
        l2 = g.model.geometry.add_line(p1, p2, sync=False)
        l3 = g.model.geometry.add_line(p2, p3, sync=False)
        path = g.model.geometry.add_wire([l1, l2, l3])
        g.model.transforms.sweep(section, path)
    """
    # Creates OCC geometry, so it is frozen like the other add_*
    # primitives — it is only absent from the usual _register()
    # chokepoint because a wire is transient and deliberately not
    # entered in the entity registry (see Note above).
    from ._compose_errors import chain_phase_guard
    chain_phase_guard(self._model._parent, "g.model.geometry.add_wire")
    if label is not None:
        raise ValueError(
            "add_wire does not accept label=: an OpenCASCADE wire "
            "is a transient construction object, not a persistent "
            "model entity. Its tag is allocated in the curve "
            "tag-space and after synchronize() aliases an existing "
            "curve, so a label would silently attach the name to "
            "that unrelated curve.\n"
            "  - To use the wire as a sweep / thru_sections path, "
            "pass the returned tag directly: "
            "path = add_wire([...]); sweep(profile, path)\n"
            "  - To name the member curves as a group, group the "
            "curves themselves: "
            "m.model.select([c1, c2, ...]).to_physical(name=...)"
        )
    curve_tags = [
        self._resolve_entity_tag(c, dim=1, what="curve", allow_sign=True)
        for c in curve_tags
    ]
    tag = gmsh.model.occ.addWire(curve_tags, checkClosed=check_closed)
    if sync:
        gmsh.model.occ.synchronize()
    self._model._log(
        f"add_wire({curve_tags}, closed={check_closed}) -> tag {tag}")
    return tag

add_curve_loop

add_curve_loop(curve_tags: list[EntityRef], *, label: str | None = None, sync: bool = True) -> Tag

Assemble an ordered list of curve references into a closed wire (curve loop). The result is used as input to add_plane_surface or add_surface_filling.

Parameters

curve_tags : ordered curve references forming a closed loop (raw tag, label, PG, part, or (dim, tag); each must resolve to one curve). Reverse a curve's orientation with a negative tag or a leading '-' on its label name, e.g. '-col_right'.

Example

::

loop = g.model.geometry.add_curve_loop([l1, l2, l3, l4])
surf = g.model.geometry.add_plane_surface(loop)

# by label, with one reversed curve
loop = g.model.geometry.add_curve_loop(
    ['col_left', 'arch', '-col_right'])
Source code in src/apeGmsh/core/_model_geometry.py
def add_curve_loop(
    self,
    curve_tags: list[EntityRef],
    *,
    label: str | None = None,
    sync : bool       = True,
) -> Tag:
    """
    Assemble an ordered list of curve references into a closed wire
    (curve loop).  The result is used as input to
    ``add_plane_surface`` or ``add_surface_filling``.

    Parameters
    ----------
    curve_tags : ordered curve references forming a closed loop
                 (raw tag, label, PG, part, or ``(dim, tag)``; each
                 must resolve to one curve).  Reverse a curve's
                 orientation with a negative tag **or** a leading
                 ``'-'`` on its label name, e.g. ``'-col_right'``.

    Example
    -------
    ::

        loop = g.model.geometry.add_curve_loop([l1, l2, l3, l4])
        surf = g.model.geometry.add_plane_surface(loop)

        # by label, with one reversed curve
        loop = g.model.geometry.add_curve_loop(
            ['col_left', 'arch', '-col_right'])
    """
    curve_tags = [
        self._resolve_entity_tag(c, dim=1, what="curve", allow_sign=True)
        for c in curve_tags
    ]
    tag = gmsh.model.occ.addCurveLoop(curve_tags)
    if sync:
        gmsh.model.occ.synchronize()
    self._model._log(f"add_curve_loop({curve_tags}) -> tag {tag}")
    return self._model._register(1, tag, label, 'curve_loop')

add_plane_surface

add_plane_surface(wire_tags: EntityRef | list[EntityRef], *, label: str | None = None, sync: bool = True, as_void: bool = False) -> Tag

Create a planar surface bounded by one or more curve loops.

Parameters

wire_tags : reference (or list of references) to curve loops — raw tag, label, PG, part, or (dim, tag); each must resolve to one curve loop. The first loop is the outer boundary; any additional loops define holes. as_void : bool If True, mark the surface as a 2-D boolean tool (ADR 0097).

Example

::

outer = g.model.geometry.add_curve_loop([l1, l2, l3, l4])
hole  = g.model.geometry.add_curve_loop([h1, h2, h3, h4])
surf  = g.model.geometry.add_plane_surface([outer, hole])
Source code in src/apeGmsh/core/_model_geometry.py
def add_plane_surface(
    self,
    wire_tags: EntityRef | list[EntityRef],
    *,
    label: str | None = None,
    sync : bool       = True,
    as_void: bool     = False,
) -> Tag:
    """
    Create a planar surface bounded by one or more curve loops.

    Parameters
    ----------
    wire_tags : reference (or list of references) to curve loops —
                raw tag, label, PG, part, or ``(dim, tag)``; each
                must resolve to one curve loop.  The first loop is
                the outer boundary; any additional loops define holes.
    as_void : bool
        If True, mark the surface as a 2-D boolean tool (ADR 0097).

    Example
    -------
    ::

        outer = g.model.geometry.add_curve_loop([l1, l2, l3, l4])
        hole  = g.model.geometry.add_curve_loop([h1, h2, h3, h4])
        surf  = g.model.geometry.add_plane_surface([outer, hole])
    """
    if not isinstance(wire_tags, list):
        wire_tags = [wire_tags]
    wire_tags = [
        self._resolve_entity_tag(w, dim=1, what="curve loop")
        for w in wire_tags
    ]
    tag = gmsh.model.occ.addPlaneSurface(wire_tags)
    if sync:
        gmsh.model.occ.synchronize()
    self._model._log(f"add_plane_surface(wires={wire_tags}) -> tag {tag}")
    return self._model._register(
        2, tag, label, 'plane_surface', role="void" if as_void else None,
    )

add_surface_filling

add_surface_filling(wire_tag: EntityRef, *, label: str | None = None, sync: bool = True) -> Tag

Create a surface filling bounded by a single curve loop, using a Coons-patch style interpolation (non-planar surfaces).

Parameters

wire_tag : reference to the bounding curve loop — raw tag, label, PG, part, or (dim, tag); must resolve to one curve loop.

Source code in src/apeGmsh/core/_model_geometry.py
def add_surface_filling(
    self,
    wire_tag: EntityRef,
    *,
    label: str | None = None,
    sync : bool       = True,
) -> Tag:
    """
    Create a surface filling bounded by a single curve loop, using a
    Coons-patch style interpolation (non-planar surfaces).

    Parameters
    ----------
    wire_tag : reference to the bounding curve loop — raw tag,
               label, PG, part, or ``(dim, tag)``; must resolve to
               one curve loop.
    """
    wire_tag = self._resolve_entity_tag(
        wire_tag, dim=1, what="curve loop")
    tag = gmsh.model.occ.addSurfaceFilling(wire_tag)
    if sync:
        gmsh.model.occ.synchronize()
    self._model._log(f"add_surface_filling(wire={wire_tag}) -> tag {tag}")
    return self._model._register(2, tag, label, 'surface_filling')

add_rectangle

add_rectangle(x: float, y: float, z: float, dx: float, dy: float, *, plane: Literal['xy', 'yz', 'xz'] = 'xy', angles_deg: tuple[float, float, float] | None = None, angles_rad: tuple[float, float, float] | None = None, pivot: tuple[float, float, float] = (0.0, 0.0, 0.0), rounded_radius: float = 0.0, label: str | None = None, sync: bool = True, as_void: bool = False) -> Tag

Add a rectangular planar surface on one of the canonical planes.

The rectangle's corner is anchored at (x, y, z) and its extents dx/dy run along the two in-plane axes selected by plane:

========= =============== =============== ================ plane dx axis dy axis constant ========= =============== =============== ================ 'xy' world X world Y z (default) 'xz' world X world Z y 'yz' world Y world Z x ========= =============== =============== ================

For a fully arbitrary orientation, build on the nearest canonical plane and rotate in place with angles_deg / angles_rad — three angles applied as successive rotations about world X, then Y, then Z, through a pivot point measured as an offset from the rectangle's geometric centre. (For a centre-anchored square with an arbitrary normal, see :meth:add_cutting_plane.)

Useful as a cutting tool for :meth:fragment — a 2D rectangle fragmented against a 3D solid splits the solid along the rectangle's plane.

Parameters

x, y, z : float Corner of the rectangle. dx, dy : float Extents along the two in-plane axes selected by plane (see the table above). plane : {'xy', 'yz', 'xz'} Canonical plane the rectangle lives on. Default 'xy'. angles_deg, angles_rad : (rx, ry, rz), optional Rotation angles about world X, Y, Z, applied in that order through pivot. Pass exactly one of the two — supplying both raises ValueError. Either may be None (no rotation). pivot : (px, py, pz) Pivot point expressed as an offset from the rectangle's centre. (0, 0, 0) (default) rotates about the centre; (dx/2, dy/2, 0) would rotate about the bottom-left corner, etc. Ignored when no angles are given. rounded_radius : float If > 0, rounds the four corners with this radius. label : str, optional Human-readable label stored in the internal registry. sync : bool Synchronise the OCC kernel after creation (default True).

Returns

Tag Surface tag of the new rectangle.

Example

::

# Split a solid at mid-height with a cutting plane
bb = gmsh.model.getBoundingBox(3, 1)
xmin, ymin, zmin, xmax, ymax, zmax = bb
zmid = (zmin + zmax) / 2
pad = 1.0
rect = m1.model.geometry.add_rectangle(
    xmin - pad, ymin - pad, zmid,
    (xmax - xmin) + 2*pad,
    (ymax - ymin) + 2*pad,
)
result = m1.model.boolean.fragment(objects=[1], tools=[rect], dim=3)

# Inclined crack plane: 30 about X through the centre
m.model.geometry.add_rectangle(
    -10, -10, 0, 20, 20,
    angles_deg=(30, 0, 0), label='plane',
)

# A vertical wall on the YZ plane at x = 0, corner at origin
g.model.geometry.add_rectangle(0, 0, 0, 4, 3, plane='yz')
Source code in src/apeGmsh/core/_model_geometry.py
def add_rectangle(
    self,
    x: float, y: float, z: float,
    dx: float, dy: float,
    *,
    plane     : Literal['xy', 'yz', 'xz'] = 'xy',
    angles_deg: tuple[float, float, float] | None = None,
    angles_rad: tuple[float, float, float] | None = None,
    pivot     : tuple[float, float, float]        = (0.0, 0.0, 0.0),
    rounded_radius: float = 0.0,
    label: str | None = None,
    sync : bool       = True,
    as_void: bool     = False,
) -> Tag:
    """
    Add a rectangular planar surface on one of the canonical planes.

    The rectangle's **corner** is anchored at **(x, y, z)** and its
    extents **dx**/**dy** run along the two in-plane axes selected by
    ``plane``:

    =========  ===============  ===============  ================
    ``plane``  ``dx`` axis      ``dy`` axis      constant
    =========  ===============  ===============  ================
    ``'xy'``   world **X**      world **Y**      ``z``  (default)
    ``'xz'``   world **X**      world **Z**      ``y``
    ``'yz'``   world **Y**      world **Z**      ``x``
    =========  ===============  ===============  ================

    For a fully arbitrary orientation, build on the nearest canonical
    plane and rotate in place with ``angles_deg`` / ``angles_rad`` —
    three angles applied as successive rotations about world X, then Y,
    then Z, through a pivot point measured **as an offset from the
    rectangle's geometric centre**.  (For a centre-anchored square with
    an arbitrary normal, see :meth:`add_cutting_plane`.)

    Useful as a cutting tool for :meth:`fragment` — a 2D rectangle
    fragmented against a 3D solid splits the solid along the
    rectangle's plane.

    Parameters
    ----------
    x, y, z : float
        Corner of the rectangle.
    dx, dy : float
        Extents along the two in-plane axes selected by ``plane``
        (see the table above).
    plane : {'xy', 'yz', 'xz'}
        Canonical plane the rectangle lives on.  Default ``'xy'``.
    angles_deg, angles_rad : (rx, ry, rz), optional
        Rotation angles about world X, Y, Z, applied in that order
        through ``pivot``.  Pass exactly one of the two — supplying
        both raises ``ValueError``.  Either may be ``None`` (no
        rotation).
    pivot : (px, py, pz)
        Pivot point expressed as an **offset from the rectangle's
        centre**.  ``(0, 0, 0)`` (default) rotates about the centre;
        ``(dx/2, dy/2, 0)`` would rotate about the bottom-left
        corner, etc.  Ignored when no angles are given.
    rounded_radius : float
        If > 0, rounds the four corners with this radius.
    label : str, optional
        Human-readable label stored in the internal registry.
    sync : bool
        Synchronise the OCC kernel after creation (default True).

    Returns
    -------
    Tag
        Surface tag of the new rectangle.

    Example
    -------
    ::

        # Split a solid at mid-height with a cutting plane
        bb = gmsh.model.getBoundingBox(3, 1)
        xmin, ymin, zmin, xmax, ymax, zmax = bb
        zmid = (zmin + zmax) / 2
        pad = 1.0
        rect = m1.model.geometry.add_rectangle(
            xmin - pad, ymin - pad, zmid,
            (xmax - xmin) + 2*pad,
            (ymax - ymin) + 2*pad,
        )
        result = m1.model.boolean.fragment(objects=[1], tools=[rect], dim=3)

        # Inclined crack plane: 30 about X through the centre
        m.model.geometry.add_rectangle(
            -10, -10, 0, 20, 20,
            angles_deg=(30, 0, 0), label='plane',
        )

        # A vertical wall on the YZ plane at x = 0, corner at origin
        g.model.geometry.add_rectangle(0, 0, 0, 4, 3, plane='yz')
    """
    if angles_deg is not None and angles_rad is not None:
        raise ValueError(
            "add_rectangle: pass either angles_deg or angles_rad, "
            "not both."
        )
    if plane not in ('xy', 'yz', 'xz'):
        raise ValueError(
            f"add_rectangle: plane must be 'xy', 'yz', or 'xz', "
            f"got {plane!r}."
        )
    angles = angles_rad
    if angles_deg is not None:
        angles = tuple(math.radians(a) for a in angles_deg)

    # Build in the local XY plane with the corner at the origin, then
    # orient onto the requested plane and translate the corner to
    # (x, y, z).  Anchoring at the origin keeps the plane rotation
    # pivoting about the corner so the corner-anchored contract holds;
    # routing through addRectangle preserves rounded_radius on every
    # plane.
    tag = gmsh.model.occ.addRectangle(
        0.0, 0.0, 0.0, dx, dy, roundedRadius=rounded_radius,
    )
    if plane == 'xz':
        # local +Y -> world +Z (local +X stays +X)
        gmsh.model.occ.rotate([(2, tag)], 0, 0, 0, 1, 0, 0, math.pi / 2)
    elif plane == 'yz':
        # cyclic X->Y, Y->Z about the (1,1,1) diagonal
        gmsh.model.occ.rotate([(2, tag)], 0, 0, 0, 1, 1, 1, 2 * math.pi / 3)
    gmsh.model.occ.translate([(2, tag)], x, y, z)

    # In-plane world axes for dx (u) and dy (v), used to locate the
    # centre when an extra in-place rotation is requested.
    _PLANE_AXES = {
        'xy': ((1.0, 0.0, 0.0), (0.0, 1.0, 0.0)),
        'xz': ((1.0, 0.0, 0.0), (0.0, 0.0, 1.0)),
        'yz': ((0.0, 1.0, 0.0), (0.0, 0.0, 1.0)),
    }
    u, v = (np.asarray(a, dtype=float) for a in _PLANE_AXES[plane])

    if angles is not None:
        centre = np.array([x, y, z], dtype=float) + (dx / 2.0) * u + (dy / 2.0) * v
        cx, cy, cz = (centre + np.asarray(pivot, dtype=float)).tolist()
        rx, ry, rz = angles
        if rx:
            gmsh.model.occ.rotate(
                [(2, tag)], cx, cy, cz, 1, 0, 0, rx,
            )
        if ry:
            gmsh.model.occ.rotate(
                [(2, tag)], cx, cy, cz, 0, 1, 0, ry,
            )
        if rz:
            gmsh.model.occ.rotate(
                [(2, tag)], cx, cy, cz, 0, 0, 1, rz,
            )

    if sync:
        gmsh.model.occ.synchronize()

    rot_msg = ""
    if angles is not None:
        rot_msg = (
            f", angles_rad=({angles[0]:.4f},{angles[1]:.4f},"
            f"{angles[2]:.4f}), pivot_offset={pivot}"
        )
    self._model._log(
        f"add_rectangle(origin=({x},{y},{z}), size=({dx},{dy}), "
        f"plane={plane!r}"
        f"{f', r={rounded_radius}' if rounded_radius else ''}"
        f"{rot_msg}) -> tag {tag}"
    )
    return self._model._register(
        2, tag, label, 'rectangle', role="void" if as_void else None,
    )

add_cutting_plane

add_cutting_plane(point: list[float] | ndarray, normal_vector: list[float] | ndarray, *, size: float | None = None, label: str | None = None, sync: bool = True) -> Tag

Create a square planar surface through point with the given normal, suitable for clipping / section / visualisation views.

The surface is a plain BRep face built from 4 points + 4 lines + a curve loop + a plane surface, so it behaves exactly like any other registered surface (it can be selected, meshed as a discrete 2-D grid, exported to STEP, etc.). It is not a Gmsh clipping plane in the rendering sense — it is real geometry.

Parameters

point : array-like of 3 floats A point on the plane. The square is centred here. normal_vector : array-like of 3 floats Plane normal. Need not be unit-length — it is normalised internally. size : float, optional Edge length of the square. When None (default), size is picked as 2 × max(model_bbox_diagonal, 1.0) so the square comfortably overhangs the current model. label : str, optional Human-readable label stored in the internal registry. sync : bool, optional Synchronise the OCC kernel after creation (default True).

Returns

Tag Surface tag of the new cutting plane.

Example

::

# A vertical plane through (0, 0, 0) with normal (1, 0, 0)
g.model.geometry.add_cutting_plane(
    point=(0, 0, 0), normal_vector=(1, 0, 0),
)
Source code in src/apeGmsh/core/_model_geometry.py
def add_cutting_plane(
    self,
    point          : list[float] | ndarray,
    normal_vector  : list[float] | ndarray,
    *,
    size           : float | None = None,
    label          : str | None   = None,
    sync           : bool         = True,
) -> Tag:
    """
    Create a square planar surface through ``point`` with the given
    normal, suitable for clipping / section / visualisation views.

    The surface is a plain BRep face built from 4 points + 4 lines
    + a curve loop + a plane surface, so it behaves exactly like
    any other registered surface (it can be selected, meshed as a
    discrete 2-D grid, exported to STEP, etc.).  It is *not* a
    Gmsh clipping plane in the rendering sense — it is real
    geometry.

    Parameters
    ----------
    point : array-like of 3 floats
        A point on the plane.  The square is centred here.
    normal_vector : array-like of 3 floats
        Plane normal.  Need not be unit-length — it is normalised
        internally.
    size : float, optional
        Edge length of the square.  When ``None`` (default), size
        is picked as ``2 × max(model_bbox_diagonal, 1.0)`` so the
        square comfortably overhangs the current model.
    label : str, optional
        Human-readable label stored in the internal registry.
    sync : bool, optional
        Synchronise the OCC kernel after creation (default True).

    Returns
    -------
    Tag
        Surface tag of the new cutting plane.

    Example
    -------
    ::

        # A vertical plane through (0, 0, 0) with normal (1, 0, 0)
        g.model.geometry.add_cutting_plane(
            point=(0, 0, 0), normal_vector=(1, 0, 0),
        )
    """
    p = np.asarray(point, dtype=float)
    n = np.asarray(normal_vector, dtype=float)
    n_norm = float(np.linalg.norm(n))
    if n_norm == 0.0:
        raise ValueError("normal_vector must be non-zero")
    n = n / n_norm

    # Pick size from the current model bounding box when not given.
    # ``getBoundingBox`` requires a synchronised OCC state, so this
    # is the only hard reason for a pre-sync.  When the caller
    # supplies an explicit ``size``, we do not sync until the end.
    if size is None:
        gmsh.model.occ.synchronize()
        xmin, ymin, zmin, xmax, ymax, zmax = gmsh.model.getBoundingBox(-1, -1)
        diag = float(np.linalg.norm([xmax - xmin, ymax - ymin, zmax - zmin]))
        size = 2.0 * max(diag, 1.0)

    # Orthonormal basis (u, v) spanning the plane.  v does not need
    # explicit normalisation: if ``n`` and ``u`` are unit and
    # orthogonal, then ``cross(n, u)`` is also unit.
    ref = (
        np.array([1.0, 0.0, 0.0]) if abs(n[0]) < 0.9
        else np.array([0.0, 1.0, 0.0])
    )
    u = np.cross(n, ref)
    u /= np.linalg.norm(u)
    v = np.cross(n, u)

    half = size / 2.0
    corner_coeffs = [(-half, -half), (half, -half), (half, half), (-half, half)]
    corners = [p + a * u + b * v for a, b in corner_coeffs]

    # Delegate to the existing BRep primitives so the new points,
    # lines, loop, and surface all flow through ``_register`` /
    # ``_log`` with the correct kinds.  Defer every sub-sync so we
    # sync exactly once at the end (or zero times if sync=False).
    pt_tags  = [
        self.add_point(float(c[0]), float(c[1]), float(c[2]), sync=False)
        for c in corners
    ]
    ln_tags  = [
        self.add_line(pt_tags[i], pt_tags[(i + 1) % 4], sync=False)
        for i in range(4)
    ]
    loop_tag = self.add_curve_loop(ln_tags, sync=False)
    # Defer the label until AFTER the sync below: a label PG bind
    # via gmsh.model.addPhysicalGroup requires the entity to be
    # visible to Gmsh, which only happens post-synchronize.
    tag      = self.add_plane_surface(loop_tag, sync=False)

    if sync:
        gmsh.model.occ.synchronize()

    self._model._log(
        f"add_cutting_plane(point={tuple(p)}, normal={tuple(n)}, "
        f"size={size}) -> tag {tag}"
    )
    # Re-register as a cutting plane (overwrites the
    # ``plane_surface`` metadata kind from add_plane_surface) and,
    # now that the surface is synced, bind the label PG.  When
    # sync=False the caller has opted out of syncing, so we only
    # update the metadata kind and skip label registration to
    # avoid the "Unknown entity" warning.
    if sync:
        self._model._register(2, tag, label, 'cutting_plane')
    else:
        self._model._metadata[(2, tag)] = {'kind': 'cutting_plane'}
    entry = self._model._metadata.get((2, tag))
    if entry is not None:
        entry['point']  = tuple(float(x) for x in p)
        entry['normal'] = tuple(float(x) for x in n)
    return tag

add_axis_cutting_plane

add_axis_cutting_plane(axis: Literal['x', 'y', 'z'], offset: float = 0.0, *, origin: list[float] | ndarray | None = None, rotation: float = 0.0, rotation_about: Literal['x', 'y', 'z'] | None = None, label: str | None = None, sync: bool = True) -> Tag

Add an axis-aligned cutting plane, optionally tilted by a rotation.

Convenience wrapper around :meth:add_cutting_plane. The plane is initially defined as normal to axis (so axis='z' produces a horizontal XY-plane). It is then:

  1. Offset along its base normal by offset.
  2. Rotated by rotation degrees about rotation_about (if both are given), producing a tilted plane through the same anchor point.
Parameters

axis : {'x', 'y', 'z'} Axis the plane is normal to. 'z' -> XY plane, etc. offset : float, optional Signed distance along the base normal from origin (or from the global origin if origin is None). origin : array-like of 3 floats, optional Anchor point before the offset is applied. Defaults to (0, 0, 0). rotation : float, optional Rotation angle in degrees. Requires rotation_about to have any effect — passing rotation without rotation_about raises ValueError so silent no-ops do not sneak through. rotation_about : {'x', 'y', 'z'}, optional Axis about which the base normal is rotated. Must differ from axis for the rotation to have any effect. label : str, optional Human-readable label stored in the internal registry. sync : bool, optional Synchronise the OCC kernel after creation (default True).

Returns

Tag Surface tag of the new cutting plane.

Examples

Horizontal plane at z = 3::

g.model.geometry.add_axis_cutting_plane('z', offset=3.0)

Vertical YZ-plane passing through x = 1.5::

g.model.geometry.add_axis_cutting_plane('x', offset=1.5)

Horizontal plane tilted 15° about the y-axis::

g.model.geometry.add_axis_cutting_plane(
    'z', offset=0.0,
    rotation=15.0, rotation_about='y',
)
Source code in src/apeGmsh/core/_model_geometry.py
def add_axis_cutting_plane(
    self,
    axis          : Literal['x', 'y', 'z'],
    offset        : float = 0.0,
    *,
    origin        : list[float] | ndarray | None = None,
    rotation      : float                        = 0.0,
    rotation_about: Literal['x', 'y', 'z'] | None = None,
    label         : str | None                   = None,
    sync          : bool                         = True,
) -> Tag:
    """
    Add an axis-aligned cutting plane, optionally tilted by a rotation.

    Convenience wrapper around :meth:`add_cutting_plane`.  The plane is
    initially defined as **normal to** ``axis`` (so ``axis='z'`` produces
    a horizontal XY-plane).  It is then:

    1. Offset along its base normal by ``offset``.
    2. Rotated by ``rotation`` degrees about ``rotation_about``
       (if both are given), producing a tilted plane through the same
       anchor point.

    Parameters
    ----------
    axis : {'x', 'y', 'z'}
        Axis the plane is **normal to**.  ``'z'`` -> XY plane, etc.
    offset : float, optional
        Signed distance along the base normal from ``origin``
        (or from the global origin if ``origin`` is None).
    origin : array-like of 3 floats, optional
        Anchor point before the offset is applied.  Defaults to (0, 0, 0).
    rotation : float, optional
        Rotation angle in **degrees**.  Requires ``rotation_about``
        to have any effect — passing ``rotation`` without
        ``rotation_about`` raises ``ValueError`` so silent
        no-ops do not sneak through.
    rotation_about : {'x', 'y', 'z'}, optional
        Axis about which the base normal is rotated.  Must differ
        from ``axis`` for the rotation to have any effect.
    label : str, optional
        Human-readable label stored in the internal registry.
    sync : bool, optional
        Synchronise the OCC kernel after creation (default True).

    Returns
    -------
    Tag
        Surface tag of the new cutting plane.

    Examples
    --------
    Horizontal plane at z = 3::

        g.model.geometry.add_axis_cutting_plane('z', offset=3.0)

    Vertical YZ-plane passing through x = 1.5::

        g.model.geometry.add_axis_cutting_plane('x', offset=1.5)

    Horizontal plane tilted 15° about the y-axis::

        g.model.geometry.add_axis_cutting_plane(
            'z', offset=0.0,
            rotation=15.0, rotation_about='y',
        )
    """
    if axis not in _AXIS_UNIT_VEC:
        raise ValueError(
            f"axis must be one of 'x', 'y', 'z'; got {axis!r}"
        )
    if rotation != 0.0 and rotation_about is None:
        raise ValueError(
            "rotation was provided without rotation_about — pass "
            "rotation_about='x'|'y'|'z' to tilt the plane, or drop "
            "the rotation argument to keep it axis-aligned."
        )

    base_normal = _AXIS_UNIT_VEC[axis].copy()

    if rotation_about is not None and rotation != 0.0:
        if rotation_about not in _AXIS_UNIT_VEC:
            raise ValueError(
                f"rotation_about must be one of 'x', 'y', 'z'; "
                f"got {rotation_about!r}"
            )
        if rotation_about == axis:
            self._model._log(
                f"add_axis_cutting_plane: rotation_about='{rotation_about}' "
                f"equals axis='{axis}'; rotation has no effect."
            )
        else:
            theta = np.deg2rad(rotation)
            k     = _AXIS_UNIT_VEC[rotation_about]
            c, s  = np.cos(theta), np.sin(theta)
            # Rodrigues' rotation formula — k is a unit vector.
            base_normal = (
                base_normal * c
                + np.cross(k, base_normal) * s
                + k * np.dot(k, base_normal) * (1.0 - c)
            )

    if origin is None:
        origin_arr = np.zeros(3)
    else:
        origin_arr = np.asarray(origin, dtype=float)
    if origin_arr.shape != (3,):
        raise ValueError(
            f"origin must be a length-3 vector; got shape {origin_arr.shape}"
        )

    point = origin_arr + offset * base_normal

    # Coincident-face advisory: an axis-aligned cutting plane that
    # sits at the same coordinate as an existing face leaves the
    # tool stranded after OCC ``fragment`` consumes it.  The sweep
    # picks the orphan up, but warning here lets users refactor
    # the offset to avoid the coincidence entirely.  Skip tilted
    # planes — the bbox-degeneracy test below is exact for axis-
    # aligned planes only.
    if rotation == 0.0 or rotation_about is None or rotation_about == axis:
        self._warn_if_coincident_face(axis, point[_AXIS_IDX[axis]])

    self._model._log(
        f"add_axis_cutting_plane(axis={axis!r}, offset={offset}, "
        f"origin={tuple(origin_arr)}, rotation={rotation}, "
        f"rotation_about={rotation_about!r}) "
        f"-> point={tuple(point)}, normal={tuple(base_normal)}"
    )

    return self.add_cutting_plane(
        point=point,
        normal_vector=base_normal,
        label=label,
        sync=sync,
    )

cut_by_surface

cut_by_surface(solid: Tag | str | list[Tag | str] | None, surface, *, keep_surface: bool = True, remove_original: bool = True, label: str | None = None, tolerance: float | None = None) -> list[Tag]

Split one or more solids with an arbitrary cutting surface.

Uses OCC's fragment operation under the hood, which splits every input shape at its intersections and keeps all resulting sub-shapes. Unlike :meth:cut_by_plane, this method does not classify the output pieces — callers that need "above/below" semantics should use :meth:cut_by_plane (which delegates here and adds the classification step).

After the fragment, :func:sweep_dangling reaps any free-floating dim<=2 entities that bound no surviving volume (so callers never see the cutting plane's corner points / edges / trimmed surface as orphans), plus any stale _metadata entry whose tag was consumed by OCC. When keep_surface=False the tool surface itself is forced into the sweep's removal set.

Parameters

solid : Tag, list[Tag], or None Volume(s) to cut. When None, every registered volume in the model is cut against the surface. surface : Tag, str, or (2, tag) The cutting surface. Can be any registered 2-D entity — a plane from :meth:add_cutting_plane, a STEP-imported trimmed surface, a Coons patch, etc. Accepts a raw tag, a label or PG name, or an explicit (2, tag) tuple. Must resolve to exactly one surface. keep_surface : bool, default True Leave the (now-trimmed) surface in the model after the cut. Useful when you want to mesh the cut interface as a shared face for conformal ties. Set to False to delete it. remove_original : bool, default True Consume the original solid(s) so only the cut pieces remain. When False, OCC keeps the originals alongside the pieces, which usually produces overlapping geometry and is rarely what you want. label : str, optional Label applied to every new volume fragment in the registry. Pass None to leave the fragments unlabelled. tolerance : float, optional Override Geometry.ToleranceBoolean for the duration of the fragment (restored afterwards). Raise it (e.g. tolerance=1e-3 on a mm-scale model) when a near- coincident cutting surface defeats OCC's default coincidence detection and the cut produces nothing or leaves debris. None (default) leaves the global tolerance unchanged.

Returns

list[Tag] Solid tags of the fragments produced by the cut, in the order OCC returns them. An empty list means the cut produced nothing new (shouldn't happen unless the surface misses every input solid entirely).

Example

::

box   = g.model.geometry.add_box(0, 0, 0, 1, 1, 1)
plane = g.model.geometry.add_axis_cutting_plane('z', offset=0.5)
pieces = g.model.geometry.cut_by_surface(box, plane)
Source code in src/apeGmsh/core/_model_geometry.py
def cut_by_surface(
    self,
    solid          : Tag | str | list[Tag | str] | None,
    surface,
    *,
    keep_surface   : bool = True,
    remove_original: bool = True,
    label          : str | None = None,
    tolerance      : float | None = None,
) -> list[Tag]:
    """
    Split one or more solids with an arbitrary cutting surface.

    Uses OCC's ``fragment`` operation under the hood, which splits
    every input shape at its intersections and keeps **all**
    resulting sub-shapes.  Unlike :meth:`cut_by_plane`, this method
    does not classify the output pieces — callers that need
    "above/below" semantics should use :meth:`cut_by_plane` (which
    delegates here and adds the classification step).

    After the fragment, :func:`sweep_dangling` reaps any free-floating
    dim<=2 entities that bound no surviving volume (so callers never
    see the cutting plane's corner points / edges / trimmed surface
    as orphans), plus any stale ``_metadata`` entry whose tag was
    consumed by OCC.  When ``keep_surface=False`` the tool surface
    itself is forced into the sweep's removal set.

    Parameters
    ----------
    solid : Tag, list[Tag], or None
        Volume(s) to cut.  When ``None``, every registered volume
        in the model is cut against the surface.
    surface : Tag, str, or (2, tag)
        The cutting surface.  Can be any registered 2-D entity —
        a plane from :meth:`add_cutting_plane`, a STEP-imported
        trimmed surface, a Coons patch, etc.  Accepts a raw tag,
        a label or PG name, or an explicit ``(2, tag)`` tuple.
        Must resolve to exactly one surface.
    keep_surface : bool, default True
        Leave the (now-trimmed) surface in the model after the
        cut.  Useful when you want to mesh the cut interface as a
        shared face for conformal ties.  Set to ``False`` to
        delete it.
    remove_original : bool, default True
        Consume the original solid(s) so only the cut pieces
        remain.  When ``False``, OCC keeps the originals alongside
        the pieces, which usually produces overlapping geometry
        and is rarely what you want.
    label : str, optional
        Label applied to every new volume fragment in the
        registry.  Pass ``None`` to leave the fragments unlabelled.
    tolerance : float, optional
        Override ``Geometry.ToleranceBoolean`` for the duration of
        the fragment (restored afterwards).  Raise it (e.g.
        ``tolerance=1e-3`` on a mm-scale model) when a near-
        coincident cutting surface defeats OCC's default
        coincidence detection and the cut produces nothing or leaves
        debris.  ``None`` (default) leaves the global tolerance
        unchanged.

    Returns
    -------
    list[Tag]
        Solid tags of the fragments produced by the cut, in the
        order OCC returns them.  An empty list means the cut
        produced nothing new (shouldn't happen unless the surface
        misses every input solid entirely).

    Example
    -------
    ::

        box   = g.model.geometry.add_box(0, 0, 0, 1, 1, 1)
        plane = g.model.geometry.add_axis_cutting_plane('z', offset=0.5)
        pieces = g.model.geometry.cut_by_surface(box, plane)
    """
    solid_tags = self._normalize_solid_input(solid, self._collect_volume_tags)

    # Sync before resolving: the dim auto-detector queries
    # gmsh.model.getEntities, which only sees synced OCC state.
    # Without this, an unsynced surface tag can be misread as a
    # stale curve sharing the same numeric tag value.
    from ._helpers import resolve_to_single_dimtag
    gmsh.model.occ.synchronize()
    surf_dim, surf_tag = resolve_to_single_dimtag(
        surface, default_dim=2, session=self._model._parent,
        what="cutting surface",
    )
    if surf_dim != 2:
        raise ValueError(
            f"cut_by_surface: surface ref {surface!r} resolved to "
            f"dim={surf_dim} (tag {surf_tag}); expected a 2-D entity."
        )

    obj_dt = [(3, int(t)) for t in solid_tags]
    tool_dt = [(2, int(surf_tag))]

    from ._model_queries import _temporary_tolerance
    with pg_preserved() as pg, _temporary_tolerance(
        tolerance, keys=("Geometry.ToleranceBoolean",),
    ):
        out_dimtags, result_map = gmsh.model.occ.fragment(
            obj_dt,
            tool_dt,
            removeObject=remove_original,
            removeTool=not keep_surface,
        )
        # Always sync before PG remap — remap needs topology visible.
        gmsh.model.occ.synchronize()
        pg.set_result(obj_dt + tool_dt, result_map)

    new_volume_tags: list[Tag] = [
        int(t) for (d, t) in out_dimtags if d == 3
    ]

    if remove_original:
        for t in solid_tags:
            self._model._metadata.pop((3, int(t)), None)

    # Register metadata for new fragments. Label= is only passed
    # when the user explicitly provided one; inherited labels are
    # already handled by the snapshot-remap above.
    for t in new_volume_tags:
        self._model._register(3, t, label, 'cut_fragment')

    if keep_surface:
        surviving_surfaces = [
            int(t) for (d, t) in out_dimtags if d == 2
        ]
        for t in surviving_surfaces:
            if (2, t) not in self._model._metadata:
                self._model._register(2, t, None, 'cut_interface')

    # Reap any free-floating dim<=2 entities OCC left behind.
    # When the surface is being consumed, force its removal —
    # add_cutting_plane registers it in _metadata so the default
    # user-intentional check would preserve it.
    also = None if keep_surface else {(2, int(surf_tag))}
    sweep_dangling(self._model, also_remove=also)

    self._model._log(
        f"cut_by_surface(solids={solid_tags}, surface={int(surf_tag)}) "
        f"-> {len(new_volume_tags)} volume fragment(s): {new_volume_tags}"
    )
    return new_volume_tags

cut_by_plane

cut_by_plane(solid: Tag | str | list[Tag | str] | None, plane, *, keep_plane: bool = True, remove_original: bool = True, above_direction: list[float] | ndarray | None = None, label_above: str | None = None, label_below: str | None = None, sync: bool = True, tolerance: float | None = None) -> tuple[list[Tag], list[Tag]]

Split one or more solids with a plane and classify the resulting pieces by which side of the plane they sit on.

Thin wrapper around :meth:cut_by_surface that additionally computes which fragments are "above" (same side as the plane normal) vs "below" the plane. The normal direction is resolved from, in order of priority:

  1. An explicit above_direction argument.
  2. The normal and point stashed in the registry by :meth:add_cutting_plane / :meth:add_axis_cutting_plane.
  3. gmsh.model.getNormal sampled at the parametric centre of the plane surface.
Parameters

solid : Tag, list[Tag], or None Volume(s) to cut. None = every registered volume. plane : Tag Planar surface to cut with. Accepts a raw tag, a label or PG name, or an explicit (2, tag) tuple — must resolve to exactly one 2-D entity. Ideally built by :meth:add_cutting_plane so its normal and point are in the registry; other planar surfaces work too but require an explicit above_direction or fall back to querying Gmsh. keep_plane : bool, default True Leave the trimmed plane in the model as a registered surface (useful for meshing the cut interface). remove_original : bool, default True Consume the original solid(s). above_direction : array-like of 3 floats, optional Override the plane's normal direction. Pieces whose centroid dotted with this vector (relative to the plane point) is positive are classified as "above". label_above, label_below : str, optional Labels applied to the above / below fragment solids. sync : bool, default True Synchronise the OCC kernel after the cut.

Returns

tuple[list[Tag], list[Tag]] (above_tags, below_tags) — solid tags on each side of the plane, classified by the sign of (centroid - plane_point) · normal.

Example

::

col = g.model.geometry.add_box(0, 0, 0, 1, 1, 3)
pl  = g.model.geometry.add_axis_cutting_plane('z', offset=1.5)

top, bot = g.model.geometry.cut_by_plane(
    col, pl,
    label_above="col_upper", label_below="col_lower",
)
Source code in src/apeGmsh/core/_model_geometry.py
def cut_by_plane(
    self,
    solid          : Tag | str | list[Tag | str] | None,
    plane,
    *,
    keep_plane     : bool = True,
    remove_original: bool = True,
    above_direction: list[float] | ndarray | None = None,
    label_above    : str | None = None,
    label_below    : str | None = None,
    sync           : bool = True,
    tolerance      : float | None = None,
) -> tuple[list[Tag], list[Tag]]:
    """
    Split one or more solids with a plane and classify the
    resulting pieces by which side of the plane they sit on.

    Thin wrapper around :meth:`cut_by_surface` that additionally
    computes which fragments are "above" (same side as the plane
    normal) vs "below" the plane.  The normal direction is
    resolved from, in order of priority:

    1. An explicit ``above_direction`` argument.
    2. The ``normal`` and ``point`` stashed in the registry by
       :meth:`add_cutting_plane` / :meth:`add_axis_cutting_plane`.
    3. ``gmsh.model.getNormal`` sampled at the parametric centre
       of the plane surface.

    Parameters
    ----------
    solid : Tag, list[Tag], or None
        Volume(s) to cut.  ``None`` = every registered volume.
    plane : Tag
        Planar surface to cut with.  Accepts a raw tag, a label
        or PG name, or an explicit ``(2, tag)`` tuple — must
        resolve to exactly one 2-D entity.  Ideally built by
        :meth:`add_cutting_plane` so its normal and point are in
        the registry; other planar surfaces work too but require
        an explicit ``above_direction`` or fall back to querying
        Gmsh.
    keep_plane : bool, default True
        Leave the trimmed plane in the model as a registered
        surface (useful for meshing the cut interface).
    remove_original : bool, default True
        Consume the original solid(s).
    above_direction : array-like of 3 floats, optional
        Override the plane's normal direction.  Pieces whose
        centroid dotted with this vector (relative to the plane
        point) is positive are classified as "above".
    label_above, label_below : str, optional
        Labels applied to the above / below fragment solids.
    sync : bool, default True
        Synchronise the OCC kernel after the cut.

    Returns
    -------
    tuple[list[Tag], list[Tag]]
        ``(above_tags, below_tags)`` — solid tags on each side of
        the plane, classified by the sign of
        ``(centroid - plane_point) · normal``.

    Example
    -------
    ::

        col = g.model.geometry.add_box(0, 0, 0, 1, 1, 3)
        pl  = g.model.geometry.add_axis_cutting_plane('z', offset=1.5)

        top, bot = g.model.geometry.cut_by_plane(
            col, pl,
            label_above="col_upper", label_below="col_lower",
        )
    """
    # Sync before resolving: the dim auto-detector queries
    # gmsh.model.getEntities, which only sees synced OCC state.
    # Without this, an unsynced plane tag can be misread as a
    # stale curve sharing the same numeric tag value.
    from ._helpers import resolve_to_single_dimtag
    gmsh.model.occ.synchronize()
    plane_dim, plane_tag = resolve_to_single_dimtag(
        plane, default_dim=2, session=self._model._parent,
        what="cutting plane",
    )
    if plane_dim != 2:
        raise ValueError(
            f"cut_by_plane: plane ref {plane!r} resolved to "
            f"dim={plane_dim} (tag {plane_tag}); expected a 2-D entity."
        )
    plane_tag = int(plane_tag)
    normal, point = self._resolve_plane_normal(
        plane_tag, above_direction,
    )

    # Perform the actual cut via the general surface method.
    fragments = self.cut_by_surface(
        solid,
        plane_tag,
        keep_surface=keep_plane,
        remove_original=remove_original,
        label=None,          # we re-label by side below
        tolerance=tolerance,
    )

    above_dt, below_dt = self._classify_fragments(
        [(3, int(t)) for t in fragments], normal, point,
        label_above=label_above,
        label_below=label_below,
    )
    above_tags = [t for _, t in above_dt]
    below_tags = [t for _, t in below_dt]

    if not above_tags or not below_tags:
        import warnings
        from ._geometry_errors import WarnGeomOneSidedCut
        warnings.warn(
            f"cut_by_plane: plane {plane_tag} produced only one "
            f"side ({len(above_tags)} above, {len(below_tags)} "
            f"below) — the plane likely sits outside the solid's "
            f"bounding box. Returning the tuple anyway so callers "
            f"that pattern-match on (above, below) don't crash.",
            WarnGeomOneSidedCut,
            stacklevel=2,
        )

    if sync:
        gmsh.model.occ.synchronize()

    self._model._log(
        f"cut_by_plane(plane={plane_tag}) -> "
        f"above={above_tags}, below={below_tags}"
    )
    return above_tags, below_tags

slice

slice(target: Tag | str | list[Tag | str] | None = None, *, axis: Literal['x', 'y', 'z'], offset: float = 0.0, point: list[float] | ndarray | None = None, dim: SliceDim = 'all', classify: bool = False, label: str | None = None, sync: bool = True, tolerance: float | None = None) -> list[Tag] | tuple[list[Tag], list[Tag]]

Slice entities at an axis-aligned plane in one atomic call.

Internally creates a temporary cutting plane, fragments the target entities, removes the cutting plane (and any trimmed geometry it left behind), and returns the fragments. Runs :func:sweep_dangling after the cut so no orphaned dim<=2 geometry survives — even when the cutting plane is coincident with an existing face of the operand. If the plane is coincident with an existing face, :class:WarnGeomCoincidentFace fires first as an advisory; the sweep cleans up regardless.

The plane location is given either as offset (signed distance from the global origin along the axis) or as point (a point the plane passes through) — not both.

Parameters

target : Tag, str, list, or None Entity (or entities) to slice — a raw tag, a label name, or a list of either. None slices everything the plane crosses, governed by dim. (Renamed from solid; the operation is no longer volume-only.) axis : {'x', 'y', 'z'} Axis the plane is normal to. 'z' slices with a horizontal XY-plane, etc. offset : float, default 0.0 Signed distance along the axis from the global origin. Mutually exclusive with point. point : array-like of 3 floats, optional A point the cutting plane passes through. Only the coordinate along axis matters (the plane is axis-aligned), but a full 3-vector is accepted for convenience. Mutually exclusive with a non-zero offset. dim : {1, 2, 3, 'all'}, default 'all' Which entity dimension to slice. 'all' (default) slices every maximal entity in the model — volumes in a solid model, surfaces in a shell model, curves in a frame model — which collapses to the historical volume-only behaviour for solid models. An explicit 1 / 2 / 3 restricts the cut to entities of exactly that dimension. Bare integer targets under 'all' resolve to their highest dimension. classify : bool, default False When True, returns (positive_side, negative_side) classified by the plane's normal direction (the positive axis direction). When False (default), returns all fragments as a flat list. label : str, optional Label applied to every fragment in the registry. sync : bool, default True Synchronise the OCC kernel after the operation.

Returns

list[Tag] All fragment tags (when classify=False). tuple[list[Tag], list[Tag]] (positive_side, negative_side) fragments classified by which side of the plane each piece's centroid sits on (when classify=True).

Example

::

# Slice a box at y = 0.5
box = g.model.geometry.add_box(0, 0, 0, 1, 1, 1)
pieces = g.model.geometry.slice(box, axis='y', offset=0.5)

# Slice through a point instead of an offset
g.model.geometry.slice(box, axis='z', point=(0, 0, 0.5))

# Slice and classify
top, bot = g.model.geometry.slice(
    box, axis='z', offset=0.5, classify=True,
)

# Slice every shell surface at x = 0
g.model.geometry.slice(axis='x', offset=0.0, dim=2)
Source code in src/apeGmsh/core/_model_geometry.py
def slice(
    self,
    target  : Tag | str | list[Tag | str] | None = None,
    *,
    axis    : Literal['x', 'y', 'z'],
    offset  : float = 0.0,
    point   : list[float] | ndarray | None = None,
    dim     : SliceDim = 'all',
    classify: bool = False,
    label   : str | None = None,
    sync    : bool = True,
    tolerance: float | None = None,
) -> list[Tag] | tuple[list[Tag], list[Tag]]:
    """
    Slice entities at an axis-aligned plane in one atomic call.

    Internally creates a temporary cutting plane, fragments the
    target entities, removes the cutting plane (and any trimmed
    geometry it left behind), and returns the fragments.  Runs
    :func:`sweep_dangling` after the cut so no orphaned dim<=2
    geometry survives — even when the cutting plane is coincident
    with an existing face of the operand.  If the plane is
    coincident with an existing face, :class:`WarnGeomCoincidentFace`
    fires first as an advisory; the sweep cleans up regardless.

    The plane location is given **either** as ``offset`` (signed
    distance from the global origin along the axis) **or** as
    ``point`` (a point the plane passes through) — not both.

    Parameters
    ----------
    target : Tag, str, list, or None
        Entity (or entities) to slice — a raw tag, a label name, or
        a list of either.  ``None`` slices everything the plane
        crosses, governed by ``dim``.  (Renamed from ``solid``; the
        operation is no longer volume-only.)
    axis : {'x', 'y', 'z'}
        Axis the plane is **normal to**.  ``'z'`` slices with
        a horizontal XY-plane, etc.
    offset : float, default 0.0
        Signed distance along the axis from the global origin.
        Mutually exclusive with ``point``.
    point : array-like of 3 floats, optional
        A point the cutting plane passes through.  Only the
        coordinate along ``axis`` matters (the plane is axis-aligned),
        but a full 3-vector is accepted for convenience.  Mutually
        exclusive with a non-zero ``offset``.
    dim : {1, 2, 3, 'all'}, default 'all'
        Which entity dimension to slice.  ``'all'`` (default) slices
        every *maximal* entity in the model — volumes in a solid
        model, surfaces in a shell model, curves in a frame model —
        which collapses to the historical volume-only behaviour for
        solid models.  An explicit ``1`` / ``2`` / ``3`` restricts
        the cut to entities of exactly that dimension.  Bare integer
        targets under ``'all'`` resolve to their highest dimension.
    classify : bool, default False
        When True, returns ``(positive_side, negative_side)``
        classified by the plane's normal direction (the positive
        axis direction).  When False (default), returns all
        fragments as a flat list.
    label : str, optional
        Label applied to every fragment in the registry.
    sync : bool, default True
        Synchronise the OCC kernel after the operation.

    Returns
    -------
    list[Tag]
        All fragment tags (when ``classify=False``).
    tuple[list[Tag], list[Tag]]
        ``(positive_side, negative_side)`` fragments classified
        by which side of the plane each piece's centroid sits on
        (when ``classify=True``).

    Example
    -------
    ::

        # Slice a box at y = 0.5
        box = g.model.geometry.add_box(0, 0, 0, 1, 1, 1)
        pieces = g.model.geometry.slice(box, axis='y', offset=0.5)

        # Slice through a point instead of an offset
        g.model.geometry.slice(box, axis='z', point=(0, 0, 0.5))

        # Slice and classify
        top, bot = g.model.geometry.slice(
            box, axis='z', offset=0.5, classify=True,
        )

        # Slice every shell surface at x = 0
        g.model.geometry.slice(axis='x', offset=0.0, dim=2)
    """
    if dim not in ('all', 1, 2, 3):
        raise ValueError(
            f"dim must be 1, 2, 3, or 'all'; got {dim!r}"
        )
    if point is not None and offset != 0.0:
        raise ValueError(
            "pass either point= or offset=, not both — point gives the "
            "plane location directly; offset gives it relative to the "
            "global origin."
        )

    # Resolve targets BEFORE creating the cutting plane — the plane
    # is itself a dim-2 surface, so a target=None / dim in {2,'all'}
    # collection done afterwards would sweep it up as an operand and
    # fragment the plane against itself.
    obj_dimtags = self._resolve_target_dimtags(target, dim=dim)

    # Build the cutting plane.  point= anchors the plane (offset 0
    # along the normal); otherwise offset= from the global origin.
    if point is not None:
        plane_tag = self.add_axis_cutting_plane(
            axis, offset=0.0, origin=point, sync=False,
        )
    else:
        plane_tag = self.add_axis_cutting_plane(
            axis, offset=offset, sync=False,
        )

    # Read the plane geometry *before* the fragment consumes it.
    normal, plane_point = self._resolve_plane_normal(plane_tag, None)

    new_dimtags = self._fragment_targets(
        obj_dimtags, plane_tag,
        label=None if classify else label,
        tolerance=tolerance,
    )

    result: list[Tag] | tuple[list[Tag], list[Tag]]
    if classify:
        above, below = self._classify_fragments(
            new_dimtags, normal, plane_point,
            label_above=label, label_below=label,
        )
        if not above or not below:
            import warnings
            from ._geometry_errors import WarnGeomOneSidedCut
            warnings.warn(
                f"slice: plane produced only one side "
                f"({len(above)} above, {len(below)} below) — the "
                f"plane likely sits outside the target's bounding "
                f"box. Returning the tuple anyway so callers that "
                f"pattern-match on (above, below) don't crash.",
                WarnGeomOneSidedCut,
                stacklevel=2,
            )
        result = ([t for _, t in above], [t for _, t in below])
    else:
        result = [t for _, t in new_dimtags]

    # Reap the consumed cutting plane and any dim<=2 debris it left
    # behind.  Registered fragments (including sliced surfaces /
    # curves, which bound no volume) are protected by _metadata.
    sweep_dangling(self._model, also_remove={(2, plane_tag)})

    if sync:
        gmsh.model.occ.synchronize()

    self._model._log(
        f"slice(axis={axis!r}, offset={offset}, point={point}, "
        f"dim={dim!r}, classify={classify}) -> "
        f"{len(new_dimtags)} fragment(s)"
    )
    return result

find_orphans

find_orphans() -> dict[int, list[int]]

Inspect the model for orphan geometry without modifying it.

Returns the dimtags that :meth:remove_orphans (and the post-op sweep that runs internally inside :meth:slice / :meth:cut_by_surface / :meth:cut_by_plane / :meth:_Boolean.fragment) would reap: dim<=2 entities that bound no registered volume and are not user-intentional (not in model._metadata, no label).

Returns

dict[int, list[int]] {0: [...], 1: [...], 2: [...]} — orphan tags at every dim <= 2. An empty list at every key means the model is clean.

Source code in src/apeGmsh/core/_model_geometry.py
def find_orphans(self) -> dict[int, list[int]]:
    """Inspect the model for orphan geometry without modifying it.

    Returns the dimtags that :meth:`remove_orphans` (and the
    post-op sweep that runs internally inside
    :meth:`slice` / :meth:`cut_by_surface` / :meth:`cut_by_plane`
    / :meth:`_Boolean.fragment`) would reap: dim<=2 entities that
    bound no registered volume and are not user-intentional (not
    in ``model._metadata``, no label).

    Returns
    -------
    dict[int, list[int]]
        ``{0: [...], 1: [...], 2: [...]}`` — orphan tags at every
        dim <= 2.  An empty list at every key means the model is
        clean.
    """
    return sweep_dangling(self._model, dry_run=True)

remove_orphans

remove_orphans(*, dry_run: bool = False) -> dict[int, list[int]]

Run the orphan sweep manually.

Identical algorithm to the post-op sweep the cut / fragment operations run internally — exposed so callers can clean up after a hand-written OCC operation or after pickling / re-loading geometry from a side channel.

Parameters

dry_run When True, behave like :meth:find_orphans (no modification) but return the same dict shape.

Returns

dict[int, list[int]] {dim: [tags]} — the tags that were (or would be) removed.

Source code in src/apeGmsh/core/_model_geometry.py
def remove_orphans(
    self,
    *,
    dry_run: bool = False,
) -> dict[int, list[int]]:
    """Run the orphan sweep manually.

    Identical algorithm to the post-op sweep the cut / fragment
    operations run internally — exposed so callers can clean up
    after a hand-written OCC operation or after pickling /
    re-loading geometry from a side channel.

    Parameters
    ----------
    dry_run
        When True, behave like :meth:`find_orphans` (no
        modification) but return the same dict shape.

    Returns
    -------
    dict[int, list[int]]
        ``{dim: [tags]}`` — the tags that were (or would be)
        removed.
    """
    return sweep_dangling(self._model, dry_run=dry_run)

find_stale_metadata

find_stale_metadata() -> list[DimTag]

Return model._metadata keys whose tag is no longer in OCC.

Closed-world inspection. Walks only the entries the apeGmsh add_* / boolean / cut / fragment primitives recorded — never the live OCC entity list. By construction it cannot false-positive on raw gmsh.model.geo.* / gmsh.model.occ.* workflows: those workflows don't populate _metadata in the first place, so any key the check inspects came from apeGmsh's own code, and a stale key means an apeGmsh-managed entity was consumed without a matching cleanup.

Counterpart to :meth:find_orphans, which does the full open-world scan and so MUST stay opt-in (see :meth:validate_pre_mesh rationale below).

Returns

list[tuple[int, int]] Stale (dim, tag) keys, in iteration order. Empty list means every registered key still points at a live OCC entity.

Source code in src/apeGmsh/core/_model_geometry.py
def find_stale_metadata(self) -> list[DimTag]:
    """Return ``model._metadata`` keys whose tag is no longer in OCC.

    **Closed-world inspection.**  Walks only the entries the
    apeGmsh ``add_*`` / boolean / cut / fragment primitives
    recorded — never the live OCC entity list.  By construction
    it cannot false-positive on raw ``gmsh.model.geo.*`` /
    ``gmsh.model.occ.*`` workflows: those workflows don't
    populate ``_metadata`` in the first place, so any key the
    check inspects came from apeGmsh's own code, and a stale key
    means an apeGmsh-managed entity was consumed without a
    matching cleanup.

    Counterpart to :meth:`find_orphans`, which does the full
    open-world scan and so MUST stay opt-in (see
    :meth:`validate_pre_mesh` rationale below).

    Returns
    -------
    list[tuple[int, int]]
        Stale ``(dim, tag)`` keys, in iteration order.  Empty
        list means every registered key still points at a live
        OCC entity.
    """
    # A read diagnostic, not a mutation: synchronize() pushes OCC
    # into the model but changes no content, so this takes the
    # kernel guard rather than the freeze guard.  validate_pre_mesh
    # inherits it through this call, which is why it needs none of
    # its own — and Mesh.generate, its only internal caller, is
    # already frozen upstream.
    from ._compose_errors import raise_if_no_live_kernel
    raise_if_no_live_kernel(
        self._model._parent,
        "g.model.geometry.find_stale_metadata()",
        alternative=(
            "there is nothing to check on a chain-phase session — "
            "the broker snapshot has no OCC entities behind it, so "
            "no _metadata key can be stale"
        ),
    )
    gmsh.model.occ.synchronize()
    live: set[DimTag] = set()
    for d in range(4):
        for _, t in gmsh.model.getEntities(d):
            live.add((d, int(t)))
    return [dt for dt in self._model._metadata if dt not in live]

validate_pre_mesh

validate_pre_mesh(*, strict: bool = False) -> None

Raise :class:GeometryValidationError on pre-mesh hazards.

Two modes, gated by strict:

  • strict=False (default; what :meth:Mesh.generate auto-invokes) — runs :meth:find_stale_metadata only. Closed-world. Catches the actual leak class the audit was chasing: an apeGmsh boolean / cut / fragment op consumed an entity without cleaning its _metadata key. Cannot false-positive on raw-gmsh.model.geo.* workflows (they don't populate _metadata).
  • strict=True (opt-in) — runs :meth:find_orphans and raises on any orphan dim<=2 entity. Open-world. Users opt in when they know their build script stays inside the apeGmsh facade (_metadata + g.labels channels); raw-gmsh users WILL trip it on legitimate models.

The split is deliberate: closed-world is the auto-fire mode because it cannot punish users for working below the apeGmsh facade. Open-world is opt-in for the same reason.

Mirrors :meth:MassesComposite.validate_pre_mesh / :meth:LoadsComposite.validate_pre_mesh / :meth:ConstraintsComposite.validate_pre_mesh (those are intrinsically closed-world; the strict=False default here matches the same contract — only inspect what the composite itself recorded).

Source code in src/apeGmsh/core/_model_geometry.py
def validate_pre_mesh(self, *, strict: bool = False) -> None:
    """Raise :class:`GeometryValidationError` on pre-mesh hazards.

    Two modes, gated by ``strict``:

    - ``strict=False`` (default; what :meth:`Mesh.generate`
      auto-invokes) — runs :meth:`find_stale_metadata` only.
      Closed-world.  Catches the actual leak class the audit was
      chasing: an apeGmsh boolean / cut / fragment op consumed
      an entity without cleaning its ``_metadata`` key.  Cannot
      false-positive on raw-``gmsh.model.geo.*`` workflows
      (they don't populate ``_metadata``).
    - ``strict=True`` (opt-in) — runs :meth:`find_orphans` and
      raises on any orphan dim<=2 entity.  Open-world.  Users
      opt in when they know their build script stays inside the
      apeGmsh facade (``_metadata`` + ``g.labels`` channels);
      raw-gmsh users WILL trip it on legitimate models.

    The split is deliberate: closed-world is the auto-fire mode
    because it cannot punish users for working below the apeGmsh
    facade.  Open-world is opt-in for the same reason.

    Mirrors :meth:`MassesComposite.validate_pre_mesh` /
    :meth:`LoadsComposite.validate_pre_mesh` /
    :meth:`ConstraintsComposite.validate_pre_mesh` (those are
    intrinsically closed-world; the ``strict=False`` default
    here matches the same contract — only inspect what the
    composite itself recorded).
    """
    from ._geometry_errors import GeometryValidationError
    if strict:
        orphans = self.find_orphans()
        offending = {d: ts for d, ts in orphans.items() if ts}
        if offending:
            raise GeometryValidationError(
                f"geometry carries orphan entities that bound no "
                f"registered volume: {offending}. Run "
                f"g.model.geometry.remove_orphans() to sweep them, "
                f"or call g.model.geometry.find_orphans() to "
                f"inspect first."
            )
        return
    stale = self.find_stale_metadata()
    if stale:
        raise GeometryValidationError(
            f"model._metadata has stale entries — keys point at "
            f"tags no longer in OCC: {stale}. An apeGmsh boolean "
            f"/ cut / fragment op consumed these entities without "
            f"cleaning their registry entries. Run "
            f"g.model.geometry.remove_orphans() to sweep them "
            f"(which also reaps stale metadata), or file an issue "
            f"naming the operation that produced the leak."
        )
    voids = [
        dt for dt, meta in self._model._metadata.items()
        if meta.get("role") == "void"
    ]
    if voids:
        labels_comp = getattr(self._model._parent, "labels", None)
        names: list[str] = []
        for d, t in voids:
            labs: list[str] = []
            if labels_comp is not None:
                try:
                    labs = labels_comp.labels_for_entity(int(d), int(t))
                except Exception:
                    labs = []
            if labs:
                names.append(repr(labs[0]))
            else:
                names.append(f"(dim={int(d)}, tag={int(t)})")
        raise GeometryValidationError(
            f"unapplied void tools would mesh as solids: "
            f"{', '.join(names)}. Call g.model.boolean.cut(host, "
            f"tool) or g.model.boolean.apply_voids(host) first."
        )

add_box

add_box(x: float, y: float, z: float, dx: float, dy: float, dz: float, *, label: str | None = None, sync: bool = True, as_void: bool = False) -> Tag

Add an axis-aligned box.

Parameters

x, y, z : origin corner dx, dy, dz : extents along X, Y, Z as_void : bool If True, mark the box as a boolean tool (ADR 0097). It must be subtracted via :meth:_Boolean.cut / :meth:_Boolean.apply_voids before generate().

Source code in src/apeGmsh/core/_model_geometry.py
def add_box(
    self,
    x: float, y: float, z: float,
    dx: float, dy: float, dz: float,
    *,
    label: str | None = None,
    sync : bool       = True,
    as_void: bool     = False,
) -> Tag:
    """
    Add an axis-aligned box.

    Parameters
    ----------
    x, y, z       : origin corner
    dx, dy, dz    : extents along X, Y, Z
    as_void : bool
        If True, mark the box as a boolean tool (ADR 0097).  It
        must be subtracted via :meth:`_Boolean.cut` /
        :meth:`_Boolean.apply_voids` before ``generate()``.
    """
    tag = gmsh.model.occ.addBox(x, y, z, dx, dy, dz)
    return self._add_solid(
        tag, 'box', f"add_box(origin=({x},{y},{z}), size=({dx},{dy},{dz}))",
        label=label, sync=sync, as_void=as_void,
    )

add_sphere

add_sphere(cx: float, cy: float, cz: float, radius: float, *, label: str | None = None, sync: bool = True, as_void: bool = False) -> Tag

Add a sphere centred at (cx, cy, cz) with the given radius.

as_void=True marks the sphere as a boolean tool (ADR 0097).

Source code in src/apeGmsh/core/_model_geometry.py
def add_sphere(
    self,
    cx: float, cy: float, cz: float,
    radius: float,
    *,
    label: str | None = None,
    sync : bool       = True,
    as_void: bool     = False,
) -> Tag:
    """Add a sphere centred at (cx, cy, cz) with the given radius.

    ``as_void=True`` marks the sphere as a boolean tool (ADR 0097).
    """
    tag = gmsh.model.occ.addSphere(cx, cy, cz, radius)
    return self._add_solid(
        tag, 'sphere', f"add_sphere(centre=({cx},{cy},{cz}), r={radius})",
        label=label, sync=sync, as_void=as_void,
    )

add_cylinder

add_cylinder(x: float, y: float, z: float, dx: float, dy: float, dz: float, radius: float, *, angle: float = 2 * math.pi, label: str | None = None, sync: bool = True, as_void: bool = False) -> Tag

Add a cylinder.

Parameters

x, y, z : base-circle centre dx, dy, dz : axis direction vector (length = height of cylinder) radius : base radius angle : sweep angle in radians (default 2π = full cylinder) as_void : bool If True, mark the cylinder as a boolean tool (ADR 0097).

Source code in src/apeGmsh/core/_model_geometry.py
def add_cylinder(
    self,
    x: float, y: float, z: float,
    dx: float, dy: float, dz: float,
    radius: float,
    *,
    angle: float      = 2 * math.pi,
    label: str | None = None,
    sync : bool       = True,
    as_void: bool     = False,
) -> Tag:
    """
    Add a cylinder.

    Parameters
    ----------
    x, y, z    : base-circle centre
    dx, dy, dz : axis direction vector (length = height of cylinder)
    radius     : base radius
    angle      : sweep angle in radians (default 2π = full cylinder)
    as_void : bool
        If True, mark the cylinder as a boolean tool (ADR 0097).
    """
    tag = gmsh.model.occ.addCylinder(x, y, z, dx, dy, dz, radius, angle=angle)
    return self._add_solid(
        tag, 'cylinder',
        f"add_cylinder(base=({x},{y},{z}), axis=({dx},{dy},{dz}), r={radius})",
        label=label, sync=sync, as_void=as_void,
    )

add_cone

add_cone(x: float, y: float, z: float, dx: float, dy: float, dz: float, r1: float, r2: float, *, angle: float = 2 * math.pi, label: str | None = None, sync: bool = True, as_void: bool = False) -> Tag

Add a cone / truncated cone.

Parameters

x, y, z : base-circle centre dx, dy, dz : axis vector r1 : base radius r2 : top radius (0 = sharp cone) angle : sweep angle in radians as_void : bool If True, mark the cone as a boolean tool (ADR 0097).

Source code in src/apeGmsh/core/_model_geometry.py
def add_cone(
    self,
    x: float, y: float, z: float,
    dx: float, dy: float, dz: float,
    r1: float, r2: float,
    *,
    angle: float      = 2 * math.pi,
    label: str | None = None,
    sync : bool       = True,
    as_void: bool     = False,
) -> Tag:
    """
    Add a cone / truncated cone.

    Parameters
    ----------
    x, y, z    : base-circle centre
    dx, dy, dz : axis vector
    r1         : base radius
    r2         : top radius (0 = sharp cone)
    angle      : sweep angle in radians
    as_void : bool
        If True, mark the cone as a boolean tool (ADR 0097).
    """
    tag = gmsh.model.occ.addCone(x, y, z, dx, dy, dz, r1, r2, angle=angle)
    return self._add_solid(
        tag, 'cone', f"add_cone(base=({x},{y},{z}), r1={r1}, r2={r2})",
        label=label, sync=sync, as_void=as_void,
    )

add_torus

add_torus(cx: float, cy: float, cz: float, r1: float, r2: float, *, angle: float = 2 * math.pi, label: str | None = None, sync: bool = True, as_void: bool = False) -> Tag

Add a torus.

Parameters

cx, cy, cz : centre r1 : major radius (axis to tube centre) r2 : minor radius (tube cross-section) angle : sweep angle in radians as_void : bool If True, mark the torus as a boolean tool (ADR 0097).

Source code in src/apeGmsh/core/_model_geometry.py
def add_torus(
    self,
    cx: float, cy: float, cz: float,
    r1: float, r2: float,
    *,
    angle: float      = 2 * math.pi,
    label: str | None = None,
    sync : bool       = True,
    as_void: bool     = False,
) -> Tag:
    """
    Add a torus.

    Parameters
    ----------
    cx, cy, cz : centre
    r1         : major radius (axis to tube centre)
    r2         : minor radius (tube cross-section)
    angle      : sweep angle in radians
    as_void : bool
        If True, mark the torus as a boolean tool (ADR 0097).
    """
    tag = gmsh.model.occ.addTorus(cx, cy, cz, r1, r2, angle=angle)
    return self._add_solid(
        tag, 'torus', f"add_torus(centre=({cx},{cy},{cz}), R={r1}, r={r2})",
        label=label, sync=sync, as_void=as_void,
    )

add_wedge

add_wedge(x: float, y: float, z: float, dx: float, dy: float, dz: float, ltx: float, *, label: str | None = None, sync: bool = True, as_void: bool = False) -> Tag

Add a right-angle wedge.

Parameters

x, y, z : origin corner dx, dy, dz : extents ltx : top X extent (0 = sharp wedge) as_void : bool If True, mark the wedge as a boolean tool (ADR 0097).

Source code in src/apeGmsh/core/_model_geometry.py
def add_wedge(
    self,
    x: float, y: float, z: float,
    dx: float, dy: float, dz: float,
    ltx: float,
    *,
    label: str | None = None,
    sync : bool       = True,
    as_void: bool     = False,
) -> Tag:
    """
    Add a right-angle wedge.

    Parameters
    ----------
    x, y, z    : origin corner
    dx, dy, dz : extents
    ltx        : top X extent (0 = sharp wedge)
    as_void : bool
        If True, mark the wedge as a boolean tool (ADR 0097).
    """
    tag = gmsh.model.occ.addWedge(x, y, z, dx, dy, dz, ltx=ltx)
    return self._add_solid(
        tag, 'wedge',
        f"add_wedge(origin=({x},{y},{z}), size=({dx},{dy},{dz}), ltx={ltx})",
        label=label, sync=sync, as_void=as_void,
    )

add_void_sweep

add_void_sweep(profile, path, *, label: str | None = None, sync: bool = True) -> Tag

Sweep a profile along a path and mark the solid as a void tool.

Orchestration over the shipped pipe (ADR 0097) — does not add a third sweep. A list of path curve tags delegates to :meth:sweep (interior path cleanup). A wire tag delegates to :meth:_Transforms.sweep.

Parameters

profile : surface ref or list of curve tags Existing dim-2 face, or a closed curve-tag list (typically from :meth:add_polyline with closed=True) that is promoted to a plane surface. path : list of curve tags, or a wire tag Sweep trajectory. Pass a list of curve tags from :meth:add_polyline; a 2-tuple is a dimtag, not two curves. label : str, optional Label on the resulting volume. sync : bool Synchronise the OCC kernel after the sweep (default True).

Returns

Tag Volume tag of the void tool. Subtract it with :meth:_Boolean.cut or :meth:_Boolean.apply_voids.

Source code in src/apeGmsh/core/_model_geometry.py
def add_void_sweep(
    self,
    profile,
    path,
    *,
    label: str | None = None,
    sync : bool       = True,
) -> Tag:
    """
    Sweep a profile along a path and mark the solid as a void tool.

    Orchestration over the shipped pipe (ADR 0097) — does not add a
    third sweep.  A list of path curve tags delegates to
    :meth:`sweep` (interior path cleanup).  A wire tag delegates to
    :meth:`_Transforms.sweep`.

    Parameters
    ----------
    profile : surface ref or list of curve tags
        Existing dim-2 face, or a closed curve-tag list (typically
        from :meth:`add_polyline` with ``closed=True``) that is
        promoted to a plane surface.
    path : list of curve tags, or a wire tag
        Sweep trajectory.  Pass a *list* of curve tags from
        :meth:`add_polyline`; a 2-tuple is a dimtag, not two curves.
    label : str, optional
        Label on the resulting volume.
    sync : bool
        Synchronise the OCC kernel after the sweep (default True).

    Returns
    -------
    Tag
        Volume tag of the void tool.  Subtract it with
        :meth:`_Boolean.cut` or :meth:`_Boolean.apply_voids`.
    """
    from ._compose_errors import chain_phase_guard
    chain_phase_guard(
        self._model._parent, "g.model.geometry.add_void_sweep",
    )
    face = self._resolve_profile_face(profile, sync=False)
    if self._is_curve_sequence(path):
        path_curves = [int(t) for t in path]
        out = self.sweep(
            face, path_curves, label=label, cleanup=True, sync=sync,
        )
        volume = out.get("volume")
        if volume is None:
            raise RuntimeError(
                "add_void_sweep: sweep produced no volume. Check that "
                "the profile is a closed face roughly perpendicular "
                "to the start of the path."
            )
        vol_tag = int(volume)
        # sweep(cleanup=True) deletes the path curves; drop their
        # registry keys so validate_pre_mesh does not see them as stale.
        for t in path_curves:
            self._model._metadata.pop((1, t), None)
        if self._is_curve_sequence(profile):
            for t in profile:
                self._model._metadata.pop((1, int(t)), None)
    else:
        path_tag = self._resolve_entity_tag(path, dim=1, what="sweep path")
        result = self._model.transforms.sweep(
            face, path_tag, dim=2, label=label, sync=sync,
        )
        if not result:
            raise RuntimeError(
                "add_void_sweep: transforms.sweep returned no entities."
            )
        max_dim = max(d for d, _ in result)
        vols = [int(t) for d, t in result if int(d) == max_dim]
        if len(vols) != 1:
            raise RuntimeError(
                f"add_void_sweep: expected one dim-{max_dim} survivor, "
                f"got {vols}."
            )
        vol_tag = vols[0]
    dim = 3
    try:
        gmsh.model.getBoundingBox(3, vol_tag)
    except Exception:
        dim = 2
    self._mark_as_void(dim, vol_tag)
    self._model._log(
        f"add_void_sweep(profile={profile!r}, path={path!r}) -> {vol_tag}"
    )
    return vol_tag

add_void_loft

add_void_loft(sections: list, *, make_solid: bool = True, make_ruled: bool = False, label: str | None = None, sync: bool = True) -> Tag

Loft through two or more section polylines and mark the solid as a void tool.

Delegates to :meth:_Transforms.thru_sections (ADR 0097). When every section is a curve-tag list, sub-curve counts must match (fillet the same vertices on every section).

Parameters

sections : list of curve-tag lists or wire tags At least two. Curve-tag lists typically come from :meth:add_polyline with closed=True. make_solid, make_ruled Forwarded to :meth:_Transforms.thru_sections. label : str, optional Label on the highest-dimension survivor. sync : bool Synchronise the OCC kernel after the loft (default True).

Returns

Tag Volume (or surface, if make_solid=False) tag of the void tool.

Source code in src/apeGmsh/core/_model_geometry.py
def add_void_loft(
    self,
    sections: list,
    *,
    make_solid: bool = True,
    make_ruled: bool = False,
    label: str | None = None,
    sync : bool = True,
) -> Tag:
    """
    Loft through two or more section polylines and mark the solid
    as a void tool.

    Delegates to :meth:`_Transforms.thru_sections` (ADR 0097).
    When every section is a curve-tag list, sub-curve counts must
    match (fillet the same vertices on every section).

    Parameters
    ----------
    sections : list of curve-tag lists or wire tags
        At least two.  Curve-tag lists typically come from
        :meth:`add_polyline` with ``closed=True``.
    make_solid, make_ruled
        Forwarded to :meth:`_Transforms.thru_sections`.
    label : str, optional
        Label on the highest-dimension survivor.
    sync : bool
        Synchronise the OCC kernel after the loft (default True).

    Returns
    -------
    Tag
        Volume (or surface, if ``make_solid=False``) tag of the
        void tool.
    """
    from ._compose_errors import chain_phase_guard
    chain_phase_guard(
        self._model._parent, "g.model.geometry.add_void_loft",
    )
    if not isinstance(sections, (list, tuple)) or len(sections) < 2:
        raise ValueError(
            "add_void_loft requires at least two sections "
            f"(got {0 if not sections else len(sections)})."
        )
    wires: list[int] = []
    counts: list[int] = []
    for sec in sections:
        if self._is_curve_sequence(sec):
            counts.append(len(sec))
            wires.append(
                self.add_wire(list(sec), check_closed=True, sync=False)
            )
        else:
            wires.append(
                self._resolve_entity_tag(sec, dim=1, what="section wire")
            )
    if len(counts) >= 2 and len(set(counts)) > 1:
        raise ValueError(
            f"add_void_loft: section curve counts must match after "
            f"fillet (got {counts}). Apply the same vertex "
            f"fillet/chamfer map to every section."
        )
    result = self._model.transforms.thru_sections(
        wires,
        make_solid=make_solid,
        make_ruled=make_ruled,
        label=label,
        sync=sync,
    )
    if not result:
        raise RuntimeError(
            "add_void_loft: thru_sections returned no entities."
        )
    max_dim = max(int(d) for d, _ in result)
    tags = [int(t) for d, t in result if int(d) == max_dim]
    if len(tags) != 1:
        raise RuntimeError(
            f"add_void_loft: expected one dim-{max_dim} survivor, "
            f"got {tags}."
        )
    self._mark_as_void(max_dim, tags[0])
    self._model._log(
        f"add_void_loft(n_sections={len(sections)}) -> {tags[0]}"
    )
    return tags[0]

g.model.boolean

apeGmsh.core._model_boolean._Boolean

_Boolean(model: 'Model')

Boolean-operation sub-composite extracted from Model.

Source code in src/apeGmsh/core/_model_boolean.py
def __init__(self, model: "Model") -> None:
    self._model = model

fuse

fuse(objects: EntityRefs, tools: EntityRefs, *, dim: int = 3, remove_object: bool = True, remove_tool: bool = True, sync: bool = True, label: str | None = None, tolerance: float | None = None) -> list[Tag]

Boolean union (A ∪ B). Returns surviving volume tags.

When label= is supplied, the labels carried by the inputs are dropped from the result and the new label is attached instead. Without label=, all input labels survive on the merged volume.

tolerance optionally overrides Geometry.ToleranceBoolean for the duration of this op (restored after) — bump it (e.g. tolerance=1e-3 for mm-scale models) when near-coincident faces defeat OCC's default coincidence detection. None (default) leaves the current global tolerance unchanged.

Example

result = g.model.boolean.fuse(box, sphere, label='merged')

Source code in src/apeGmsh/core/_model_boolean.py
def fuse(
    self,
    objects : EntityRefs,
    tools   : EntityRefs,
    *,
    dim           : int  = 3,
    remove_object : bool = True,
    remove_tool   : bool = True,
    sync          : bool = True,
    label         : str | None = None,
    tolerance     : float | None = None,
) -> list[Tag]:
    """
    Boolean union (A \u222a B).  Returns surviving volume tags.

    When ``label=`` is supplied, the labels carried by the inputs
    are dropped from the result and the new ``label`` is attached
    instead.  Without ``label=``, all input labels survive on the
    merged volume.

    ``tolerance`` optionally overrides ``Geometry.ToleranceBoolean``
    for the duration of this op (restored after) \u2014 bump it (e.g.
    ``tolerance=1e-3`` for mm-scale models) when near-coincident
    faces defeat OCC's default coincidence detection. ``None``
    (default) leaves the current global tolerance unchanged.

    Example
    -------
    ``result = g.model.boolean.fuse(box, sphere, label='merged')``
    """
    return self._bool_op(
        'fuse', objects, tools, dim,
        remove_object, remove_tool, sync, label=label,
        tolerance=tolerance,
    )

cut

cut(objects: EntityRefs, tools: EntityRefs, *, dim: int = 3, remove_object: bool = True, remove_tool: bool = True, sync: bool = True, label: str | None = None, tolerance: float | None = None) -> list[Tag]

Boolean difference (A − B). Returns surviving volume tags.

When label= is supplied, the object's label is dropped from the result and the new label is attached instead.

tolerance optionally overrides Geometry.ToleranceBoolean for the duration of this op (see :meth:fuse); None (default) leaves the current global tolerance unchanged.

Example

result = g.model.boolean.cut(box, cylinder, label='holey')

Source code in src/apeGmsh/core/_model_boolean.py
def cut(
    self,
    objects : EntityRefs,
    tools   : EntityRefs,
    *,
    dim           : int  = 3,
    remove_object : bool = True,
    remove_tool   : bool = True,
    sync          : bool = True,
    label         : str | None = None,
    tolerance     : float | None = None,
) -> list[Tag]:
    """
    Boolean difference (A \u2212 B).  Returns surviving volume tags.

    When ``label=`` is supplied, the object's label is dropped
    from the result and the new ``label`` is attached instead.

    ``tolerance`` optionally overrides ``Geometry.ToleranceBoolean``
    for the duration of this op (see :meth:`fuse`); ``None``
    (default) leaves the current global tolerance unchanged.

    Example
    -------
    ``result = g.model.boolean.cut(box, cylinder, label='holey')``
    """
    return self._bool_op(
        'cut', objects, tools, dim,
        remove_object, remove_tool, sync, label=label,
        tolerance=tolerance,
    )

apply_voids

apply_voids(host: EntityRefs, *, remove_object: bool = True, remove_tool: bool = True, sync: bool = True, label: str | None = None, tolerance: float | None = None) -> list[Tag]

Subtract every unapplied as_void tool from host.

Collects live model._metadata entries with role="void" at the host's dimension and delegates to :meth:cut (ADR 0097). Tools at a different dimension than the host are ignored (a dim-2 opening does not cut a volume, and vice versa).

Parameters

host : entity ref(s) The body (or face) to cut. All host entities must share one dimension. remove_object, remove_tool, sync, label, tolerance Forwarded to :meth:cut. remove_tool=True (default) consumes the void bodies so they cannot be meshed as solids.

Returns

list[Tag] Surviving host tags after the cut.

Raises

ValueError Host is empty, mixed-dimension, or there are no void tools at the host dimension.

Source code in src/apeGmsh/core/_model_boolean.py
def apply_voids(
    self,
    host: EntityRefs,
    *,
    remove_object : bool = True,
    remove_tool   : bool = True,
    sync          : bool = True,
    label         : str | None = None,
    tolerance     : float | None = None,
) -> list[Tag]:
    """
    Subtract every unapplied ``as_void`` tool from *host*.

    Collects live ``model._metadata`` entries with ``role="void"``
    at the host's dimension and delegates to :meth:`cut` (ADR 0097).
    Tools at a different dimension than the host are ignored (a
    dim-2 opening does not cut a volume, and vice versa).

    Parameters
    ----------
    host : entity ref(s)
        The body (or face) to cut.  All host entities must share
        one dimension.
    remove_object, remove_tool, sync, label, tolerance
        Forwarded to :meth:`cut`.  ``remove_tool=True`` (default)
        consumes the void bodies so they cannot be meshed as
        solids.

    Returns
    -------
    list[Tag]
        Surviving host tags after the cut.

    Raises
    ------
    ValueError
        Host is empty, mixed-dimension, or there are no void tools
        at the host dimension.
    """
    parent = self._model._parent
    host_dt = resolve_to_dimtags(
        host, default_dim=3, session=parent,
    )
    if not host_dt:
        raise ValueError("apply_voids: host resolved to nothing.")
    dims = {int(d) for d, _ in host_dt}
    if len(dims) != 1:
        raise ValueError(
            f"apply_voids: host must be a single dimension, "
            f"got {sorted(dims)}."
        )
    dim = dims.pop()
    host_set = {(int(d), int(t)) for d, t in host_dt}
    tools = [
        (int(d), int(t))
        for (d, t), meta in self._model._metadata.items()
        if meta.get("role") == "void"
        and int(d) == dim
        and (int(d), int(t)) not in host_set
    ]
    if not tools:
        raise ValueError(
            f"apply_voids: no unapplied void tools at dim={dim}."
        )
    return self.cut(
        host_dt, tools, dim=dim,
        remove_object=remove_object, remove_tool=remove_tool,
        sync=sync, label=label, tolerance=tolerance,
    )

intersect

intersect(objects: EntityRefs, tools: EntityRefs, *, dim: int = 3, remove_object: bool = True, remove_tool: bool = True, sync: bool = True, label: str | None = None, tolerance: float | None = None) -> list[Tag]

Boolean intersection (A ∩ B). Returns surviving volume tags.

When label= is supplied, the input labels are dropped from the intersection and the new label is attached instead.

tolerance optionally overrides Geometry.ToleranceBoolean for the duration of this op (see :meth:fuse); None (default) leaves the current global tolerance unchanged.

Source code in src/apeGmsh/core/_model_boolean.py
def intersect(
    self,
    objects : EntityRefs,
    tools   : EntityRefs,
    *,
    dim           : int  = 3,
    remove_object : bool = True,
    remove_tool   : bool = True,
    sync          : bool = True,
    label         : str | None = None,
    tolerance     : float | None = None,
) -> list[Tag]:
    """Boolean intersection (A \u2229 B).  Returns surviving volume tags.

    When ``label=`` is supplied, the input labels are dropped from
    the intersection and the new ``label`` is attached instead.

    ``tolerance`` optionally overrides ``Geometry.ToleranceBoolean``
    for the duration of this op (see :meth:`fuse`); ``None``
    (default) leaves the current global tolerance unchanged.
    """
    return self._bool_op(
        'intersect', objects, tools, dim,
        remove_object, remove_tool, sync, label=label,
        tolerance=tolerance,
    )

fragment

fragment(objects: EntityRefs, tools: EntityRefs, *, dim: int = 3, remove_object: bool = True, remove_tool: bool = True, cleanup_free: bool = True, sync: bool = True, tolerance: float | None = None) -> list[Tag]

Boolean fragment — splits all shapes at their intersections and preserves all sub-volumes (useful for conformal meshing).

Parameters

objects : tag(s) of the entities to fragment. tools : tag(s) of the cutting entities (e.g. rectangles). Dimensions are auto-resolved from the registry, so bare integer tags work even when tools have a different dimension than dim. dim : target dimension for bare integer tags in objects (default 3). remove_object, remove_tool : passed to OCC (default True). cleanup_free : bool, default True When True, run :func:sweep_dangling after the fragment to reap free-floating dim<=2 entities that bound no surviving volume AND are not user-intentional (not in model._metadata, not carrying a label). The previous centroid-in-bbox heuristic over-collected shell-on-solid geometry whose centroid happened to fall outside a volume bbox; the topology-driven sweep preserves any standalone shell the user explicitly created (add_rectangle, add_plane_surface, etc.) because those entities live in _metadata. Default flipped to True once the safer sweep landed; pass cleanup_free=False only when you need OCC's raw output (no orphan removal, no stale- metadata reap) for downstream inspection. sync : synchronise the OCC kernel (default True). tolerance : float | None Optional override for Geometry.ToleranceBoolean during the fragment (see :meth:fuse) — raise it when shapes touch at near-coincident faces the default tolerance misses. None (default) leaves the current global tolerance unchanged.

Returns

list[Tag] Tags of all surviving entities at the target dimension.

Source code in src/apeGmsh/core/_model_boolean.py
def fragment(
    self,
    objects : EntityRefs,
    tools   : EntityRefs,
    *,
    dim           : int  = 3,
    remove_object : bool = True,
    remove_tool   : bool = True,
    cleanup_free  : bool = True,
    sync          : bool = True,
    tolerance     : float | None = None,
) -> list[Tag]:
    """
    Boolean fragment \u2014 splits all shapes at their intersections and
    preserves all sub-volumes (useful for conformal meshing).

    Parameters
    ----------
    objects : tag(s) of the entities to fragment.
    tools : tag(s) of the cutting entities (e.g. rectangles).
        Dimensions are auto-resolved from the registry, so bare
        integer tags work even when tools have a different dimension
        than *dim*.
    dim : target dimension for bare integer tags in *objects*
        (default 3).
    remove_object, remove_tool : passed to OCC (default True).
    cleanup_free : bool, default True
        When True, run :func:`sweep_dangling` after the fragment to
        reap free-floating dim<=2 entities that bound no surviving
        volume AND are not user-intentional (not in
        ``model._metadata``, not carrying a label).  The previous
        centroid-in-bbox heuristic over-collected shell-on-solid
        geometry whose centroid happened to fall outside a volume
        bbox; the topology-driven sweep preserves any standalone
        shell the user explicitly created (``add_rectangle``,
        ``add_plane_surface``, etc.) because those entities live
        in ``_metadata``.  Default flipped to ``True`` once the
        safer sweep landed; pass ``cleanup_free=False`` only when
        you need OCC's raw output (no orphan removal, no stale-
        metadata reap) for downstream inspection.
    sync : synchronise the OCC kernel (default True).
    tolerance : float | None
        Optional override for ``Geometry.ToleranceBoolean`` during
        the fragment (see :meth:`fuse`) — raise it when shapes touch
        at near-coincident faces the default tolerance misses.
        ``None`` (default) leaves the current global tolerance
        unchanged.

    Returns
    -------
    list[Tag]
        Tags of all surviving entities at the target dimension.
    """
    result = self._bool_op(
        'fragment', objects, tools, dim,
        remove_object, remove_tool, sync,
        tolerance=tolerance,
    )

    # In a 2D-only model every surface is "free" (there ARE no
    # volumes to bound), so the sweep would destroy every surface
    # the user created.  ``sweep_dangling`` protects metadata-
    # registered surfaces by definition, but skipping the sweep
    # in the 2D-only case is cheaper and avoids any debate about
    # what "orphan" means without volumes.
    if cleanup_free and gmsh.model.getEntities(3):
        sweep_dangling(self._model)

    return result

conformal

conformal(*, dims: list[int] | None = None, tolerance: float | None = None, sync: bool = True)

Fragment all entities against each other to produce a conformal model.

Convenience alias living next to :meth:fragment — delegates to :meth:Model.queries.make_conformal. Use this when you want to weld an entire imported/arc-built model into a single connected topology (the whole-model "fragment everything against everything" case), rather than fragmenting one explicit object/tool pair.

See :meth:_Queries.make_conformal for full parameter docs and the Part-instance renumbering caveat.

Source code in src/apeGmsh/core/_model_boolean.py
def conformal(
    self,
    *,
    dims     : list[int] | None = None,
    tolerance: float | None     = None,
    sync     : bool             = True,
):
    """
    Fragment all entities against each other to produce a conformal model.

    Convenience alias living next to :meth:`fragment` — delegates to
    :meth:`Model.queries.make_conformal`.  Use this when you want to weld
    an entire imported/arc-built model into a single connected topology
    (the whole-model "fragment everything against everything" case),
    rather than fragmenting one explicit object/tool pair.

    See :meth:`_Queries.make_conformal` for full parameter docs and the
    Part-instance renumbering caveat.
    """
    return self._model.queries.make_conformal(
        dims=dims, tolerance=tolerance, sync=sync,
    )

g.model.transforms

apeGmsh.core._model_transforms._Transforms

_Transforms(model: 'Model')

Transform and extrusion/revolution sub-composite extracted from Model.

Source code in src/apeGmsh/core/_model_transforms.py
def __init__(self, model: "Model") -> None:
    self._model = model

translate

translate(tags: EntityRefs, dx: float, dy: float, dz: float, *, dim: int = 3, sync: bool = True) -> '_Transforms'

Translate entities by (dx, dy, dz).

tags accepts int / label / PG name / (dim, tag) / list of any mix. dim is the fallback for bare ints.

Example

::

g.model.transforms.translate(box, 5, 0, 0)
g.model.transforms.translate("col.body", 0, 0, 5)
Source code in src/apeGmsh/core/_model_transforms.py
def translate(
    self,
    tags: EntityRefs,
    dx: float, dy: float, dz: float,
    *,
    dim : int  = 3,
    sync: bool = True,
) -> "_Transforms":
    """
    Translate entities by (dx, dy, dz).

    ``tags`` accepts int / label / PG name / ``(dim, tag)`` / list
    of any mix.  *dim* is the fallback for bare ints.

    Example
    -------
    ::

        g.model.transforms.translate(box, 5, 0, 0)
        g.model.transforms.translate("col.body", 0, 0, 5)
    """
    with pg_preserved_identity():
        gmsh.model.occ.translate(self._resolve_dt(tags, dim), dx, dy, dz)
        if sync:
            gmsh.model.occ.synchronize()
    self._model._log(f"translate by ({dx}, {dy}, {dz})")
    return self

rotate

rotate(tags: EntityRefs, angle: float, *, ax: float = 0.0, ay: float = 0.0, az: float = 1.0, cx: float = 0.0, cy: float = 0.0, cz: float = 0.0, dim: int = 3, sync: bool = True) -> '_Transforms'

Rotate entities around an axis through (cx, cy, cz) with direction (ax, ay, az) by angle radians.

tags accepts any flexible-ref form (see :meth:translate).

Example

g.model.transforms.rotate(box, math.pi / 4, az=1)

Source code in src/apeGmsh/core/_model_transforms.py
def rotate(
    self,
    tags  : EntityRefs,
    angle : float,
    *,
    ax: float = 0.0, ay: float = 0.0, az: float = 1.0,
    cx: float = 0.0, cy: float = 0.0, cz: float = 0.0,
    dim : int  = 3,
    sync: bool = True,
) -> "_Transforms":
    """
    Rotate entities around an axis through (cx, cy, cz) with direction
    (ax, ay, az) by ``angle`` radians.

    ``tags`` accepts any flexible-ref form (see :meth:`translate`).

    Example
    -------
    ``g.model.transforms.rotate(box, math.pi / 4, az=1)``
    """
    with pg_preserved_identity():
        gmsh.model.occ.rotate(
            self._resolve_dt(tags, dim),
            cx, cy, cz,
            ax, ay, az,
            angle,
        )
        if sync:
            gmsh.model.occ.synchronize()
    self._model._log(
        f"rotate {math.degrees(angle):.2f}\u00b0 about axis=({ax},{ay},{az}) "
        f"through ({cx},{cy},{cz})"
    )
    return self

scale

scale(tags: EntityRefs, sx: float, sy: float, sz: float, *, cx: float = 0.0, cy: float = 0.0, cz: float = 0.0, dim: int = 3, sync: bool = True) -> '_Transforms'

Scale (dilate) entities by (sx, sy, sz) from centre (cx, cy, cz).

tags accepts any flexible-ref form (see :meth:translate).

Example

g.model.transforms.scale(box, 2, 2, 2) # uniform double

Source code in src/apeGmsh/core/_model_transforms.py
def scale(
    self,
    tags: EntityRefs,
    sx: float, sy: float, sz: float,
    *,
    cx: float = 0.0, cy: float = 0.0, cz: float = 0.0,
    dim : int  = 3,
    sync: bool = True,
) -> "_Transforms":
    """
    Scale (dilate) entities by (sx, sy, sz) from centre (cx, cy, cz).

    ``tags`` accepts any flexible-ref form (see :meth:`translate`).

    Example
    -------
    ``g.model.transforms.scale(box, 2, 2, 2)``   # uniform double
    """
    with pg_preserved_identity():
        gmsh.model.occ.dilate(
            self._resolve_dt(tags, dim),
            cx, cy, cz,
            sx, sy, sz,
        )
        if sync:
            gmsh.model.occ.synchronize()
    self._model._log(f"scale ({sx},{sy},{sz}) about ({cx},{cy},{cz})")
    return self

mirror

mirror(tags: EntityRefs, a: float, b: float, c: float, d: float, *, dim: int = 3, sync: bool = True) -> '_Transforms'

Mirror entities through the plane ax + by + cz + d = 0.

tags accepts any flexible-ref form (see :meth:translate).

Example

g.model.transforms.mirror(box, 1, 0, 0, 0) # reflect through YZ plane

Source code in src/apeGmsh/core/_model_transforms.py
def mirror(
    self,
    tags: EntityRefs,
    a: float, b: float, c: float, d: float,
    *,
    dim : int  = 3,
    sync: bool = True,
) -> "_Transforms":
    """
    Mirror entities through the plane ax + by + cz + d = 0.

    ``tags`` accepts any flexible-ref form (see :meth:`translate`).

    Example
    -------
    ``g.model.transforms.mirror(box, 1, 0, 0, 0)``   # reflect through YZ plane
    """
    with pg_preserved_identity():
        gmsh.model.occ.mirror(self._resolve_dt(tags, dim), a, b, c, d)
        if sync:
            gmsh.model.occ.synchronize()
    self._model._log(f"mirror through plane {a}x + {b}y + {c}z + {d} = 0")
    return self

copy

copy(tags: EntityRefs, *, dim: int = 3, sync: bool = True) -> list[Tag]

Duplicate entities. Returns the tags of the new copies.

tags accepts any flexible-ref form (see :meth:translate).

Example

copies = g.model.transforms.copy([box, sphere])

Source code in src/apeGmsh/core/_model_transforms.py
def copy(
    self,
    tags: EntityRefs,
    *,
    dim : int  = 3,
    sync: bool = True,
) -> list[Tag]:
    """
    Duplicate entities.  Returns the tags of the new copies.

    ``tags`` accepts any flexible-ref form (see :meth:`translate`).

    Example
    -------
    ``copies = g.model.transforms.copy([box, sphere])``
    """
    with pg_preserved_identity():
        new_dimtags = gmsh.model.occ.copy(self._resolve_dt(tags, dim))
        if sync:
            gmsh.model.occ.synchronize()
    new_tags = [t for _, t in new_dimtags]
    self._model._log(f"copy -> new tags {new_tags}")
    return new_tags

extrude

extrude(tags: EntityRefs, dx: float, dy: float, dz: float, *, dim: int = 2, num_elements: list[int] | None = None, heights: list[float] | None = None, recombine: bool = False, sync: bool = True) -> list[DimTag]

Linear extrusion — sweeps entities along (dx, dy, dz).

Creates new geometry one dimension up: point -> curve, curve -> surface, surface -> volume.

Parameters

tags : entities to extrude. dx, dy, dz : extrusion vector. dim : default dimension for bare integer tags (default 2). num_elements : structured layer counts, e.g. [10] for 10 layers. Empty list (default) = unstructured. heights : relative heights per layer, e.g. [0.3, 0.7]. Must sum to 1.0 when provided. Empty = uniform layers. recombine : if True, produce hex/quad elements instead of tet/tri (requires structured layers). sync : synchronise OCC kernel after extrusion (default True).

Returns

list[DimTag] All generated (dim, tag) pairs. For a surface -> volume extrusion the list contains the top face, the volume, and the lateral faces — index into it to assign physical groups.

Example

::

surf = g.model.geometry.add_plane_surface(loop)
out  = g.model.transforms.extrude(surf, 0, 0, 3.0, num_elements=[10])
# out[0] = (2, top_face), out[1] = (3, volume), ...
Source code in src/apeGmsh/core/_model_transforms.py
def extrude(
    self,
    tags: EntityRefs,
    dx: float, dy: float, dz: float,
    *,
    dim          : int             = 2,
    num_elements : list[int] | None = None,
    heights      : list[float] | None = None,
    recombine    : bool            = False,
    sync         : bool            = True,
) -> list[DimTag]:
    """
    Linear extrusion \u2014 sweeps entities along (dx, dy, dz).

    Creates new geometry one dimension up: point -> curve,
    curve -> surface, surface -> volume.

    Parameters
    ----------
    tags : entities to extrude.
    dx, dy, dz : extrusion vector.
    dim : default dimension for bare integer tags (default 2).
    num_elements : structured layer counts, e.g. ``[10]`` for
        10 layers.  Empty list (default) = unstructured.
    heights : relative heights per layer, e.g. ``[0.3, 0.7]``.
        Must sum to 1.0 when provided.  Empty = uniform layers.
    recombine : if True, produce hex/quad elements instead of
        tet/tri (requires structured layers).
    sync : synchronise OCC kernel after extrusion (default True).

    Returns
    -------
    list[DimTag]
        All generated (dim, tag) pairs.  For a surface -> volume
        extrusion the list contains the top face, the volume, and
        the lateral faces \u2014 index into it to assign physical groups.

    Example
    -------
    ::

        surf = g.model.geometry.add_plane_surface(loop)
        out  = g.model.transforms.extrude(surf, 0, 0, 3.0, num_elements=[10])
        # out[0] = (2, top_face), out[1] = (3, volume), ...
    """
    dt = self._resolve_dt(tags, dim)
    ne = num_elements if num_elements is not None else []
    ht = heights if heights is not None else []
    with pg_preserved_identity():
        result: list[tuple[int, int]] = gmsh.model.occ.extrude(
            dt, dx, dy, dz,
            numElements=ne,
            heights=ht,
            recombine=recombine,
        )
        if sync:
            gmsh.model.occ.synchronize()
    for d, t in result:
        self._model._register(d, t, None, 'extrude')
    self._model._log(
        f"extrude({dt}, ({dx},{dy},{dz})) -> {len(result)} entities"
    )
    return result

revolve

revolve(tags: EntityRefs, angle: float, *, x: float = 0.0, y: float = 0.0, z: float = 0.0, ax: float = 0.0, ay: float = 0.0, az: float = 1.0, dim: int = 2, num_elements: list[int] | None = None, heights: list[float] | None = None, recombine: bool = False, sync: bool = True) -> list[DimTag]

Revolution — sweeps entities around an axis.

Parameters

tags : entities to revolve. angle : sweep angle in radians (2π for full revolution). x, y, z : point on the rotation axis. ax, ay, az : direction vector of the rotation axis. dim : default dimension for bare integer tags (default 2). num_elements, heights, recombine : same as :meth:extrude. sync : synchronise OCC kernel (default True).

Returns

list[DimTag] All generated (dim, tag) pairs.

Example

::

# Revolve a cross-section 360° around the Y axis
out = g.model.transforms.revolve(profile, 2 * math.pi, ay=1)
Source code in src/apeGmsh/core/_model_transforms.py
def revolve(
    self,
    tags  : EntityRefs,
    angle : float,
    *,
    x : float = 0.0, y : float = 0.0, z : float = 0.0,
    ax: float = 0.0, ay: float = 0.0, az: float = 1.0,
    dim          : int             = 2,
    num_elements : list[int] | None = None,
    heights      : list[float] | None = None,
    recombine    : bool            = False,
    sync         : bool            = True,
) -> list[DimTag]:
    """
    Revolution \u2014 sweeps entities around an axis.

    Parameters
    ----------
    tags : entities to revolve.
    angle : sweep angle in radians (2\u03c0 for full revolution).
    x, y, z : point on the rotation axis.
    ax, ay, az : direction vector of the rotation axis.
    dim : default dimension for bare integer tags (default 2).
    num_elements, heights, recombine : same as :meth:`extrude`.
    sync : synchronise OCC kernel (default True).

    Returns
    -------
    list[DimTag]
        All generated (dim, tag) pairs.

    Example
    -------
    ::

        # Revolve a cross-section 360\u00b0 around the Y axis
        out = g.model.transforms.revolve(profile, 2 * math.pi, ay=1)
    """
    dt = self._resolve_dt(tags, dim)
    ne = num_elements if num_elements is not None else []
    ht = heights if heights is not None else []
    with pg_preserved_identity():
        result: list[tuple[int, int]] = gmsh.model.occ.revolve(
            dt, x, y, z, ax, ay, az, angle,
            numElements=ne,
            heights=ht,
            recombine=recombine,
        )
        if sync:
            gmsh.model.occ.synchronize()
    for d, t in result:
        self._model._register(d, t, None, 'revolve')
    self._model._log(
        f"revolve({dt}, angle={math.degrees(angle):.1f}\u00b0, "
        f"axis=({ax},{ay},{az}) through ({x},{y},{z})) "
        f"-> {len(result)} entities"
    )
    return result

sweep

sweep(profiles: EntityRefs, path: Tag, *, dim: int = 2, trihedron: str = 'DiscreteTrihedron', label: str | None = None, sync: bool = True) -> list[DimTag]

Sweep one or more profile entities along an arbitrary wire.

This is the "constant-section sweep" operation: a single profile (point, curve, or surface) is translated along path, generating geometry one dimension up — point -> curve, curve -> surface, surface -> volume. Unlike :meth:extrude the path does not have to be a straight line: it can be any OCC wire built from lines, arcs, splines, or a mix, assembled via :meth:~Model.add_wire.

Parameters

profiles : entity or entities to sweep. For a solid you normally pass a plane surface. path : tag of an OCC wire to sweep along (use :meth:~Model.add_wire to build it). A curve_loop can be used for closed paths. dim : default dimension for bare integer tags in profiles (default 2). trihedron : how the profile frame is transported along the path. One of "DiscreteTrihedron" (default), "CorrectedFrenet", "Fixed", "Frenet", "ConstantNormal", "Darboux", "GuideAC", "GuidePlan", "GuideACWithContact", "GuidePlanWithContact". Most structural workflows want the default; use "Frenet" for smooth curves without inflection and "Fixed" to keep the profile's orientation constant in world space. label : optional label applied to the highest-dimension survivor of the sweep (the volume for a surface sweep). sync : synchronise the OCC kernel after the call (default True).

Returns

list[DimTag] All generated (dim, tag) pairs. Index into the list to grab the volume, the lateral faces, or the end caps and assign them to physical groups.

Example

::

section = g.model.geometry.add_plane_surface(loop, label="I_section")
path    = g.model.geometry.add_wire([arc1, line1, arc2])
out     = g.model.transforms.sweep(section, path, label="curved_beam")
Source code in src/apeGmsh/core/_model_transforms.py
def sweep(
    self,
    profiles : EntityRefs,
    path     : Tag,
    *,
    dim       : int        = 2,
    trihedron : str        = "DiscreteTrihedron",
    label     : str | None = None,
    sync      : bool       = True,
) -> list[DimTag]:
    """
    Sweep one or more profile entities along an arbitrary wire.

    This is the "constant-section sweep" operation: a single profile
    (point, curve, or surface) is translated along *path*, generating
    geometry one dimension up — point -> curve, curve -> surface,
    surface -> volume.  Unlike :meth:`extrude` the path does not have
    to be a straight line: it can be any OCC wire built from lines,
    arcs, splines, or a mix, assembled via
    :meth:`~Model.add_wire`.

    Parameters
    ----------
    profiles : entity or entities to sweep.  For a solid you
        normally pass a plane surface.
    path : tag of an OCC wire to sweep along (use
        :meth:`~Model.add_wire` to build it).  A curve_loop can be
        used for closed paths.
    dim : default dimension for bare integer tags in *profiles*
        (default 2).
    trihedron : how the profile frame is transported along the
        path.  One of ``"DiscreteTrihedron"`` (default),
        ``"CorrectedFrenet"``, ``"Fixed"``, ``"Frenet"``,
        ``"ConstantNormal"``, ``"Darboux"``, ``"GuideAC"``,
        ``"GuidePlan"``, ``"GuideACWithContact"``,
        ``"GuidePlanWithContact"``.  Most structural workflows
        want the default; use ``"Frenet"`` for smooth curves
        without inflection and ``"Fixed"`` to keep the profile's
        orientation constant in world space.
    label : optional label applied to the highest-dimension
        survivor of the sweep (the volume for a surface sweep).
    sync : synchronise the OCC kernel after the call (default
        True).

    Returns
    -------
    list[DimTag]
        All generated ``(dim, tag)`` pairs.  Index into the list
        to grab the volume, the lateral faces, or the end caps and
        assign them to physical groups.

    Example
    -------
    ::

        section = g.model.geometry.add_plane_surface(loop, label="I_section")
        path    = g.model.geometry.add_wire([arc1, line1, arc2])
        out     = g.model.transforms.sweep(section, path, label="curved_beam")
    """
    dt = self._resolve_dt(profiles, dim)
    with pg_preserved_identity():
        result: list[tuple[int, int]] = gmsh.model.occ.addPipe(
            dt, int(path), trihedron=trihedron,
        )
        if sync:
            gmsh.model.occ.synchronize()

    if result and label is not None:
        max_dim = max(d for d, _ in result)
        for d, t in result:
            lbl = label if d == max_dim else None
            self._model._register(d, t, lbl, 'sweep')
    else:
        for d, t in result:
            self._model._register(d, t, None, 'sweep')

    self._model._log(
        f"sweep({dt}, path={path}, trihedron={trihedron!r}) "
        f"-> {len(result)} entities"
    )
    return result

thru_sections

thru_sections(wires: list[Tag], *, make_solid: bool = True, make_ruled: bool = False, max_degree: int = -1, continuity: str = '', parametrization: str = '', smoothing: bool = False, label: str | None = None, sync: bool = True) -> list[DimTag]

Variable-section sweep — loft a volume (or surface shell) through an ordered list of wires.

This is the right operation when the cross-section changes along the sweep: a tapered column, a transition piece between two different flange shapes, a blended nozzle. Each wire defines one intermediate section; OCC builds a smooth surface that interpolates between them and (optionally) caps the ends to produce a solid.

All wires should be topologically similar (same number of sub-curves in the same order) for reliable lofting. Open wires produce a skin; closed wires with make_solid=True produce a solid.

Parameters

wires : ordered list of wire tags (build each one with :meth:~Model.add_wire). At least two wires are required. make_solid : if True (default), cap the ends and return a solid; if False, return only the skinned surface(s). make_ruled : if True, force the lateral faces to be ruled surfaces (linear interpolation between adjacent sections). max_degree : maximum degree of the resulting surface (-1 = OCC default). continuity : "C0", "G1", "C1", "G2", "C2", "C3", or "CN" ("" = OCC default). parametrization : "ChordLength", "Centripetal", or "IsoParametric" ("" = OCC default). smoothing : if True, apply a smoothing pass to the resulting surface. label : optional label applied to the highest-dimension survivor (the volume when make_solid=True). sync : synchronise the OCC kernel after the call (default True).

Returns

list[DimTag] All generated (dim, tag) pairs.

Example

::

w_base = g.model.geometry.add_wire([lb1, lb2, lb3, lb4])
w_top  = g.model.geometry.add_wire([lt1, lt2, lt3, lt4])
out    = g.model.transforms.thru_sections(
    [w_base, w_top],
    make_solid=True,
    label="tapered_column",
)
Source code in src/apeGmsh/core/_model_transforms.py
def thru_sections(
    self,
    wires : list[Tag],
    *,
    make_solid     : bool       = True,
    make_ruled     : bool       = False,
    max_degree     : int        = -1,
    continuity     : str        = "",
    parametrization: str        = "",
    smoothing      : bool       = False,
    label          : str | None = None,
    sync           : bool       = True,
) -> list[DimTag]:
    """
    Variable-section sweep — loft a volume (or surface shell)
    through an ordered list of wires.

    This is the right operation when the cross-section *changes*
    along the sweep: a tapered column, a transition piece between
    two different flange shapes, a blended nozzle.  Each wire
    defines one intermediate section; OCC builds a smooth surface
    that interpolates between them and (optionally) caps the ends
    to produce a solid.

    All wires should be topologically similar (same number of
    sub-curves in the same order) for reliable lofting.  Open wires
    produce a skin; closed wires with ``make_solid=True`` produce a
    solid.

    Parameters
    ----------
    wires : ordered list of wire tags (build each one with
        :meth:`~Model.add_wire`).  At least two wires are required.
    make_solid : if True (default), cap the ends and return a
        solid; if False, return only the skinned surface(s).
    make_ruled : if True, force the lateral faces to be ruled
        surfaces (linear interpolation between adjacent sections).
    max_degree : maximum degree of the resulting surface
        (``-1`` = OCC default).
    continuity : ``"C0"``, ``"G1"``, ``"C1"``, ``"G2"``, ``"C2"``,
        ``"C3"``, or ``"CN"`` (``""`` = OCC default).
    parametrization : ``"ChordLength"``, ``"Centripetal"``, or
        ``"IsoParametric"`` (``""`` = OCC default).
    smoothing : if True, apply a smoothing pass to the resulting
        surface.
    label : optional label applied to the highest-dimension
        survivor (the volume when ``make_solid=True``).
    sync : synchronise the OCC kernel after the call (default
        True).

    Returns
    -------
    list[DimTag]
        All generated ``(dim, tag)`` pairs.

    Example
    -------
    ::

        w_base = g.model.geometry.add_wire([lb1, lb2, lb3, lb4])
        w_top  = g.model.geometry.add_wire([lt1, lt2, lt3, lt4])
        out    = g.model.transforms.thru_sections(
            [w_base, w_top],
            make_solid=True,
            label="tapered_column",
        )
    """
    if len(wires) < 2:
        raise ValueError(
            "thru_sections requires at least two wires (got "
            f"{len(wires)})."
        )
    with pg_preserved_identity():
        result: list[tuple[int, int]] = gmsh.model.occ.addThruSections(
            list(wires),
            makeSolid      = make_solid,
            makeRuled      = make_ruled,
            maxDegree      = max_degree,
            continuity     = continuity,
            parametrization= parametrization,
            smoothing      = smoothing,
        )
        if sync:
            gmsh.model.occ.synchronize()

    if result and label is not None:
        max_dim = max(d for d, _ in result)
        for d, t in result:
            lbl = label if d == max_dim else None
            self._model._register(d, t, lbl, 'thru_sections')
    else:
        for d, t in result:
            self._model._register(d, t, None, 'thru_sections')

    self._model._log(
        f"thru_sections(wires={list(wires)}, solid={make_solid}) "
        f"-> {len(result)} entities"
    )
    return result

g.model.io

apeGmsh.core._model_io._IO

_IO(model: 'Model')

IO sub-composite — import/export IGES, STEP, DXF, MSH.

Source code in src/apeGmsh/core/_model_io.py
def __init__(self, model: "Model") -> None:
    self._model = model

load_iges

load_iges(file_path: Path | str, *, highest_dim_only: bool = True, sync: bool = True, heal: bool | float | str = False, dedupe: bool | float = False, fuse: bool = False, label: str | None = None) -> dict[int, list[Tag]]

Import an IGES file into the current model.

All imported entities are registered and their tags are returned so you can immediately use them in boolean ops or transforms.

Parameters

highest_dim_only : bool If True (default) only the highest-dimension entities are returned and registered (volumes for solids, surfaces for surface models). Set to False to capture every sub-entity (faces, edges, vertices) as well. heal : bool, float, or "auto" Run heal_shapes on the imported entities immediately after import. True and "auto" derive a scale-aware tolerance from the model bounding box (≈ 1e-6 · bbox_diagonal) — a fixed absolute tolerance is meaningless across unit systems. A float overrides it (e.g. heal=1e-3). False (default) imports raw and, if the result shows slivers, emits a non-mutating :class:WarnGeomImportHealth advisory (see :meth:diagnose). For non-tolerance knobs, call heal_shapes() directly. dedupe : bool or float Run g.model.queries.remove_duplicates after the import (and after heal, if enabled). True uses the current Gmsh tolerance; a float overrides it for the call. fuse : bool If True, union all imported top-dimension entities into a single survivor via g.model.boolean.fuse. No-op when the import yields fewer than two entities at the top dimension. Combined with highest_dim_only=False, the lower-dim sub-imports are discarded since the volume fuse invalidates them. label : str, optional Global label attached to all imported entities (or to the fused survivor, when fuse=True). Resolvable via g.labels.entities(name).

Returns

dict[int, list[Tag]] {dim: [tag, ...]} indexed by dimension.

Example

::

imported = g.model.io.load_iges("part.iges")
bodies   = imported[3]           # all imported volume tags
flange   = bodies[0]             # first imported volume

boss = g.model.geometry.add_cylinder(10, 10, 0,  0, 0, 5,  3)
result = g.model.boolean.fuse(flange, boss)
Source code in src/apeGmsh/core/_model_io.py
def load_iges(
    self,
    file_path       : Path | str,
    *,
    highest_dim_only: bool = True,
    sync            : bool = True,
    heal            : bool | float | str = False,
    dedupe          : bool | float = False,
    fuse            : bool = False,
    label           : str | None = None,
) -> dict[int, list[Tag]]:
    """
    Import an IGES file into the current model.

    All imported entities are registered and their tags are returned so
    you can immediately use them in boolean ops or transforms.

    Parameters
    ----------
    highest_dim_only : bool
        If True (default) only the highest-dimension entities are
        returned and registered (volumes for solids, surfaces for
        surface models).  Set to False to capture every sub-entity
        (faces, edges, vertices) as well.
    heal : bool, float, or "auto"
        Run ``heal_shapes`` on the imported entities immediately
        after import.  ``True`` and ``"auto"`` derive a
        **scale-aware** tolerance from the model bounding box
        (``≈ 1e-6 · bbox_diagonal``) — a fixed absolute tolerance is
        meaningless across unit systems.  A float overrides it
        (e.g. ``heal=1e-3``).  ``False`` (default) imports raw and,
        if the result shows slivers, emits a non-mutating
        :class:`WarnGeomImportHealth` advisory (see
        :meth:`diagnose`).  For non-tolerance knobs, call
        ``heal_shapes()`` directly.
    dedupe : bool or float
        Run ``g.model.queries.remove_duplicates`` after the import
        (and after heal, if enabled).  ``True`` uses the current
        Gmsh tolerance; a float overrides it for the call.
    fuse : bool
        If True, union all imported top-dimension entities into a
        single survivor via ``g.model.boolean.fuse``.  No-op when
        the import yields fewer than two entities at the top
        dimension.  Combined with ``highest_dim_only=False``, the
        lower-dim sub-imports are discarded since the volume fuse
        invalidates them.
    label : str, optional
        Global label attached to all imported entities (or to the
        fused survivor, when ``fuse=True``).  Resolvable via
        ``g.labels.entities(name)``.

    Returns
    -------
    dict[int, list[Tag]]
        ``{dim: [tag, ...]}`` indexed by dimension.

    Example
    -------
    ::

        imported = g.model.io.load_iges("part.iges")
        bodies   = imported[3]           # all imported volume tags
        flange   = bodies[0]             # first imported volume

        boss = g.model.geometry.add_cylinder(10, 10, 0,  0, 0, 5,  3)
        result = g.model.boolean.fuse(flange, boss)
    """
    return self._import_shapes(
        Path(file_path), 'iges', highest_dim_only, sync,
        heal=heal, dedupe=dedupe, fuse=fuse, label=label,
    )

load_step

load_step(file_path: Path | str, *, highest_dim_only: bool = True, sync: bool = True, heal: bool | float | str = False, dedupe: bool | float = False, fuse: bool = False, label: str | None = None) -> dict[int, list[Tag]]

Import a STEP file into the current model.

All imported entities are registered and their tags are returned so you can immediately use them in boolean ops or transforms.

Parameters

highest_dim_only : bool If True (default) only the highest-dimension entities are returned and registered. Set to False to include all sub-entities. heal : bool, float, or "auto" Run heal_shapes on the imported entities immediately after import. True and "auto" derive a scale-aware tolerance from the model bounding box (≈ 1e-6 · bbox_diagonal) — a fixed absolute tolerance is meaningless across unit systems. A float overrides it (e.g. heal=1e-3). False (default) imports raw and, if the result shows slivers, emits a non-mutating :class:WarnGeomImportHealth advisory (see :meth:diagnose). For non-tolerance knobs, call heal_shapes() directly. dedupe : bool or float Run g.model.queries.remove_duplicates after the import (and after heal, if enabled). True uses the current Gmsh tolerance; a float overrides it for the call. fuse : bool If True, union all imported top-dimension entities into a single survivor via g.model.boolean.fuse. No-op when the import yields fewer than two entities at the top dimension. Combined with highest_dim_only=False, the lower-dim sub-imports are discarded since the volume fuse invalidates them. label : str, optional Global label attached to all imported entities (or to the fused survivor, when fuse=True). Resolvable via g.labels.entities(name).

Returns

dict[int, list[Tag]] {dim: [tag, ...]} indexed by dimension.

Example

::

# one-shot: import an assembly, clean + fuse + label
imported = g.model.io.load_step(
    "assembly.step",
    heal=True, dedupe=True, fuse=True, label="frame",
)
body = imported[3][0]   # single fused volume
Source code in src/apeGmsh/core/_model_io.py
def load_step(
    self,
    file_path       : Path | str,
    *,
    highest_dim_only: bool = True,
    sync            : bool = True,
    heal            : bool | float | str = False,
    dedupe          : bool | float = False,
    fuse            : bool = False,
    label           : str | None = None,
) -> dict[int, list[Tag]]:
    """
    Import a STEP file into the current model.

    All imported entities are registered and their tags are returned so
    you can immediately use them in boolean ops or transforms.

    Parameters
    ----------
    highest_dim_only : bool
        If True (default) only the highest-dimension entities are
        returned and registered.  Set to False to include all
        sub-entities.
    heal : bool, float, or "auto"
        Run ``heal_shapes`` on the imported entities immediately
        after import.  ``True`` and ``"auto"`` derive a
        **scale-aware** tolerance from the model bounding box
        (``≈ 1e-6 · bbox_diagonal``) — a fixed absolute tolerance is
        meaningless across unit systems.  A float overrides it
        (e.g. ``heal=1e-3``).  ``False`` (default) imports raw and,
        if the result shows slivers, emits a non-mutating
        :class:`WarnGeomImportHealth` advisory (see
        :meth:`diagnose`).  For non-tolerance knobs, call
        ``heal_shapes()`` directly.
    dedupe : bool or float
        Run ``g.model.queries.remove_duplicates`` after the import
        (and after heal, if enabled).  ``True`` uses the current
        Gmsh tolerance; a float overrides it for the call.
    fuse : bool
        If True, union all imported top-dimension entities into a
        single survivor via ``g.model.boolean.fuse``.  No-op when
        the import yields fewer than two entities at the top
        dimension.  Combined with ``highest_dim_only=False``, the
        lower-dim sub-imports are discarded since the volume fuse
        invalidates them.
    label : str, optional
        Global label attached to all imported entities (or to the
        fused survivor, when ``fuse=True``).  Resolvable via
        ``g.labels.entities(name)``.

    Returns
    -------
    dict[int, list[Tag]]
        ``{dim: [tag, ...]}`` indexed by dimension.

    Example
    -------
    ::

        # one-shot: import an assembly, clean + fuse + label
        imported = g.model.io.load_step(
            "assembly.step",
            heal=True, dedupe=True, fuse=True, label="frame",
        )
        body = imported[3][0]   # single fused volume
    """
    return self._import_shapes(
        Path(file_path), 'step', highest_dim_only, sync,
        heal=heal, dedupe=dedupe, fuse=fuse, label=label,
    )

heal_shapes

heal_shapes(tags: TagsLike | None = None, *, dim: int = 3, tolerance: float = 1e-08, fix_degenerated: bool = True, fix_small_edges: bool = True, fix_small_faces: bool = True, sew_faces: bool = True, make_solids: bool = True, sync: bool = True) -> _IO

Heal topology issues in imported CAD geometry (STEP / IGES).

Wraps gmsh.model.occ.healShapes which fixes common issues such as degenerate edges, tiny faces, gaps between faces, and open shells that should be solids.

Parameters

tags : entities to heal (default: all entities in the model). dim : default dimension for bare integer tags. tolerance : healing tolerance (default 1e-8). fix_degenerated : fix degenerate edges/faces. fix_small_edges : remove edges smaller than tolerance. fix_small_faces : remove faces smaller than tolerance. sew_faces : reconnect open shells at shared edges. make_solids : close healed shells into solids. sync : synchronise OCC kernel (default True).

Returns

self — for method chaining.

Example

::

imported = g.model.io.load_step("legacy_part.step")
g.model.io.heal_shapes(tolerance=1e-3)
Source code in src/apeGmsh/core/_model_io.py
def heal_shapes(
    self,
    tags: TagsLike | None = None,
    *,
    dim             : int   = 3,
    tolerance       : float = 1e-8,
    fix_degenerated : bool  = True,
    fix_small_edges : bool  = True,
    fix_small_faces : bool  = True,
    sew_faces       : bool  = True,
    make_solids     : bool  = True,
    sync            : bool  = True,
) -> _IO:
    """
    Heal topology issues in imported CAD geometry (STEP / IGES).

    Wraps ``gmsh.model.occ.healShapes`` which fixes common issues
    such as degenerate edges, tiny faces, gaps between faces, and
    open shells that should be solids.

    Parameters
    ----------
    tags : entities to heal (default: all entities in the model).
    dim : default dimension for bare integer tags.
    tolerance : healing tolerance (default 1e-8).
    fix_degenerated : fix degenerate edges/faces.
    fix_small_edges : remove edges smaller than tolerance.
    fix_small_faces : remove faces smaller than tolerance.
    sew_faces : reconnect open shells at shared edges.
    make_solids : close healed shells into solids.
    sync : synchronise OCC kernel (default True).

    Returns
    -------
    self — for method chaining.

    Example
    -------
    ::

        imported = g.model.io.load_step("legacy_part.step")
        g.model.io.heal_shapes(tolerance=1e-3)
    """
    # Phase 3B.2d / ADR 0038 — chain-phase freeze.  ``heal_shapes``
    # mutates the OCC kernel even for entities already in the
    # broker, so it's gated unconditionally rather than relying on
    # ``_register`` (which only runs for new outputs).
    from ._compose_errors import chain_phase_guard
    chain_phase_guard(self._model._parent, "g.model.io.heal_shapes")
    if tags is not None:
        dt = self._model._as_dimtags(tags, dim)
    else:
        dt = []  # empty = heal everything

    out: list[tuple[int, int]] = gmsh.model.occ.healShapes(
        dimTags=dt,
        tolerance=tolerance,
        fixDegenerated=fix_degenerated,
        fixSmallEdges=fix_small_edges,
        fixSmallFaces=fix_small_faces,
        sewFaces=sew_faces,
        makeSolids=make_solids,
    )
    if sync:
        gmsh.model.occ.synchronize()

    for d, t in out:
        if (d, t) not in self._model._metadata:
            self._model._register(d, t, None, 'healed')
    self._model._log(
        f"heal_shapes(tol={tolerance}) -> {len(out)} entities output"
    )
    return self

diagnose

diagnose(*, warn: bool = False) -> ImportHealth

Report CAD health of the current model without mutating it.

Scans the live OCC geometry and returns an :class:ImportHealth with per-dimension entity counts, sliver tallies (edges / faces far below the model scale), the bbox diagonal, and a suggested heal= tolerance. Nothing is healed, deduped, or renumbered — this is the look-before-you-leap counterpart to :meth:heal_shapes (which does mutate and renumber).

Parameters

warn : bool, default False When True, emit a :class:WarnGeomImportHealth advisory if the report :attr:~ImportHealth.is_suspect (slivers present). load_step / load_iges use this internally on a raw (un-healed) import.

Returns

ImportHealth

Example

::

g.model.io.load_step("messy.step")        # raw
report = g.model.io.diagnose()
if report.is_suspect:
    g.model.io.load_step("messy.step", heal="auto", dedupe=True)
Source code in src/apeGmsh/core/_model_io.py
def diagnose(self, *, warn: bool = False) -> ImportHealth:
    """Report CAD health of the current model **without mutating it**.

    Scans the live OCC geometry and returns an :class:`ImportHealth`
    with per-dimension entity counts, sliver tallies (edges / faces
    far below the model scale), the bbox diagonal, and a suggested
    ``heal=`` tolerance.  Nothing is healed, deduped, or
    renumbered — this is the look-before-you-leap counterpart to
    :meth:`heal_shapes` (which *does* mutate and renumber).

    Parameters
    ----------
    warn : bool, default False
        When True, emit a :class:`WarnGeomImportHealth` advisory if
        the report :attr:`~ImportHealth.is_suspect` (slivers
        present).  ``load_step`` / ``load_iges`` use this internally
        on a raw (un-healed) import.

    Returns
    -------
    ImportHealth

    Example
    -------
    ::

        g.model.io.load_step("messy.step")        # raw
        report = g.model.io.diagnose()
        if report.is_suspect:
            g.model.io.load_step("messy.step", heal="auto", dedupe=True)
    """
    health = _compute_health()
    if warn and health.is_suspect:
        warnings.warn(WarnGeomImportHealth(health.advisory()), stacklevel=2)
    return health

save_iges

save_iges(file_path: Path | str) -> None

Export the current model to IGES.

The .iges extension is appended automatically if omitted.

Source code in src/apeGmsh/core/_model_io.py
def save_iges(self, file_path: Path | str) -> None:
    """
    Export the current model to IGES.

    The ``.iges`` extension is appended automatically if omitted.
    """
    # Guarded before the write: on a kernel-less session gmsh.write
    # does not fail, it emits whatever empty model it holds — a
    # plausible-looking CAD file with nothing in it.
    self._require_kernel("g.model.io.save_iges()")
    file_path = Path(file_path).with_suffix('.iges')
    gmsh.write(str(file_path))
    self._model._log(f"saved IGES -> {file_path}")

save_step

save_step(file_path: Path | str) -> None

Export the current model to STEP.

The .step extension is appended automatically if omitted.

Source code in src/apeGmsh/core/_model_io.py
def save_step(self, file_path: Path | str) -> None:
    """
    Export the current model to STEP.

    The ``.step`` extension is appended automatically if omitted.
    """
    self._require_kernel("g.model.io.save_step()")
    file_path = Path(file_path).with_suffix('.step')
    gmsh.write(str(file_path))
    self._model._log(f"saved STEP -> {file_path}")

load_dxf

load_dxf(file_path: Path | str, *, point_tolerance: float = 1e-06, create_physical_groups: bool = True, sync: bool = True) -> dict[str, dict[int, list[Tag]]]

Import a DXF file into the current model.

Uses ezdxf to parse the DXF (supports all AutoCAD versions from R12 to 2024+), then builds Gmsh geometry through the OCC kernel. AutoCAD layers become Gmsh physical groups automatically.

Supported DXF entity types: LINE, ARC, CIRCLE, LWPOLYLINE, POLYLINE, SPLINE, POINT.

Parameters

file_path : Path or str Path to the .dxf file. point_tolerance : float Distance below which two DXF endpoints are considered coincident and share a single Gmsh point. Default 1e-6. create_physical_groups : bool If True (default), a physical group is created for each DXF layer. If False, entities are created but no physical groups are made (useful when you want to assign groups manually). sync : bool Synchronise the OCC kernel after import (default True).

Returns

dict[str, dict[int, list[Tag]]] {layer_name: {dim: [tag, ...]}}

Each key is a DXF layer name.  Values map entity dimension
to lists of Gmsh tags created from that layer.
Example

::

# AutoCAD drawing with layers: "C80x80", "V30x50"
layers = g.model.io.load_dxf("frame_2D.dxf")

# layers == {
#     "C80x80": {1: [1, 2, 3, 4]},
#     "V30x50": {1: [5, 6, 7, 8, 9]},
# }

# Physical groups are already created — ready for meshing.
# Access beam curves:
beam_curves = layers["V30x50"][1]
Source code in src/apeGmsh/core/_model_io.py
def load_dxf(
    self,
    file_path: Path | str,
    *,
    point_tolerance: float = 1e-6,
    create_physical_groups: bool = True,
    sync: bool = True,
) -> dict[str, dict[int, list[Tag]]]:
    """
    Import a DXF file into the current model.

    Uses ``ezdxf`` to parse the DXF (supports all AutoCAD versions
    from R12 to 2024+), then builds Gmsh geometry through the OCC
    kernel.  AutoCAD **layers** become Gmsh physical groups
    automatically.

    Supported DXF entity types: ``LINE``, ``ARC``, ``CIRCLE``,
    ``LWPOLYLINE``, ``POLYLINE``, ``SPLINE``, ``POINT``.

    Parameters
    ----------
    file_path : Path or str
        Path to the ``.dxf`` file.
    point_tolerance : float
        Distance below which two DXF endpoints are considered
        coincident and share a single Gmsh point.  Default ``1e-6``.
    create_physical_groups : bool
        If True (default), a physical group is created for each DXF
        layer.  If False, entities are created but no physical groups
        are made (useful when you want to assign groups manually).
    sync : bool
        Synchronise the OCC kernel after import (default True).

    Returns
    -------
    dict[str, dict[int, list[Tag]]]
        ``{layer_name: {dim: [tag, ...]}}``

        Each key is a DXF layer name.  Values map entity dimension
        to lists of Gmsh tags created from that layer.

    Example
    -------
    ::

        # AutoCAD drawing with layers: "C80x80", "V30x50"
        layers = g.model.io.load_dxf("frame_2D.dxf")

        # layers == {
        #     "C80x80": {1: [1, 2, 3, 4]},
        #     "V30x50": {1: [5, 6, 7, 8, 9]},
        # }

        # Physical groups are already created — ready for meshing.
        # Access beam curves:
        beam_curves = layers["V30x50"][1]
    """
    importer = _DXFImporter(self._model, point_tolerance)
    return importer.run(Path(file_path), create_physical_groups, sync)

save_dxf

save_dxf(file_path: Path | str) -> None

Export the current model to DXF.

The .dxf extension is appended automatically if omitted.

Source code in src/apeGmsh/core/_model_io.py
def save_dxf(self, file_path: Path | str) -> None:
    """
    Export the current model to DXF.

    The ``.dxf`` extension is appended automatically if omitted.
    """
    self._require_kernel("g.model.io.save_dxf()")
    file_path = Path(file_path).with_suffix('.dxf')
    gmsh.write(str(file_path))
    self._model._log(f"saved DXF -> {file_path}")

save_msh

save_msh(file_path: Path | str) -> None

Export the current model to Gmsh's native MSH format.

Unlike STEP/IGES, this preserves everything: geometry, mesh, physical groups, and partition data.

The .msh extension is appended automatically if omitted.

Source code in src/apeGmsh/core/_model_io.py
def save_msh(self, file_path: Path | str) -> None:
    """
    Export the current model to Gmsh's native MSH format.

    Unlike STEP/IGES, this preserves **everything**: geometry, mesh,
    physical groups, and partition data.

    The ``.msh`` extension is appended automatically if omitted.
    """
    self._require_kernel("g.model.io.save_msh()")
    file_path = Path(file_path).with_suffix('.msh')
    gmsh.option.setNumber("Mesh.SaveAll", 1)
    gmsh.write(str(file_path))
    self._model._log(f"saved MSH -> {file_path}")

load_msh

load_msh(file_path: Path | str) -> dict[int, list[Tag]]

Import a Gmsh .msh file using gmsh.merge.

Unlike load_iges / load_step, this preserves physical groups, mesh data, and partition info — because .msh is Gmsh's native format.

Parameters

file_path : Path or str Path to the .msh file.

Returns

dict[int, list[Tag]] {dim: [tag, ...]} of all entities after merge.

Source code in src/apeGmsh/core/_model_io.py
def load_msh(
    self,
    file_path: Path | str,
) -> dict[int, list[Tag]]:
    """
    Import a Gmsh ``.msh`` file using ``gmsh.merge``.

    Unlike ``load_iges`` / ``load_step``, this preserves physical
    groups, mesh data, and partition info — because ``.msh`` is
    Gmsh's native format.

    Parameters
    ----------
    file_path : Path or str
        Path to the ``.msh`` file.

    Returns
    -------
    dict[int, list[Tag]]
        ``{dim: [tag, ...]}`` of all entities after merge.
    """
    # Phase 3B.2d / ADR 0038 — chain-phase freeze.
    from ._compose_errors import chain_phase_guard
    chain_phase_guard(self._model._parent, "g.model.io.load_msh")
    file_path = Path(file_path)
    if not file_path.exists():
        raise FileNotFoundError(f"MSH file not found: {file_path}")

    gmsh.merge(str(file_path))

    result: dict[int, list[Tag]] = {}
    for d in range(4):
        for dim, tag in gmsh.model.getEntities(d):
            result.setdefault(dim, []).append(tag)

    dim_summary = {d: len(ts) for d, ts in result.items()}
    self._model._log(f"loaded MSH <- {file_path.name}  {dim_summary}")
    return result

load_geo

load_geo(file_path: Path | str) -> dict[int, list[Tag]]

Import a Gmsh .geo script using gmsh.merge.

The script is executed in the active model, so any Mesh N; statements inside the file will run. The CAD kernel used for synchronization is auto-detected by scanning the file head for SetFactory("OpenCASCADE"):

  • found -> gmsh.model.occ.synchronize()
  • absent -> gmsh.model.geo.synchronize()
Parameters

file_path : Path or str Path to the .geo file.

Returns

dict[int, list[Tag]] {dim: [tag, ...]} of all entities after merge.

Source code in src/apeGmsh/core/_model_io.py
def load_geo(
    self,
    file_path: Path | str,
) -> dict[int, list[Tag]]:
    """
    Import a Gmsh ``.geo`` script using ``gmsh.merge``.

    The script is executed in the active model, so any ``Mesh N;``
    statements inside the file will run.  The CAD kernel used for
    synchronization is auto-detected by scanning the file head for
    ``SetFactory("OpenCASCADE")``:

    - found  -> ``gmsh.model.occ.synchronize()``
    - absent -> ``gmsh.model.geo.synchronize()``

    Parameters
    ----------
    file_path : Path or str
        Path to the ``.geo`` file.

    Returns
    -------
    dict[int, list[Tag]]
        ``{dim: [tag, ...]}`` of all entities after merge.
    """
    # Phase 3B.2d / ADR 0038 — chain-phase freeze.
    from ._compose_errors import chain_phase_guard
    chain_phase_guard(self._model._parent, "g.model.io.load_geo")
    file_path = Path(file_path)
    if not file_path.exists():
        raise FileNotFoundError(f"GEO file not found: {file_path}")

    head = file_path.read_text(encoding="utf-8", errors="ignore")[:4096]
    use_occ = "SetFactory(\"OpenCASCADE\")" in head

    gmsh.merge(str(file_path))
    if use_occ:
        gmsh.model.occ.synchronize()
        kernel = "occ"
    else:
        gmsh.model.geo.synchronize()
        kernel = "geo"

    result: dict[int, list[Tag]] = {}
    for d in range(4):
        for dim, tag in gmsh.model.getEntities(d):
            result.setdefault(dim, []).append(tag)

    dim_summary = {d: len(ts) for d, ts in result.items()}
    self._model._log(
        f"loaded GEO <- {file_path.name} [{kernel}]  {dim_summary}"
    )
    return result

g.model.queries

apeGmsh.core._model_queries._Queries

_Queries(model: 'Model')

Queries sub-composite — remove, topology queries, and registry.

Source code in src/apeGmsh/core/_model_queries.py
def __init__(self, model: "Model") -> None:
    self._model = model

remove

remove(tags: TagsLike, *, dim: int = 3, recursive: bool = False, sync: bool = True) -> None

Delete entities from the model.

Parameters

recursive : bool If True, also delete all lower-dimensional entities that are exclusively owned by these entities.

Source code in src/apeGmsh/core/_model_queries.py
def remove(
    self,
    tags     : TagsLike,
    *,
    dim      : int  = 3,
    recursive: bool = False,
    sync     : bool = True,
) -> None:
    """
    Delete entities from the model.

    Parameters
    ----------
    recursive : bool
        If True, also delete all lower-dimensional entities that are
        exclusively owned by these entities.
    """
    # A geometry mutation despite living on the queries composite:
    # it deletes entities and drops their Model._metadata rows, so
    # post-extraction it would diverge the broker from gmsh exactly
    # as g.model.<geometry> would.
    self._require_unfrozen(f"g.model.queries.remove({tags!r})")
    dim_tags = self._model._as_dimtags(tags, dim)
    gmsh.model.occ.remove(dim_tags, recursive=recursive)
    if sync:
        gmsh.model.occ.synchronize()
    for dt in dim_tags:
        self._model._metadata.pop(dt, None)
    cleanup_label_pgs(dim_tags)
    self._model._log(f"removed {dim_tags} (recursive={recursive})")

remove_duplicates

remove_duplicates(*, tolerance: float | None = None, sync: bool = True) -> _Queries

Merge all coincident OCC entities in the current model.

Calls gmsh.model.occ.removeAllDuplicates(), which walks every dimension (points -> curves -> surfaces -> volumes) and collapses entities that are geometrically identical within the OCC tolerance. The internal registry is then reconciled so only entities that survive the merge are tracked.

This is the recommended post-processing step after importing IGES or STEP files, which routinely produce coincident points and overlapping curves at shared frame joints.

Parameters

tolerance : float | None Geometric merge tolerance. When provided, temporarily overrides Geometry.Tolerance and Geometry.ToleranceBoolean for the duration of this call, then restores the previous values. Use this when the IGES exporter introduced small coordinate imprecisions (e.g. tolerance=1e-3 for mm-scale models). When None (default), the current Gmsh tolerance is used unchanged. sync : bool Synchronise the OCC kernel after merging (default True). Set to False only if you intend to call model.sync() manually as part of a larger batch operation.

Returns

self — for method chaining

Example

::

imported = g.model.io.load_iges("Frame3D.iges", highest_dim_only=False)
g.model.queries.remove_duplicates(tolerance=1e-3)
g.plot.geometry(label_tags=True)
Source code in src/apeGmsh/core/_model_queries.py
def remove_duplicates(
    self,
    *,
    tolerance: float | None = None,
    sync     : bool         = True,
) -> _Queries:
    """
    Merge all coincident OCC entities in the current model.

    Calls ``gmsh.model.occ.removeAllDuplicates()``, which walks every
    dimension (points -> curves -> surfaces -> volumes) and collapses
    entities that are geometrically identical within the OCC tolerance.
    The internal registry is then reconciled so only entities that
    survive the merge are tracked.

    This is the recommended post-processing step after importing IGES
    or STEP files, which routinely produce coincident points and
    overlapping curves at shared frame joints.

    Parameters
    ----------
    tolerance : float | None
        Geometric merge tolerance.  When provided, temporarily overrides
        ``Geometry.Tolerance`` and ``Geometry.ToleranceBoolean`` for the
        duration of this call, then restores the previous values.
        Use this when the IGES exporter introduced small coordinate
        imprecisions (e.g. ``tolerance=1e-3`` for mm-scale models).
        When None (default), the current Gmsh tolerance is used unchanged.
    sync : bool
        Synchronise the OCC kernel after merging (default True).
        Set to False only if you intend to call ``model.sync()``
        manually as part of a larger batch operation.

    Returns
    -------
    self — for method chaining

    Example
    -------
    ::

        imported = g.model.io.load_iges("Frame3D.iges", highest_dim_only=False)
        g.model.queries.remove_duplicates(tolerance=1e-3)
        g.plot.geometry(label_tags=True)
    """
    self._require_unfrozen("g.model.queries.remove_duplicates()")
    before = {d: len(gmsh.model.getEntities(d)) for d in range(4)}

    with _temporary_tolerance(tolerance):
        gmsh.model.occ.removeAllDuplicates()

    if sync:
        gmsh.model.occ.synchronize()

    # Reconcile metadata — drop any (dim, tag) pairs that no longer
    # exist in the gmsh model after the merge.
    surviving: set[tuple[int, int]] = {
        (dim, tag)
        for dim in range(4)
        for _, tag in gmsh.model.getEntities(dim)
    }
    stale_dts = [dt for dt in self._model._metadata if dt not in surviving]
    for dt in stale_dts:
        del self._model._metadata[dt]

    # Reconcile label PGs — removeAllDuplicates doesn't provide a
    # result_map, so we walk all label PGs and drop dead entity tags.
    reconcile_label_pgs()

    after = {d: len(gmsh.model.getEntities(d)) for d in range(4)}
    removed = {d: before[d] - after[d] for d in range(4) if before[d] != after[d]}
    tol_str = f"tolerance={tolerance}" if tolerance is not None else ""
    self._model._log(
        f"remove_duplicates({tol_str}): merged {removed} entities "
        f"(before={before}, after={after})"
    )
    return self

make_conformal

make_conformal(*, dims: list[int] | None = None, tolerance: float | None = None, sync: bool = True) -> _Queries

Fragment all entities against each other to produce a conformal model.

Canonical fix for two flavours of disjoint topology:

  • IGES/STEP imports — CAD exporters routinely produce coincident vertices on separate BRep objects (column endpoints, beam endpoints) that share an XYZ but have no shared OCC point.
  • Arc-built wires (add_arc / add_circle(angle1,angle2) / add_ellipse(angle1,angle2)) — OCC creates fresh endpoint points on each partial-arc curve rather than welding to existing point tags. A wire built as arc + line + line is then topologically three disjoint pieces; the mesh produces two nodes at every arc-line junction with no moment continuity. make_conformal(dims=[1]) welds them at the geometry layer.

A conformal model is required for FEM meshing because elements must share nodes at junctions rather than having two independent nodes at the same location.

This method calls gmsh.model.occ.fragment() with all entities of the requested dimensions as both objects and tools. OCC computes all intersections, splits curves at shared points, and merges coincident vertices — leaving a single connected topology.

Parameters

dims : list[int] | None Dimensions to fragment. Defaults to all non-empty dimensions present in the model (typically [1] for wireframe frames, [1, 2] for mixed models). Pass [1] explicitly to restrict to curves only and avoid fragmenting surfaces. tolerance : float | None Geometric tolerance for OCC's intersection / coincidence detection. Temporarily overrides Geometry.ToleranceBoolean for the duration of the fragment call, then restores the original value. Use this when curves only touch at endpoints (no proper crossing) and the default OCC tolerance is too tight to detect them — e.g. tolerance=1.0 for mm-scale models. When None (default), the current Gmsh tolerance is used unchanged. sync : bool Synchronise the OCC kernel after fragmenting (default True).

Returns

self — for method chaining

Warnings

make_conformal() renumbers OCC entities. Any :class:Part instances already constructed against the pre-fragment model hold stale entity-tag dicts in their Instance.entities attribute, and a best-effort remap runs only for parts registered on the session at call time. For correctness:

  • Preferred: call make_conformal() before constructing Part / Assembly instances.
  • Otherwise: rebuild any Part instances after fragmenting.

Same caveat for hand-stored (dim, tag) references in user code — fragment refreshes tags, so cached lookups will dangle.

Example

::

m1.model.io.load_iges("Frame3D.iges", highest_dim_only=False)
m1.remove_duplicates(tolerance=1.0)
m1.model.queries.make_conformal(dims=[1], tolerance=1.0)
m1.plot.geometry(label_tags=True)
Source code in src/apeGmsh/core/_model_queries.py
def make_conformal(
    self,
    *,
    dims     : list[int] | None = None,
    tolerance: float | None     = None,
    sync     : bool             = True,
) -> _Queries:
    """
    Fragment all entities against each other to produce a conformal model.

    Canonical fix for two flavours of disjoint topology:

    * **IGES/STEP imports** — CAD exporters routinely produce coincident
      vertices on separate BRep objects (column endpoints, beam endpoints)
      that share an XYZ but have no shared OCC point.
    * **Arc-built wires (``add_arc`` / ``add_circle(angle1,angle2)`` /
      ``add_ellipse(angle1,angle2)``)** — OCC creates fresh endpoint
      points on each partial-arc curve rather than welding to existing
      point tags. A wire built as `arc + line + line` is then topologically
      three disjoint pieces; the mesh produces two nodes at every
      arc-line junction with no moment continuity. ``make_conformal(dims=[1])``
      welds them at the geometry layer.

    A conformal model is required for FEM meshing because elements must
    share nodes at junctions rather than having two independent nodes
    at the same location.

    This method calls ``gmsh.model.occ.fragment()`` with all entities of
    the requested dimensions as both objects and tools.  OCC computes all
    intersections, splits curves at shared points, and merges coincident
    vertices — leaving a single connected topology.

    Parameters
    ----------
    dims : list[int] | None
        Dimensions to fragment.  Defaults to all non-empty dimensions
        present in the model (typically ``[1]`` for wireframe frames,
        ``[1, 2]`` for mixed models).  Pass ``[1]`` explicitly to
        restrict to curves only and avoid fragmenting surfaces.
    tolerance : float | None
        Geometric tolerance for OCC's intersection / coincidence detection.
        Temporarily overrides ``Geometry.ToleranceBoolean`` for the duration
        of the fragment call, then restores the original value.
        Use this when curves only touch at endpoints (no proper crossing)
        and the default OCC tolerance is too tight to detect them —
        e.g. ``tolerance=1.0`` for mm-scale models.
        When None (default), the current Gmsh tolerance is used unchanged.
    sync : bool
        Synchronise the OCC kernel after fragmenting (default True).

    Returns
    -------
    self — for method chaining

    Warnings
    --------
    ``make_conformal()`` **renumbers OCC entities**. Any
    :class:`Part` instances already constructed against the pre-fragment
    model hold stale entity-tag dicts in their ``Instance.entities``
    attribute, and a best-effort remap runs only for parts registered
    on the session at call time. For correctness:

    * **Preferred:** call ``make_conformal()`` *before* constructing
      ``Part`` / ``Assembly`` instances.
    * **Otherwise:** rebuild any ``Part`` instances after fragmenting.

    Same caveat for hand-stored ``(dim, tag)`` references in user code —
    fragment refreshes tags, so cached lookups will dangle.

    Example
    -------
    ::

        m1.model.io.load_iges("Frame3D.iges", highest_dim_only=False)
        m1.remove_duplicates(tolerance=1.0)
        m1.model.queries.make_conformal(dims=[1], tolerance=1.0)
        m1.plot.geometry(label_tags=True)
    """
    self._require_unfrozen("g.model.queries.make_conformal()")
    before = {d: len(gmsh.model.getEntities(d)) for d in range(4)}

    if dims is None:
        dims = [d for d in range(4) if gmsh.model.getEntities(d)]

    all_dimtags: list[tuple[int, int]] = [
        (d, tag)
        for d in dims
        for _, tag in gmsh.model.getEntities(d)
    ]

    if not all_dimtags:
        self._model._log("make_conformal(): no entities found, nothing to do")
        return self

    with pg_preserved() as pg, \
         _temporary_tolerance(tolerance, keys=("Geometry.ToleranceBoolean",)):
        _, result_map = gmsh.model.occ.fragment(
            all_dimtags, [], removeObject=True, removeTool=True,
        )
        if sync:
            gmsh.model.occ.synchronize()
        pg.set_result(all_dimtags, result_map)

    # Rebuild metadata from scratch — fragment renumbers entities.
    # Only kind/normal/point metadata is preserved; labels live
    # in g.labels (Gmsh PGs) and are remapped by remap_physical_groups.
    old_metadata = dict(self._model._metadata)
    self._model._metadata.clear()
    for d in range(4):
        for _, tag in gmsh.model.getEntities(d):
            old_entry = old_metadata.get((d, tag))
            if old_entry:
                self._model._metadata[(d, tag)] = old_entry
            else:
                self._model._metadata[(d, tag)] = {'kind': 'fragment'}

    # Remap Instance.entities if the session has a parts registry.
    # Without this, instances track stale tags after make_conformal.
    parts = getattr(self._model._parent, 'parts', None)
    if parts is not None:
        # Build (dim, old_tag) → [new_tags at same dim]
        dt_remap: dict[tuple[int, int], list[int]] = {}
        for old_dt, new_dts in zip(all_dimtags, result_map):
            od, ot = int(old_dt[0]), int(old_dt[1])
            dt_remap[(od, ot)] = [int(t) for d, t in new_dts if int(d) == od]

        for inst in parts._instances.values():
            for d in list(inst.entities.keys()):
                old_tags = inst.entities.get(d, [])
                new_tags: list[int] = []
                for ot in old_tags:
                    mapped = dt_remap.get((d, ot))
                    if mapped is not None:
                        new_tags.extend(mapped)
                    else:
                        new_tags.append(ot)
                inst.entities[d] = new_tags

    after = {d: len(gmsh.model.getEntities(d)) for d in range(4)}
    delta = {d: after[d] - before[d] for d in range(4) if before[d] != after[d]}
    tol_str = f", tolerance={tolerance}" if tolerance is not None else ""
    self._model._log(
        f"make_conformal(dims={dims}{tol_str}): entity delta={delta} "
        f"(before={before}, after={after})"
    )
    return self

bounding_box

bounding_box(tag, *, dim: int = 3) -> tuple[float, float, float, float, float, float]

Return the axis-aligned bounding box of an entity.

tag accepts an int, a label, a PG name, or a (dim, tag) tuple. Must resolve to exactly one entity. When tag is a bare int, dim is honoured as an explicit dimension hint (no live-model lookup) — important because Gmsh tag spaces are per-dimension, so the same int can refer to different entities at different dims.

Returns

(xmin, ymin, zmin, xmax, ymax, zmax)

Example

xmin, ymin, zmin, xmax, ymax, zmax = g.model.queries.bounding_box("box")

Source code in src/apeGmsh/core/_model_queries.py
def bounding_box(
    self,
    tag,
    *,
    dim: int = 3,
) -> tuple[float, float, float, float, float, float]:
    """
    Return the axis-aligned bounding box of an entity.

    ``tag`` accepts an int, a label, a PG name, or a ``(dim, tag)``
    tuple.  Must resolve to exactly one entity.  When ``tag`` is a
    bare int, ``dim`` is honoured as an explicit dimension hint
    (no live-model lookup) — important because Gmsh tag spaces are
    per-dimension, so the same int can refer to different entities
    at different dims.

    Returns
    -------
    (xmin, ymin, zmin, xmax, ymax, zmax)

    Example
    -------
    ``xmin, ymin, zmin, xmax, ymax, zmax = g.model.queries.bounding_box("box")``
    """
    self._require_kernel("g.model.queries.bounding_box()")
    if isinstance(tag, int) and not isinstance(tag, bool):
        return gmsh.model.getBoundingBox(dim, tag)
    from ._helpers import resolve_to_single_dimtag
    d, t = resolve_to_single_dimtag(
        tag, default_dim=dim, session=self._model._parent,
        what="bounding_box target",
    )
    return gmsh.model.getBoundingBox(d, t)

center_of_mass

center_of_mass(tag, *, dim: int = 3) -> tuple[float, float, float]

Return the center of mass of an entity.

tag accepts an int, a label, a PG name, or a (dim, tag) tuple. Must resolve to exactly one entity. Bare ints are interpreted at dim directly (no live-model lookup).

Example

cx, cy, cz = g.model.queries.center_of_mass("box")

Source code in src/apeGmsh/core/_model_queries.py
def center_of_mass(
    self,
    tag,
    *,
    dim: int = 3,
) -> tuple[float, float, float]:
    """
    Return the center of mass of an entity.

    ``tag`` accepts an int, a label, a PG name, or a ``(dim, tag)``
    tuple.  Must resolve to exactly one entity.  Bare ints are
    interpreted at ``dim`` directly (no live-model lookup).

    Example
    -------
    ``cx, cy, cz = g.model.queries.center_of_mass("box")``
    """
    self._require_kernel("g.model.queries.center_of_mass()")
    if isinstance(tag, int) and not isinstance(tag, bool):
        return gmsh.model.occ.getCenterOfMass(dim, tag)
    from ._helpers import resolve_to_single_dimtag
    d, t = resolve_to_single_dimtag(
        tag, default_dim=dim, session=self._model._parent,
        what="center_of_mass target",
    )
    return gmsh.model.occ.getCenterOfMass(d, t)

mass

mass(tag, *, dim: int = 3) -> float

Return the mass (volume for 3D, area for 2D, length for 1D) of an entity.

tag accepts an int, a label, a PG name, or a (dim, tag) tuple. Must resolve to exactly one entity. Bare ints are interpreted at dim directly (no live-model lookup).

Example

vol = g.model.queries.mass("box")

Source code in src/apeGmsh/core/_model_queries.py
def mass(
    self,
    tag,
    *,
    dim: int = 3,
) -> float:
    """
    Return the mass (volume for 3D, area for 2D, length for 1D)
    of an entity.

    ``tag`` accepts an int, a label, a PG name, or a ``(dim, tag)``
    tuple.  Must resolve to exactly one entity.  Bare ints are
    interpreted at ``dim`` directly (no live-model lookup).

    Example
    -------
    ``vol = g.model.queries.mass("box")``
    """
    self._require_kernel("g.model.queries.mass()")
    if isinstance(tag, int) and not isinstance(tag, bool):
        return gmsh.model.occ.getMass(dim, tag)
    from ._helpers import resolve_to_single_dimtag
    d, t = resolve_to_single_dimtag(
        tag, default_dim=dim, session=self._model._parent,
        what="mass target",
    )
    return gmsh.model.occ.getMass(d, t)

boundary

boundary(tags: TagsLike, *, dim: int = 3, oriented: bool = False, combined: bool = True, recursive: bool = False) -> list[DimTag]

Return the boundary entities of the given entities.

Parameters

tags : int, label, PG name, (dim, tag), or list thereof. Strings are resolved as label first (Tier 1, g.labels), then user physical-group name (Tier 2, g.physical). dim : default dimension for bare integer tags or string refs. oriented : if True, return oriented boundary (signs on tags). combined : if True, return the boundary of the combined entities. recursive : if True, recurse down to dimension 0.

Returns

list[DimTag] Boundary entities as (dim, tag) pairs.

Example

::

faces = g.model.queries.boundary(vol_tag)            # by tag
edges = g.model.queries.boundary("Plate", dim=2)     # by label
Source code in src/apeGmsh/core/_model_queries.py
def boundary(
    self,
    tags: TagsLike,
    *,
    dim      : int  = 3,
    oriented : bool = False,
    combined : bool = True,
    recursive: bool = False,
) -> list[DimTag]:
    """
    Return the boundary entities of the given entities.

    Parameters
    ----------
    tags : int, label, PG name, ``(dim, tag)``, or list thereof.
        Strings are resolved as label first (Tier 1, ``g.labels``),
        then user physical-group name (Tier 2, ``g.physical``).
    dim : default dimension for bare integer tags or string refs.
    oriented : if True, return oriented boundary (signs on tags).
    combined : if True, return the boundary of the combined entities.
    recursive : if True, recurse down to dimension 0.

    Returns
    -------
    list[DimTag]
        Boundary entities as (dim, tag) pairs.

    Example
    -------
    ::

        faces = g.model.queries.boundary(vol_tag)            # by tag
        edges = g.model.queries.boundary("Plate", dim=2)     # by label
    """
    self._require_kernel("g.model.queries.boundary()")
    if isinstance(tags, str):
        from ._helpers import _resolve_string_to_dimtags
        dt = _resolve_string_to_dimtags(
            tags, default_dim=dim, session=self._model._parent,
        )
    elif (
        isinstance(tags, list)
        and tags
        and all(isinstance(t, str) for t in tags)
    ):
        from ._helpers import _resolve_string_to_dimtags
        dt = []
        for name in tags:
            dt.extend(_resolve_string_to_dimtags(
                name, default_dim=dim, session=self._model._parent,
            ))
    else:
        dt = self._model._as_dimtags(tags, dim)
    return gmsh.model.getBoundary(
        dt,
        combined=combined,
        oriented=oriented,
        recursive=recursive,
    )

boundary_curves

boundary_curves(tag) -> list[DimTag]

Return all unique curves (dim = 1) on the boundary of an entity.

Wraps the two-step query needed to get a volume's edges: boundary(vol) skips straight to vertices when recursive=True, so the correct pattern is to fetch faces first, then walk each face's boundary individually with combined=False (so shared edges are not cancelled), and deduplicate the result.

Parameters

tag : int, label, PG name, (dim, tag) tuple, or list thereof.

Returns

list[DimTag] (1, curve_tag) pairs, deduplicated.

Example

::

edges = g.model.queries.boundary_curves('box')   # 12 edges
edges = g.model.queries.boundary_curves(surf)    # 4 edges of a face
Source code in src/apeGmsh/core/_model_queries.py
def boundary_curves(self, tag) -> list[DimTag]:
    """
    Return all unique curves (dim = 1) on the boundary of an entity.

    Wraps the two-step query needed to get a volume's edges:
    ``boundary(vol)`` skips straight to vertices when ``recursive=True``,
    so the correct pattern is to fetch faces first, then walk each face's
    boundary individually with ``combined=False`` (so shared edges are not
    cancelled), and deduplicate the result.

    Parameters
    ----------
    tag : int, label, PG name, ``(dim, tag)`` tuple, or list thereof.

    Returns
    -------
    list[DimTag]
        ``(1, curve_tag)`` pairs, deduplicated.

    Example
    -------
    ::

        edges = g.model.queries.boundary_curves('box')   # 12 edges
        edges = g.model.queries.boundary_curves(surf)    # 4 edges of a face
    """
    # Guarded here as well as in boundary(), so the message names
    # this call rather than the delegate underneath it.
    self._require_kernel("g.model.queries.boundary_curves()")
    owners = self._resolve_to_dimtags(tag)
    # If the entities are already curves, return them deduplicated.
    if all(d == 1 for d, _ in owners):
        return list(dict.fromkeys(owners))
    # Surfaces → walk one more level with combined=False to keep shared edges.
    if all(d == 2 for d, _ in owners):
        return list(dict.fromkeys(
            self.boundary(owners, combined=False, oriented=False)
        ))
    # Volumes (or mixed): faces first, then their individual boundaries.
    faces = self.boundary(owners, oriented=False)
    return list(dict.fromkeys(
        self.boundary(faces, combined=False, oriented=False)
    ))

boundary_points

boundary_points(tag) -> list[DimTag]

Return all unique points (dim = 0) on the boundary of an entity.

Equivalent to boundary(tag, recursive=True) for volumes — Gmsh's recursive walk goes straight to dim=0 — but provided as a named alias for symmetry with boundary_curves.

Example

::

corners = g.model.queries.boundary_points('box')   # 8 corners
Source code in src/apeGmsh/core/_model_queries.py
def boundary_points(self, tag) -> list[DimTag]:
    """
    Return all unique points (dim = 0) on the boundary of an entity.

    Equivalent to ``boundary(tag, recursive=True)`` for volumes —
    Gmsh's recursive walk goes straight to dim=0 — but provided as a
    named alias for symmetry with ``boundary_curves``.

    Example
    -------
    ::

        corners = g.model.queries.boundary_points('box')   # 8 corners
    """
    self._require_kernel("g.model.queries.boundary_points()")
    owners = self._resolve_to_dimtags(tag)
    return list(dict.fromkeys(
        dt for dt in self.boundary(owners, oriented=False, recursive=True)
        if dt[0] == 0
    ))

adjacencies

adjacencies(tag: Tag, *, dim: int = 3) -> tuple[list[Tag], list[Tag]]

Return entities adjacent to the given entity.

Returns

(upward, downward) upward — tags of entities of dim + 1 that contain this entity. downward — tags of entities of dim - 1 on this entity's boundary.

Example

::

up, down = g.model.queries.adjacencies(face_tag, dim=2)
# up   = volumes bounded by this face
# down = curves on this face's boundary
Source code in src/apeGmsh/core/_model_queries.py
def adjacencies(
    self,
    tag: Tag,
    *,
    dim: int = 3,
) -> tuple[list[Tag], list[Tag]]:
    """
    Return entities adjacent to the given entity.

    Returns
    -------
    (upward, downward)
        ``upward`` — tags of entities of ``dim + 1`` that contain
        this entity.
        ``downward`` — tags of entities of ``dim - 1`` on this
        entity's boundary.

    Example
    -------
    ::

        up, down = g.model.queries.adjacencies(face_tag, dim=2)
        # up   = volumes bounded by this face
        # down = curves on this face's boundary
    """
    self._require_kernel("g.model.queries.adjacencies()")
    d = self._model._resolve_dim(tag, dim)
    up, down = gmsh.model.getAdjacencies(d, tag)
    return list(up), list(down)

entities_in_bounding_box

entities_in_bounding_box(xmin: float, ymin: float, zmin: float, xmax: float, ymax: float, zmax: float, *, dim: int = -1) -> list[DimTag]

Return all entities inside a bounding box.

Parameters

xmin, ymin, zmin, xmax, ymax, zmax : box limits. dim : restrict to this dimension (-1 = all dimensions).

Returns

list[DimTag]

Example

::

# Find all entities in a region
found = g.model.queries.entities_in_bounding_box(
    0, 0, 0,  10, 10, 10, dim=3
)
Source code in src/apeGmsh/core/_model_queries.py
def entities_in_bounding_box(
    self,
    xmin: float, ymin: float, zmin: float,
    xmax: float, ymax: float, zmax: float,
    *,
    dim: int = -1,
) -> list[DimTag]:
    """
    Return all entities inside a bounding box.

    Parameters
    ----------
    xmin, ymin, zmin, xmax, ymax, zmax : box limits.
    dim : restrict to this dimension (-1 = all dimensions).

    Returns
    -------
    list[DimTag]

    Example
    -------
    ::

        # Find all entities in a region
        found = g.model.queries.entities_in_bounding_box(
            0, 0, 0,  10, 10, 10, dim=3
        )
    """
    self._require_kernel("g.model.queries.entities_in_bounding_box()")
    return gmsh.model.getEntitiesInBoundingBox(
        xmin, ymin, zmin, xmax, ymax, zmax, dim,
    )

plane

plane(*args, **kwargs) -> Plane

Construct a :class:Plane for use with m.model.select(...).crossing_plane(plane, mode=...) (or any other API that accepts a plane spec).

Forms accepted

plane(z=0) / plane(x=5) Axis-aligned plane. plane(p1, p2, p3) Plane through three non-collinear points. plane(normal=(0, 0, 1), through=(0, 0, 5)) Direct construction from a normal and an anchor point.

Example

::

mid = m.model.queries.plane(z=2.5)
faces_cut = m.model.select(faces, dim=2).crossing_plane(
    mid, mode="crossing")
below     = m.model.select(faces, dim=2).crossing_plane(
    mid, mode="not_crossing")
Source code in src/apeGmsh/core/_model_queries.py
def plane(self, *args, **kwargs) -> Plane:
    """
    Construct a :class:`Plane` for use with
    ``m.model.select(...).crossing_plane(plane, mode=...)`` (or any
    other API that accepts a plane spec).

    Forms accepted
    --------------
    ``plane(z=0)`` / ``plane(x=5)``
        Axis-aligned plane.
    ``plane(p1, p2, p3)``
        Plane through three non-collinear points.
    ``plane(normal=(0, 0, 1), through=(0, 0, 5))``
        Direct construction from a normal and an anchor point.

    Example
    -------
    ::

        mid = m.model.queries.plane(z=2.5)
        faces_cut = m.model.select(faces, dim=2).crossing_plane(
            mid, mode="crossing")
        below     = m.model.select(faces, dim=2).crossing_plane(
            mid, mode="not_crossing")
    """
    if args and not kwargs:
        if len(args) == 3:
            return Plane.through(*args)
        raise ValueError(
            "plane(*args) needs 3 points; got "
            f"{len(args)}.  Did you mean plane(z=0)?"
        )
    if kwargs and not args:
        if 'normal' in kwargs and 'through' in kwargs:
            import numpy as _np
            n = _np.asarray(kwargs['normal'], dtype=float)
            n = n / _np.linalg.norm(n)
            a = _np.asarray(kwargs['through'], dtype=float)
            return Plane(normal=n, anchor=a)
        return Plane.at(**kwargs)
    raise ValueError(
        "plane() takes either an axis kwarg (z=0), 3 positional points, "
        "or normal=/through= kwargs."
    )

registry

registry() -> pd.DataFrame

Return a DataFrame of all entities created through this helper.

Indexed by (dim, tag) — matching Gmsh's identity model where tags are only unique within a dimension.

Columns: kind, label

The label column is populated from g.labels (the single source of truth), not from the metadata dict.

Source code in src/apeGmsh/core/_model_queries.py
def registry(self) -> pd.DataFrame:
    """
    Return a DataFrame of all entities created through this helper.

    Indexed by ``(dim, tag)`` — matching Gmsh's identity model where
    tags are only unique within a dimension.

    Columns: ``kind``, ``label``

    The ``label`` column is populated from ``g.labels`` (the single
    source of truth), not from the metadata dict.
    """
    if not self._model._metadata:
        return pd.DataFrame(columns=['dim', 'tag', 'kind', 'label'])

    # Build label reverse map from g.labels
    labels_comp = getattr(self._model._parent, 'labels', None)
    label_map: dict[tuple[int, int], str] = {}
    if labels_comp is not None:
        try:
            label_map = labels_comp.reverse_map()
        except Exception:
            pass

    rows = []
    for (dim, tag), info in self._model._metadata.items():
        row = {'dim': dim, 'tag': tag, **info}
        row['label'] = label_map.get((dim, tag), '')
        rows.append(row)
    return (
        pd.DataFrame(rows)
        .set_index(['dim', 'tag'])
        .sort_index()
    )

Fluent selection — g.model.select()

g.model.select(...) is the single entity-selection surface and the geometry entry of the unified, daisy-chainable selection idiom. The former g.model.queries.select(...) predicate selector and the former g.model.selection.select_* entity composite have been removed; their behaviour is folded into the verbs below. select() returns an EntitySelection (entity family) with direct terminals .to_label() / .to_physical() / .to_dataframe(); .result() is a zero-cost identity alias yielding the Selection payload (retained by architecture as the entity-side terminal type).

(g.model.select("Faces")                          # tiered name resolve
    .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"))

Entity-family in_box is gmsh BRep containment and rejects inclusive= with a TypeError (it is point-family only). See Selection for the full idiom, the verb surface, and the point-vs-entity family contract.

Geometric predicates — cheat sheet

The straddle predicates are reached two ways: as the .crossing_plane(spec, *, mode=) verb on the EntitySelection chain, or as the .select(on=/crossing=/not_on=/not_crossing=) refinement method on the Selection payload (after .result()):

Predicate Where Dim Example Keeps entities that…
mode="on" / on= .crossing_plane() verb / .select() kwarg any on={"z": 0} lie entirely on the plane
mode="crossing" / crossing= .crossing_plane() verb / .select() kwarg any crossing={"z": 0} straddle the plane
mode="not_on" / not_on= .crossing_plane() verb / .select() kwarg any not_on={"z": 0} are not entirely on the plane
mode="not_crossing" / not_crossing= .crossing_plane() verb / .select() kwarg any not_crossing={"z": 0} lie entirely on one side
.parallel_to(...) method on Selection 1 (curves) edges.parallel_to("z") are curves whose chord direction is parallel to it
.normal_along(...) method on Selection 2 (surfaces) faces.normal_along("z") are surfaces whose normal is along it

Primitive (plane / line) spec formats

Form Meaning
{"z": 0} / {"x": 5} / {"y": -3} Axis-aligned plane
[(x1,y1,z1), (x2,y2,z2)] Infinite line through 2 points (for curves in 2-D)
[(x1,y1,z1), (x2,y2,z2), (x3,y3,z3)] Infinite plane through 3 points (for surfaces / volumes)
m.model.queries.plane(...) Plane object — axis-aligned, 3-point, or normal=/through=

Direction formats accepted by .parallel_to(...) / .normal_along(...)

Form Meaning
"x", "y", "z" Axis alias
(1, 0, 0) / (1, 1, 0) Any non-zero 3-vector (normalized internally)
angle_tol=2.0 Tolerance in degrees; default 1.0. Anti-parallel counts as parallel.

Seeding a selection

Call Returns
g.model.select(dim=N) every entity at dimension N (point=0, curve=1, surface=2, volume=3)
g.model.select(name_or_dimtags, dim=N) by PG / label / part name, or from an explicit (dim, tag) set

Selection — .result() payload of select()

g.model.select(...) returns an EntitySelection; its .result() yields a Selection — a chainable list of (dim, tag) pairs. No import is needed. The Selection payload still carries the position predicates (.select(on=/crossing=/not_on=/not_crossing=, tol=)), direction filters, and set-algebra.

curves = m.model.queries.boundary(surf, oriented=False)  # -> Selection

# axis-aligned plane
bottom = curves.select(on={'y': 0})

# 2-point line
mid    = curves.select(crossing=[(0,5,0),(5,5,0)])

# chain to narrow further
left_bottom = curves.select(on={'y': 0}).select(on={'x': 0})

# extract bare tags for downstream calls
m.mesh.structured.set_transfinite_curve(bottom.tags(), n=11)

Starting from every entity of a dimension

When parsing an imported .geo / STEP file with no labels yet, seed with g.model.select(dim=N) (no target):

# Every volume in the model
g.model.select(dim=3).to_physical("solids")

# Volumes the plane z = -15 slices through
(g.model.select(dim=3)
    .crossing_plane({"z": -15}, mode="crossing")
    .to_physical("crossers"))

# The floor (surfaces lying on z = 0)
(g.model.select(dim=2)
    .crossing_plane({"z": 0}, mode="on")
    .to_physical("base"))

dim=0 points, dim=1 curves, dim=2 surfaces, dim=3 volumes.

Direction-based filters — parallel_to and normal_along

For dim-restricted filtering by direction (not position), the Selection payload offers two methods:

# Curves: keep only edges whose chord is along a direction
edges = g.model.select("box", dim=1).result()
verticals = edges.parallel_to("z")                    # axis alias
diagonals = edges.parallel_to((1, 1, 0), angle_tol=2) # arbitrary vector

# Surfaces: keep only faces whose normal is along a direction
faces = g.model.select("box", dim=2).result()
horizontals = faces.normal_along("z")

Both accept axis aliases ("x" / "y" / "z") or any non-zero 3-vector (normalized internally; anti-parallel counts as parallel). Default angle_tol is 1.0°. The methods are dim-restricted: parallel_to raises if the Selection contains non-curve entities, normal_along raises for non-surface entities — with a fix-it suggestion in the error.

They chain with the position predicates:

# Vertical edges on the x = 0 wall
(g.model.select("box", dim=1).result()
    .parallel_to("z")
    .select(on={"x": 0}))

Combining selections — |, &, -

Two Selections can be combined with set-algebra operators. Semantics are set-like with deduplication — a (dim, tag) pair never appears twice in the result, so downstream calls like to_physical register each entity once.

Each operation has both an operator form (terse, for one-liners) and a named-method form (discoverable via autocomplete, keeps the chain fluent — important when you don't want to break out to a variable).

Operator Method Meaning Example
a \| b a.union(b) entities in either sides = nx.union(ny)
a & b a.intersect(b) entities in both edge = top.intersect(front)
a - b a.difference(b) in a, not in b lateral = all.difference(horizontal)
surf = g.model.select(dim=2).result()

# Three equivalent ways to grab the lateral sides of an axis-aligned box:
(surf.normal_along("x") | surf.normal_along("y")).to_physical("sides")
(surf - surf.normal_along("z")).to_physical("sides")
surf.normal_along("x").union(surf.normal_along("y")).to_physical("sides")

# Intersection — curves shared by two faces (the edge between them)
top_edges   = m.model.queries.boundary("top",   dim=2, oriented=False)
front_edges = m.model.queries.boundary("front", dim=2, oriented=False)
shared_edge = top_edges.intersect(front_edges)

Why | and not +? Selection subclasses list, where + already means concatenation with duplicates preserved. The | family follows Python's set / dict convention for combining-with-dedup, which is the right semantics for selection sets — combining the xmin faces with the ymin faces should give each shared corner edge once, not twice.

Resolve-only select(...) — no predicate required

g.model.select("name", dim=N) with no spatial verb returns the entities under that name as a chainable selection — useful as an entry point into the method-style filters:

g.model.select("box", dim=1).result().parallel_to("z").to_physical("verticals")

apeGmsh.core._selection.Selection

Selection(dimtags: Iterable[DimTag] = (), *, _queries: '_Queries | None' = None)

Bases: list

A filtered list of (dim, tag) pairs — the payload yielded by the .result() terminal of an :class:EntitySelection (itself returned by m.model.select(...)). Retained by architecture as the entity-side terminal payload; it is not a legacy or backward-compat type.

A Selection is a list subclass, so it iterates as (dim, tag) pairs and supports indexing. It is also chainable — every method that narrows or combines returns a new Selection.

Refine (narrow what you have)

============================== ========================================== .select(...) position predicates: on, crossing, not_on, not_crossing .parallel_to(direction) curves whose chord is along a direction .normal_along(direction) surfaces whose normal is along a direction .partition_by(axis=None) group entities by dominant BB axis ============================== ==========================================

Combine (set algebra on two Selections)

Set semantics with deduplication — appropriate for (dim, tag) pairs, where logical identity matters and duplicates would cause downstream calls (e.g. to_physical) to register the same entity twice.

Each operation has both an operator form (terse, for one-liners) and a named-method form (discoverable via autocomplete, keeps the chain fluent).

===================== ===================== ===================== =========================== Operator Method Meaning Example ===================== ===================== ===================== =========================== a | b a.union(b) union nx | ny a & b a.intersect(b) intersection top & front a - b a.difference(b) set difference all - horizontal ===================== ===================== ===================== ===========================

Why | and not +: Selection subclasses list, where + is concatenation with duplicates preserved. | follows the set / dict convention for combining-with-dedup, and is the right semantics for selection sets.

Consume (turn a Selection into something else)

================================ =========================================== .tags() bare integer tags (drops dim) .to_label(name) register entities as a label .to_physical(name) register entities as a physical group ================================ ===========================================

Example

::

surf = m.model.select(dim=2).result()

# Lateral sides of an axis-aligned box — three equivalent forms:
(surf.normal_along("x") | surf.normal_along("y")).to_physical("sides")
(surf - surf.normal_along("z")).to_physical("sides")
surf.normal_along("x").union(surf.normal_along("y")).to_physical("sides")

# Chain refine → consume
(m.model.select(curves, dim=1).result()
    .select(on={'z': 0})
    .select(on={'x': 0})
    .to_label("bottom_left_edge"))
Source code in src/apeGmsh/core/_selection.py
def __init__(self, dimtags: Iterable[DimTag] = (), *,
             _queries: "_Queries | None" = None) -> None:
    super().__init__(dimtags)
    self._queries = _queries

select

select(*, on=None, crossing=None, not_on=None, not_crossing=None, tol: float = 1e-06) -> 'Selection'

Filter this selection further by position predicates (on / crossing / not_on / not_crossing).

Source code in src/apeGmsh/core/_selection.py
def select(self, *, on=None, crossing=None, not_on=None, not_crossing=None,
           tol: float = 1e-6) -> "Selection":
    """Filter this selection further by position predicates
    (``on`` / ``crossing`` / ``not_on`` / ``not_crossing``)."""
    return _select_impl(self, on=on, crossing=crossing,
                        not_on=not_on, not_crossing=not_crossing,
                        tol=tol, _queries=self._queries)

tags

tags() -> list[int]

Return bare integer tags (drops dim).

Source code in src/apeGmsh/core/_selection.py
def tags(self) -> list[int]:
    """Return bare integer tags (drops dim)."""
    return [t for _, t in self]

to_label

to_label(name: str) -> 'Selection'

Register every entity in this selection as a label.

Groups by dimension before calling session.labels.add so a mixed-dim Selection is handled correctly. Returns self for chaining.

Example

::

(m.model.select(curves, dim=1).result()
    .select(on={'x': 0})
    .select(on={'y': 5})
    .to_label('left_top_edge'))

m.mesh.sizing.set_size('left_top_edge', size=0.1)
Source code in src/apeGmsh/core/_selection.py
def to_label(self, name: str) -> "Selection":
    """
    Register every entity in this selection as a label.

    Groups by dimension before calling ``session.labels.add`` so a
    mixed-dim Selection is handled correctly.  Returns ``self`` for
    chaining.

    Example
    -------
    ::

        (m.model.select(curves, dim=1).result()
            .select(on={'x': 0})
            .select(on={'y': 5})
            .to_label('left_top_edge'))

        m.mesh.sizing.set_size('left_top_edge', size=0.1)
    """
    import warnings
    if self._queries is None:
        raise RuntimeError(
            "Selection.to_label()/.to_physical() requires a Selection "
            "bound to the model-queries engine — build it via "
            "m.model.select(...).result(). This Selection has "
            "_queries=None (constructed standalone), so it has no "
            "session to register the label/physical-group on."
        )
    session = self._queries._model._parent
    dims    = sorted({d for d, _ in self})
    with warnings.catch_warnings():
        # Re-using the same name across multiple dims is the documented
        # intent here, not a mistake — silence the labels-composite warning
        # so a mixed-dim selection labels cleanly.
        if len(dims) > 1:
            warnings.filterwarnings(
                "ignore", message=r".*already exists at dim.*",
            )
        for d in dims:
            tags = [t for dim, t in self if dim == d]
            session.labels.add(d, tags, name=name)
    return self

to_physical

to_physical(name: str) -> 'Selection'

Register every entity in this selection as a physical group.

Groups by dimension before calling session.physical.add so a mixed-dim Selection is handled correctly. Returns self for chaining.

Example

::

(m.model.select(faces, dim=2).result()
    .select(on={'z': 0})
    .to_physical('Base'))

g.constraints.fix('Base', dofs=[1, 2, 3])
Source code in src/apeGmsh/core/_selection.py
def to_physical(self, name: str) -> "Selection":
    """
    Register every entity in this selection as a physical group.

    Groups by dimension before calling ``session.physical.add`` so a
    mixed-dim Selection is handled correctly.  Returns ``self`` for
    chaining.

    Example
    -------
    ::

        (m.model.select(faces, dim=2).result()
            .select(on={'z': 0})
            .to_physical('Base'))

        g.constraints.fix('Base', dofs=[1, 2, 3])
    """
    if self._queries is None:
        raise RuntimeError(
            "Selection.to_label()/.to_physical() requires a Selection "
            "bound to the model-queries engine — build it via "
            "m.model.select(...).result(). This Selection has "
            "_queries=None (constructed standalone), so it has no "
            "session to register the label/physical-group on."
        )
    session = self._queries._model._parent
    for d in sorted({d for d, _ in self}):
        tags = [t for dim, t in self if dim == d]
        session.physical.add(d, tags, name=name)
    return self

parallel_to

parallel_to(direction: 'str | tuple[float, float, float] | np.ndarray', *, angle_tol: float = 1.0) -> 'Selection'

Keep curves whose endpoint chord is parallel to direction.

Only meaningful for curves (dim=1). Raises ValueError if the Selection contains entities of any other dim.

Parameters

direction : str or 3-vector "x", "y", "z" for axis aliases, or any non-zero 3-vector for an arbitrary direction. Anti-parallel matches count as parallel — a z-edge with reversed endpoint order is still a z-edge. angle_tol : float, default 1.0 Maximum angle (in degrees) between the curve's chord direction and direction for the curve to be kept.

Returns

Selection New Selection of curves that match.

Example

::

edges = m.model.select(None, dim=1).result()
verticals = edges.parallel_to("z")
obliques  = edges.parallel_to((1, 1, 0), angle_tol=2.0)

m.mesh.structured.set_transfinite_curve(verticals.tags(), n=21)
Source code in src/apeGmsh/core/_selection.py
def parallel_to(
    self,
    direction: "str | tuple[float, float, float] | np.ndarray",
    *,
    angle_tol: float = 1.0,
) -> "Selection":
    """Keep curves whose endpoint chord is parallel to ``direction``.

    Only meaningful for curves (dim=1).  Raises ``ValueError`` if the
    Selection contains entities of any other dim.

    Parameters
    ----------
    direction : str or 3-vector
        ``"x"``, ``"y"``, ``"z"`` for axis aliases, or any non-zero
        3-vector for an arbitrary direction.  Anti-parallel matches
        count as parallel — a z-edge with reversed endpoint order is
        still a z-edge.
    angle_tol : float, default 1.0
        Maximum angle (in degrees) between the curve's chord direction
        and ``direction`` for the curve to be kept.

    Returns
    -------
    Selection
        New Selection of curves that match.

    Example
    -------
    ::

        edges = m.model.select(None, dim=1).result()
        verticals = edges.parallel_to("z")
        obliques  = edges.parallel_to((1, 1, 0), angle_tol=2.0)

        m.mesh.structured.set_transfinite_curve(verticals.tags(), n=21)
    """
    _require_dim(self, 1, method="parallel_to")
    target = _parse_direction(direction)
    cos_tol = math.cos(math.radians(angle_tol))
    kept = [
        dt for dt in self
        if abs(float(_chord_direction(dt) @ target)) >= cos_tol
    ]
    return Selection(kept, _queries=self._queries)

normal_along

normal_along(direction: 'str | tuple[float, float, float] | np.ndarray', *, angle_tol: float = 1.0) -> 'Selection'

Keep surfaces whose face normal is along direction.

Only meaningful for surfaces (dim=2). Raises ValueError if the Selection contains entities of any other dim.

Same direction grammar and tolerance as :meth:parallel_to. The normal is computed from three boundary points — exact for flat faces, an approximation for curved faces (prefer on= for those). Anti-parallel matches count as parallel.

Example

::

faces = m.model.select("layer_1", dim=2).result()
horizontals = faces.normal_along("z")
verticals   = faces.normal_along("x").select(...)
Source code in src/apeGmsh/core/_selection.py
def normal_along(
    self,
    direction: "str | tuple[float, float, float] | np.ndarray",
    *,
    angle_tol: float = 1.0,
) -> "Selection":
    """Keep surfaces whose face normal is along ``direction``.

    Only meaningful for surfaces (dim=2).  Raises ``ValueError`` if
    the Selection contains entities of any other dim.

    Same direction grammar and tolerance as :meth:`parallel_to`.  The
    normal is computed from three boundary points — exact for flat
    faces, an approximation for curved faces (prefer ``on=`` for those).
    Anti-parallel matches count as parallel.

    Example
    -------
    ::

        faces = m.model.select("layer_1", dim=2).result()
        horizontals = faces.normal_along("z")
        verticals   = faces.normal_along("x").select(...)
    """
    _require_dim(self, 2, method="normal_along")
    target = _parse_direction(direction)
    cos_tol = math.cos(math.radians(angle_tol))
    kept = [
        dt for dt in self
        if abs(float(_face_normal(dt) @ target)) >= cos_tol
    ]
    return Selection(kept, _queries=self._queries)

union

union(other) -> 'Selection'

Alias for self | other. See :meth:__or__.

Source code in src/apeGmsh/core/_selection.py
def union(self, other) -> "Selection":
    """Alias for ``self | other``.  See :meth:`__or__`."""
    return self.__or__(other)

intersect

intersect(other) -> 'Selection'

Alias for self & other. See :meth:__and__.

Source code in src/apeGmsh/core/_selection.py
def intersect(self, other) -> "Selection":
    """Alias for ``self & other``.  See :meth:`__and__`."""
    return self.__and__(other)

difference

difference(other) -> 'Selection'

Alias for self - other. See :meth:__sub__.

Source code in src/apeGmsh/core/_selection.py
def difference(self, other) -> "Selection":
    """Alias for ``self - other``.  See :meth:`__sub__`."""
    return self.__sub__(other)

partition_by

partition_by(axis: str | None = None)

Group entities by their dominant bounding-box axis.

Returns

If axis is None: dict[str, Selection] keyed by 'x', 'y', 'z'. If axis is one of 'x', 'y', 'z': a single Selection for that axis only.

Semantics by entity dimension
  • dim = 1 (curves) — dominant axis is the largest BB extent (the direction the curve runs along).
  • dim = 2 (surfaces) — dominant axis is the smallest BB extent (the surface normal — for axis-aligned faces this picks the perpendicular direction).
  • Mixed dims partition independently per dim using the right rule.
Example

::

curves = m.model.queries.boundary_curves('box')
groups = curves.partition_by()
m.mesh.structured.set_transfinite_curve(groups['x'].tags(), nx)
m.mesh.structured.set_transfinite_curve(groups['y'].tags(), ny)
m.mesh.structured.set_transfinite_curve(groups['z'].tags(), nz)
Source code in src/apeGmsh/core/_selection.py
def partition_by(self, axis: str | None = None):
    """
    Group entities by their dominant bounding-box axis.

    Returns
    -------
    If ``axis`` is ``None``: ``dict[str, Selection]`` keyed by ``'x'``,
    ``'y'``, ``'z'``.
    If ``axis`` is one of ``'x'``, ``'y'``, ``'z'``: a single
    ``Selection`` for that axis only.

    Semantics by entity dimension
    -----------------------------
    - **dim = 1 (curves)** — dominant axis is the **largest** BB extent
      (the direction the curve runs along).
    - **dim = 2 (surfaces)** — dominant axis is the **smallest** BB extent
      (the surface normal — for axis-aligned faces this picks the
      perpendicular direction).
    - Mixed dims partition independently per dim using the right rule.

    Example
    -------
    ::

        curves = m.model.queries.boundary_curves('box')
        groups = curves.partition_by()
        m.mesh.structured.set_transfinite_curve(groups['x'].tags(), nx)
        m.mesh.structured.set_transfinite_curve(groups['y'].tags(), ny)
        m.mesh.structured.set_transfinite_curve(groups['z'].tags(), nz)
    """
    if axis is not None and axis not in ('x', 'y', 'z'):
        raise ValueError(f"axis must be 'x', 'y', or 'z', got {axis!r}")

    groups: dict[str, list] = {'x': [], 'y': [], 'z': []}
    AXES = ('x', 'y', 'z')

    for d, t in self:
        xmin, ymin, zmin, xmax, ymax, zmax = gmsh.model.getBoundingBox(d, t)
        spans = [xmax - xmin, ymax - ymin, zmax - zmin]
        if d == 1:
            # Curve → direction of largest extent
            idx = int(np.argmax(spans))
        elif d == 2:
            # Surface → axis with smallest extent (≈ normal direction)
            idx = int(np.argmin(spans))
        elif d == 3:
            # Volume → largest extent (most useful for transfinite hints)
            idx = int(np.argmax(spans))
        else:
            continue                       # dim 0 — points have no axis
        groups[AXES[idx]].append((d, t))

    if axis is not None:
        return Selection(groups[axis], _queries=self._queries)
    return {ax: Selection(items, _queries=self._queries)
            for ax, items in groups.items()}

Geometric primitives (internal)

These classes are constructed automatically by select() from raw input. You never instantiate them directly, but their docstrings describe the accepted formats.

apeGmsh.core._selection.Plane dataclass

Plane(normal: ndarray, anchor: ndarray)

Infinite plane defined by a unit normal and an anchor point.

at classmethod

at(**kwargs) -> 'Plane'

Axis-aligned plane. E.g. Plane.at(z=0), Plane.at(x=5).

Source code in src/apeGmsh/core/_selection.py
@classmethod
def at(cls, **kwargs) -> "Plane":
    """Axis-aligned plane.  E.g. ``Plane.at(z=0)``, ``Plane.at(x=5)``."""
    if len(kwargs) != 1:
        raise ValueError("Plane.at() takes exactly one keyword, e.g. z=0")
    axis, value = next(iter(kwargs.items()))
    axes = {'x': 0, 'y': 1, 'z': 2}
    if axis not in axes:
        raise ValueError(f"Unknown axis {axis!r}. Use 'x', 'y', or 'z'.")
    normal = np.zeros(3)
    normal[axes[axis]] = 1.0
    anchor = np.zeros(3)
    anchor[axes[axis]] = float(value)
    return cls(normal=normal, anchor=anchor)

through classmethod

through(p1, p2, p3) -> 'Plane'

Plane through three non-collinear points.

Source code in src/apeGmsh/core/_selection.py
@classmethod
def through(cls, p1, p2, p3) -> "Plane":
    """Plane through three non-collinear points."""
    p1, p2, p3 = np.array(p1, float), np.array(p2, float), np.array(p3, float)
    n = np.cross(p2 - p1, p3 - p1)
    norm = np.linalg.norm(n)
    if norm < 1e-14:
        raise ValueError("Points are collinear — cannot define a plane.")
    return cls(normal=n / norm, anchor=p1)

signed_distances

signed_distances(bb: tuple) -> np.ndarray

Signed distance of each bounding-box corner from this plane.

Source code in src/apeGmsh/core/_selection.py
def signed_distances(self, bb: tuple) -> np.ndarray:
    """Signed distance of each bounding-box corner from this plane."""
    corners = _bb_corners(bb)                       # (8, 3)
    return (corners - self.anchor) @ self.normal    # (8,)

apeGmsh.core._selection.Line dataclass

Line(normal: ndarray, anchor: ndarray)

Infinite line used to cut 2-D geometry.

The 'signed distance' is computed as the component of each bounding-box corner along the line's in-plane normal — the axis perpendicular to the line direction projected onto the dominant plane (XY, XZ, or YZ).

through classmethod

through(p1, p2) -> 'Line'

Line through two points.

Source code in src/apeGmsh/core/_selection.py
@classmethod
def through(cls, p1, p2) -> "Line":
    """Line through two points."""
    p1, p2 = np.array(p1, float), np.array(p2, float)
    d = p2 - p1
    norm = np.linalg.norm(d)
    if norm < 1e-14:
        raise ValueError("Points are coincident — cannot define a line.")
    d = d / norm
    # Build a normal perpendicular to d in the plane that best contains it
    # Try cross with Z, then Y, then X to avoid degeneracy
    for ref in (np.array([0., 0., 1.]), np.array([0., 1., 0.]), np.array([1., 0., 0.])):
        n = np.cross(d, ref)
        if np.linalg.norm(n) > 1e-6:
            break
    n = n / np.linalg.norm(n)
    return cls(normal=n, anchor=p1)