Skip to content

Parts — g.parts

A Part owns an isolated Gmsh session and exports a shape to STEP. g.parts is the assembly-side registry that imports those STEPs back in and tracks which tags belong to which label.

Part

apeGmsh.core.Part.Part

Part(name: str, *, auto_persist: bool = True)

Bases: _SessionBase

An isolated geometry unit — no meshing, no solver state.

Carries geometry plus Tier-1 naming (labels + auto-created physical groups from label= kwargs, persisted via the STEP sidecar). For independently-meshed parts use a full session per part + g.compose instead — see the module docstring.

Parameters

name : str Descriptive name (also used as the Gmsh model name). auto_persist : bool, default True When True, the Part writes its geometry to an OS tempfile on end() if save() was not called explicitly. The tempfile is reclaimed via weakref.finalize when the Part is garbage-collected, or eagerly via cleanup(). Set to False to opt out — in that case parts.add(part) will raise FileNotFoundError unless you called save() by hand.

Source code in src/apeGmsh/core/Part.py
def __init__(self, name: str, *, auto_persist: bool = True) -> None:
    super().__init__(name=name, verbose=False)
    # Register this Part's name in the process-wide clash table
    # so Part.edit.copy / pattern_* can detect duplicates.
    from ._part_edit import _register_part_name
    _register_part_name(name, self)
    self.file_path: Path | None = None       # set by save() or auto-persist
    self.properties: dict[str, Any] = {}     # user metadata
    # When a geometry method is called with ``label="name"``,
    # ``Model._register`` auto-creates a physical group so the
    # label travels through the STEP sidecar into the Assembly.
    self._auto_pg_from_label = True

    # Auto-persist bookkeeping.  ``_owns_file`` is the
    # authorisation bit for deletion — it is True only when we
    # wrote the file ourselves into a temp directory, never
    # when the user called save() with an explicit path.
    self._auto_persist: bool = auto_persist
    self._owns_file: bool = False
    self._temp_dir: Path | None = None
    self._finalizer: weakref.finalize | None = None

has_file property

has_file: bool

True if the Part has been saved to disk.

begin

begin(*, verbose: bool | None = None) -> 'Part'

Open the Part's Gmsh session.

If the Part is being reused — a previous with part: block auto-persisted a tempfile and this call re-enters — the stale tempfile is cleaned up before the new session starts so the next end() can auto-persist fresh geometry.

Source code in src/apeGmsh/core/Part.py
def begin(self, *, verbose: bool | None = None) -> "Part":
    """Open the Part's Gmsh session.

    If the Part is being reused — a previous ``with part:`` block
    auto-persisted a tempfile and this call re-enters — the stale
    tempfile is cleaned up before the new session starts so the
    next ``end()`` can auto-persist fresh geometry.
    """
    if self._owns_file:
        self.cleanup()
        self.file_path = None
    return super().begin(verbose=verbose)  # type: ignore[return-value]

end

end() -> None

Close the Part's Gmsh session.

When auto_persist=True and the user did not call save() inside the session, the geometry is written to an OS tempfile before Gmsh is finalised so the Part can flow straight into assembly.parts.add(part).

Exceptions raised by auto-persist itself are caught and emitted as a warning rather than masking any exception the user's build code may have raised. Gmsh finalisation always runs.

Source code in src/apeGmsh/core/Part.py
def end(self) -> None:
    """Close the Part's Gmsh session.

    When ``auto_persist=True`` and the user did not call
    ``save()`` inside the session, the geometry is written to
    an OS tempfile **before** Gmsh is finalised so the Part can
    flow straight into ``assembly.parts.add(part)``.

    Exceptions raised by auto-persist itself are caught and
    emitted as a warning rather than masking any exception the
    user's build code may have raised.  Gmsh finalisation
    always runs.
    """
    try:
        if (
            self._active
            and self._auto_persist
            and self.file_path is None
            and gmsh.model.getEntities()
        ):
            self._auto_persist_to_temp()
    except Exception as exc:
        warnings.warn(
            f"Part {self.name!r}: auto-persist failed ({exc!r}); "
            f"the Part will not be auto-importable via "
            f"parts.add(). Call part.save('...') explicitly to "
            f"recover.",
            stacklevel=2,
        )
    finally:
        super().end()

cleanup

cleanup() -> None

Delete any auto-persisted tempfile now, without waiting for garbage collection.

Safe to call multiple times. Safe to call on a Part whose file_path was set by explicit save() — the _owns_file guard means the user's file is never touched. After cleanup(), has_file returns False and the Part can be re-built via a new with block.

Source code in src/apeGmsh/core/Part.py
def cleanup(self) -> None:
    """Delete any auto-persisted tempfile now, without waiting
    for garbage collection.

    Safe to call multiple times.  Safe to call on a Part whose
    ``file_path`` was set by explicit ``save()`` — the
    ``_owns_file`` guard means the user's file is never
    touched.  After ``cleanup()``, ``has_file`` returns False
    and the Part can be re-built via a new ``with`` block.
    """
    # Snapshot ownership BEFORE resetting it so the
    # post-finalizer file_path reset only runs when we
    # genuinely owned the file.
    was_owned = self._owns_file

    if self._finalizer is not None and self._finalizer.alive:
        self._finalizer()
    self._finalizer = None
    self._owns_file = False
    self._temp_dir = None

    if was_owned:
        self.file_path = None

save

save(file_path: str | Path | None = None, *, fmt: str | None = None, write_anchors: bool = True, _internal_autopersist: bool = False) -> Path

Export the Part geometry to a CAD file.

Calling save() with a user-supplied path transfers ownership of the output file to the caller — any tempfile previously created by auto-persist is cleaned up immediately, and the library will never delete the new output.

Parameters

file_path : str, Path, or None Destination path. If None, defaults to "{name}.step". The extension determines the format unless fmt overrides it. fmt : str, optional Force format: "step" or "iges". write_anchors : bool, default True Write a JSON sidecar ({file_path}.apegmsh.json) carrying the label -> center-of-mass map for every user-named entity in the Part. This is what lets assembly.parts.add(part) expose the instance's labels via inst.by_label('name'). The sidecar is silently omitted when the Part has no user-named entities, so there is no cost for small throwaway Parts. Pass write_anchors=False to suppress unconditionally — useful when publishing a CAD file to third-party tools that shouldn't see apeGmsh metadata.

Returns

Path Resolved path of the written file.

Source code in src/apeGmsh/core/Part.py
def save(
    self,
    file_path: str | Path | None = None,
    *,
    fmt: str | None = None,
    write_anchors: bool = True,
    _internal_autopersist: bool = False,
) -> Path:
    """
    Export the Part geometry to a CAD file.

    Calling ``save()`` with a user-supplied path **transfers
    ownership of the output file to the caller** — any
    tempfile previously created by auto-persist is cleaned up
    immediately, and the library will never delete the new
    output.

    Parameters
    ----------
    file_path : str, Path, or None
        Destination path.  If ``None``, defaults to
        ``"{name}.step"``.  The extension determines the format
        unless *fmt* overrides it.
    fmt : str, optional
        Force format: ``"step"`` or ``"iges"``.
    write_anchors : bool, default True
        Write a JSON sidecar (``{file_path}.apegmsh.json``)
        carrying the label -> center-of-mass map for every
        user-named entity in the Part.  This is what lets
        ``assembly.parts.add(part)`` expose the instance's
        labels via ``inst.by_label('name')``.  The sidecar is
        silently omitted when the Part has no user-named
        entities, so there is no cost for small throwaway
        Parts.  Pass ``write_anchors=False`` to suppress
        unconditionally — useful when publishing a CAD file
        to third-party tools that shouldn't see apeGmsh
        metadata.

    Returns
    -------
    Path
        Resolved path of the written file.
    """
    if not self._active:
        raise RuntimeError("Part session is not active — call begin() first.")

    # Explicit save by the user: hand off ownership.  The
    # internal auto-persist path sets ``_internal_autopersist``
    # so this branch is skipped — otherwise auto-persist would
    # cleanup() mid-write and zero out the temp directory we're
    # about to create the file in.
    if not _internal_autopersist and self._owns_file:
        self.cleanup()

    # Default: save as STEP using the Part name
    if file_path is None:
        file_path = Path(f"{self.name}.step")

    file_path = Path(file_path)

    # Override extension if fmt is given
    if fmt is not None:
        fmt = fmt.lower().strip(".")
        ext_map = {"step": ".step", "stp": ".step",
                   "iges": ".iges", "igs": ".iges"}
        ext = ext_map.get(fmt)
        if ext is None:
            raise ValueError(f"Unknown format '{fmt}'. Use 'step' or 'iges'.")
        file_path = file_path.with_suffix(ext)

    if file_path.suffix.lower() not in self._VALID_EXT:
        raise ValueError(
            f"Extension '{file_path.suffix}' is not a supported CAD format. "
            f"Use one of {self._VALID_EXT}."
        )

    # Sync OCC kernel before export
    gmsh.model.occ.synchronize()
    gmsh.write(str(file_path))
    self.file_path = file_path.resolve()

    # Write the label->COM sidecar so Assembly.parts.add(part)
    # can expose this Part's user-named entities via
    # ``inst.by_label(...)``.  Failures here are warned, not
    # raised — the CAD write itself already succeeded.
    if write_anchors:
        self._write_anchors(self.file_path)

    return self.file_path

g.parts — registry

apeGmsh.core._parts_registry.PartsRegistry

PartsRegistry(parent: '_SessionBase')

Bases: _PartsFragmentationMixin

Instance management composite — registered as g.parts.

Source code in src/apeGmsh/core/_parts_registry.py
def __init__(self, parent: "_SessionBase") -> None:
    self._parent = parent
    self._instances: dict[str, Instance] = {}
    self._counter: int = 0

instances property

instances: dict[str, Instance]

Read-only view of all instances.

part

part(label: str)

Track entities created inside the block as a named part.

Yields the label string. After the block, any entities that exist now but didn't before are stored as an Instance.

Example::

with g.parts.part("beam"):
    g.model.geometry.add_box(0, 0, 0, 1, 0.5, 10)
Source code in src/apeGmsh/core/_parts_registry.py
@contextmanager
def part(self, label: str):
    """Track entities created inside the block as a named part.

    Yields the label string.  After the block, any entities that
    exist now but didn't before are stored as an Instance.

    Example::

        with g.parts.part("beam"):
            g.model.geometry.add_box(0, 0, 0, 1, 0.5, 10)
    """
    if label in self._instances:
        raise ValueError(f"Part label '{label}' already exists.")

    before = {d: set(t for _, t in gmsh.model.getEntities(d)) for d in range(4)}
    yield label
    after = {d: set(t for _, t in gmsh.model.getEntities(d)) for d in range(4)}

    entities: dict[int, list[int]] = {}
    for d in range(4):
        new_tags = sorted(after[d] - before[d])
        if new_tags:
            entities[d] = new_tags

    dimtags = [(d, t) for d, tags in entities.items() for t in tags]
    inst = Instance(
        label=label,
        part_name=label,
        entities=entities,
        bbox=self._compute_bbox(dimtags) if dimtags else None,
    )
    self._register_instance(inst)

register

register(name: str, dimtags: list[DimTag] | None = None, *, label: str | None = None, pg: str | None = None, dim: int | None = None) -> Instance

Tag existing entities under a part name.

Exactly one of dimtags, label, or pg must be given.

Parameters

name : str Unique part name. dimtags : list of (dim, tag), optional Entities to assign directly. Also accepted positionally as the second argument. label : str, optional Name of an apeGmsh label (g.labels) whose entities should be adopted. pg : str, optional Name of a physical group (g.physical) whose entities should be adopted. dim : int, optional Forwarded to g.labels.entities(label, dim=dim) when using label= and the label spans multiple dimensions.

Returns

Instance

Source code in src/apeGmsh/core/_parts_registry.py
def register(
    self,
    name: str,
    dimtags: list[DimTag] | None = None,
    *,
    label: str | None = None,
    pg: str | None = None,
    dim: int | None = None,
) -> Instance:
    """Tag existing entities under a part name.

    Exactly one of ``dimtags``, ``label``, or ``pg`` must be given.

    Parameters
    ----------
    name : str
        Unique part name.
    dimtags : list of (dim, tag), optional
        Entities to assign directly.  Also accepted positionally
        as the second argument.
    label : str, optional
        Name of an apeGmsh label (``g.labels``) whose entities
        should be adopted.
    pg : str, optional
        Name of a physical group (``g.physical``) whose entities
        should be adopted.
    dim : int, optional
        Forwarded to ``g.labels.entities(label, dim=dim)`` when
        using ``label=`` and the label spans multiple dimensions.

    Returns
    -------
    Instance
    """
    provided = sum(x is not None for x in (dimtags, label, pg))
    if provided != 1:
        raise TypeError(
            "register() requires exactly one of dimtags=, label=, "
            f"or pg= (got {provided})."
        )

    if label is not None:
        labels_comp = self._parent.labels
        if dim is not None:
            tags = labels_comp.entities(label, dim=dim)
            resolved: list[DimTag] = [(dim, int(t)) for t in tags]
        else:
            # Raises ValueError on multi-dim, KeyError on missing
            labels_comp.entities(label)
            resolved = []
            for d in range(4):
                try:
                    d_tags = labels_comp.entities(label, dim=d)
                except KeyError:
                    continue
                resolved = [(d, int(t)) for t in d_tags]
                break
    elif pg is not None:
        physical = self._parent.physical
        resolved = []
        for d in range(4):
            pg_tag = physical.get_tag(d, pg)
            if pg_tag is None:
                continue
            resolved.extend(
                (d, int(t)) for t in physical.get_entities(d, pg_tag)
            )
        if not resolved:
            raise KeyError(f"No physical group named {pg!r}.")
        pg_dims = {d for d, _ in resolved}
        if len(pg_dims) > 1:
            raise ValueError(
                f"Physical group {pg!r} exists at multiple "
                f"dimensions {sorted(pg_dims)}. Multi-dimensional "
                f"physical groups are not supported."
            )
    else:
        resolved = [(int(d), int(t)) for d, t in dimtags]

    if name in self._instances:
        raise ValueError(f"Part label '{name}' already exists.")

    # Ownership check — each entity can belong to at most one part
    for d, t in resolved:
        for existing_label, existing_inst in self._instances.items():
            if t in existing_inst.entities.get(d, []):
                raise ValueError(
                    f"Entity (dim={d}, tag={t}) already belongs to "
                    f"part '{existing_label}'. Remove it first."
                )

    entities: dict[int, list[int]] = {}
    for d, t in resolved:
        entities.setdefault(d, []).append(t)

    inst = Instance(
        label=name,
        part_name=name,
        entities=entities,
        bbox=self._compute_bbox(resolved) if resolved else None,
    )
    self._register_instance(inst)
    return inst

from_model

from_model(label: str, *, dim: int | None = None, tags: list[int] | None = None) -> Instance

Adopt entities already in the Gmsh session as a named part.

Useful after g.model.io.load_step() or g.model.io.load_iges() when you want the imported geometry tracked for constraints and fragmentation.

Parameters

label : str Part name. dim : int, optional Dimension to adopt. If None, adopts all dimensions. tags : list[int], optional Specific entity tags to adopt. If None, adopts all untracked entities (not already assigned to a part).

Returns

Instance

Examples

::

# Load geometry, then adopt it
g.model.io.load_step("bracket.step")
g.parts.from_model("bracket")

# Adopt only specific volumes
g.parts.from_model("slab", dim=3, tags=[1, 2])
Source code in src/apeGmsh/core/_parts_registry.py
def from_model(
    self,
    label: str,
    *,
    dim: int | None = None,
    tags: list[int] | None = None,
) -> Instance:
    """Adopt entities already in the Gmsh session as a named part.

    Useful after ``g.model.io.load_step()`` or ``g.model.io.load_iges()``
    when you want the imported geometry tracked for constraints
    and fragmentation.

    Parameters
    ----------
    label : str
        Part name.
    dim : int, optional
        Dimension to adopt.  If None, adopts all dimensions.
    tags : list[int], optional
        Specific entity tags to adopt.  If None, adopts all
        **untracked** entities (not already assigned to a part).

    Returns
    -------
    Instance

    Examples
    --------
    ::

        # Load geometry, then adopt it
        g.model.io.load_step("bracket.step")
        g.parts.from_model("bracket")

        # Adopt only specific volumes
        g.parts.from_model("slab", dim=3, tags=[1, 2])
    """
    if label in self._instances:
        raise ValueError(f"Part label '{label}' already exists.")

    # Collect already-tracked tags per dim
    tracked: dict[int, set[int]] = {}
    for inst in self._instances.values():
        for d, ts in inst.entities.items():
            tracked.setdefault(d, set()).update(ts)

    # Determine which dims to scan
    dims = [dim] if dim is not None else list(range(4))

    entities: dict[int, list[int]] = {}
    for d in dims:
        all_tags_d = [t for _, t in gmsh.model.getEntities(d)]
        if tags is not None:
            # User specified exact tags — use them
            adopted = [t for t in all_tags_d if t in tags]
        else:
            # Adopt untracked entities
            adopted = [t for t in all_tags_d if t not in tracked.get(d, set())]
        if adopted:
            entities[d] = sorted(adopted)

    if not entities:
        import warnings
        warnings.warn(
            f"No entities to adopt for part '{label}'.  "
            f"All entities are already tracked or the session is empty.",
            stacklevel=2,
        )

    dimtags = [(d, t) for d, ts in entities.items() for t in ts]
    inst = Instance(
        label=label,
        part_name=label,
        entities=entities,
        bbox=self._compute_bbox(dimtags) if dimtags else None,
    )
    self._register_instance(inst)
    return inst

add

add(part: 'Part', *, label: str | None = None, translate: tuple[float, float, float] = (0.0, 0.0, 0.0), rotate: tuple[float, ...] | None = None, highest_dim_only: bool = True) -> Instance

Import a saved Part into the session.

Parameters

part : Part Must have been save()-d to disk. label : str, optional Auto-generated as "{part.name}_1" if omitted. translate, rotate : placement transforms. highest_dim_only : keep only highest-dim entities from the CAD.

Source code in src/apeGmsh/core/_parts_registry.py
def add(
    self,
    part: "Part",
    *,
    label: str | None = None,
    translate: tuple[float, float, float] = (0.0, 0.0, 0.0),
    rotate: tuple[float, ...] | None = None,
    highest_dim_only: bool = True,
) -> Instance:
    """Import a saved Part into the session.

    Parameters
    ----------
    part : Part
        Must have been ``save()``-d to disk.
    label : str, optional
        Auto-generated as ``"{part.name}_1"`` if omitted.
    translate, rotate : placement transforms.
    highest_dim_only : keep only highest-dim entities from the CAD.
    """
    if not part.has_file:
        hint = (
            "Call part.save('file.step') explicitly"
            if not getattr(part, "_auto_persist", True)
            else
            "Exit the Part's `with` block (or call part.end()) "
            "before calling parts.add(part) so auto-persist can "
            "write the tempfile, OR call part.save('file.step') "
            "explicitly"
        )
        raise FileNotFoundError(
            f"Part '{part.name}' has no file to import.  {hint}."
        )
    if label is None:
        self._counter += 1
        label = f"{part.name}_{self._counter}"
    # part.has_file was checked above; this implies file_path
    # is not None. Narrow the type for mypy.
    assert part.file_path is not None
    return self._import_cad(
        file_path=part.file_path,
        label=label,
        part_name=part.name,
        translate=translate,
        rotate=rotate,
        highest_dim_only=highest_dim_only,
        properties=dict(part.properties),
    )

add_plane_wave_box

add_plane_wave_box(*, x: tuple[float, int], y: tuple[float, int], z, skin_thickness=None, center: tuple[float, float, float] = (0.0, 0.0, 0.0), rotation_z_deg: float = 0.0, name: str | None = None, names: dict[str, str] | None = None, apply_transfinite: bool = True)

Build a structured soil box wrapped by an ASDAbsorbingBoundary skin.

A plane-wave box is an axis-aligned structured soil box plus a one-element-thick absorbing offset shell on its five truncation faces (the local +Z top is the free surface and is never shelled). Soil + shell form one rectangular block; the shell is decomposed into face / vertical-edge / bottom-edge / bottom-corner regions, each tagged with its OpenSees btype. The companion bridge element (ASDAbsorbingBoundary3D) fans out one element per skin-region hex.

Built directly in the live session (no Part/STEP round-trip); pairs with — but does not use — :meth:add_DRM_box. See ADR 0054.

Parameters

x, y : (size, n_elements) Lateral soil extent (symmetric, centred) and element count. z : (depth, n_elements) | list[(depth, n_elements)] Vertical soil extent (downward, free surface at the top) and element count. Pass a top → bottom list of layers for a stratified column (e.g. z=[(15, 3), (25, 5)]); each layer gets its own soil + lateral skin PGs, so it can take its own absorbing material via ops.element.absorbing_boundary(materials=[m0, m1, …]) (ADR 0054 AB-1c). skin_thickness : float | (tx, ty, tz) | None Absorbing-skin thickness. None (default) matches the adjacent soil element size per face. A skin much thicker than the adjacent soil element warns (WarnAbsorbingSkinAspect) — it absorbs poorly. center : (cx, cy, cz) World location of the soil top-face centre (free surface). rotation_z_deg : float Must be 0 — the ASDAbsorbingBoundary3D element requires boundary-face normals along global X or Y, so a rotated absorbing box is rejected by the solver. name, names, apply_transfinite : PG-name prefix, per-PG override dict, and transfinite toggle — mirroring :meth:add_DRM_box.

Returns

AbsorbingSkinResult PG names (soil_pg, skin_pgs by btype, skin_all_pg, bottom_pgs, free_surface_pg), axes, and placement.

Example

::

res = g.parts.add_plane_wave_box(
    x=(605, 22), y=(605, 20), z=(420, 16),
)
g.mesh.generation.generate(dim=3)
# res.skin_pgs["L"], res.skin_all_pg, res.bottom_pgs ...
Source code in src/apeGmsh/core/_parts_registry.py
def add_plane_wave_box(
    self,
    *,
    x: tuple[float, int],
    y: tuple[float, int],
    z,
    skin_thickness=None,
    center: tuple[float, float, float] = (0.0, 0.0, 0.0),
    rotation_z_deg: float = 0.0,
    name: str | None = None,
    names: dict[str, str] | None = None,
    apply_transfinite: bool = True,
):
    """Build a structured soil box wrapped by an ASDAbsorbingBoundary skin.

    A *plane-wave box* is an axis-aligned structured soil box plus a
    one-element-thick absorbing **offset shell** on its five truncation
    faces (the local ``+Z`` top is the free surface and is never shelled).
    Soil + shell form one rectangular block; the shell is decomposed into
    face / vertical-edge / bottom-edge / bottom-corner regions, each tagged
    with its OpenSees ``btype``.  The companion bridge element
    (``ASDAbsorbingBoundary3D``) fans out one element per skin-region hex.

    Built directly in the live session (no Part/STEP round-trip); pairs with
    — but does not use — :meth:`add_DRM_box`.  See ADR 0054.

    Parameters
    ----------
    x, y : (size, n_elements)
        Lateral soil extent (symmetric, centred) and element count.
    z : (depth, n_elements) | list[(depth, n_elements)]
        Vertical soil extent (downward, free surface at the top) and element
        count.  Pass a top → bottom ``list`` of layers for a stratified column
        (e.g. ``z=[(15, 3), (25, 5)]``); each layer gets its own soil + lateral
        skin PGs, so it can take its own absorbing material via
        ``ops.element.absorbing_boundary(materials=[m0, m1, …])`` (ADR 0054 AB-1c).
    skin_thickness : float | (tx, ty, tz) | None
        Absorbing-skin thickness.  ``None`` (default) matches the adjacent
        soil element size per face.  A skin much thicker than the adjacent
        soil element warns (`WarnAbsorbingSkinAspect`) — it absorbs poorly.
    center : (cx, cy, cz)
        World location of the soil top-face centre (free surface).
    rotation_z_deg : float
        Must be ``0`` — the ASDAbsorbingBoundary3D element requires
        boundary-face normals along global X or Y, so a rotated absorbing
        box is rejected by the solver.
    name, names, apply_transfinite :
        PG-name prefix, per-PG override dict, and transfinite toggle —
        mirroring :meth:`add_DRM_box`.

    Returns
    -------
    AbsorbingSkinResult
        PG names (``soil_pg``, ``skin_pgs`` by btype, ``skin_all_pg``,
        ``bottom_pgs``, ``free_surface_pg``), ``axes``, and placement.

    Example
    -------
    ::

        res = g.parts.add_plane_wave_box(
            x=(605, 22), y=(605, 20), z=(420, 16),
        )
        g.mesh.generation.generate(dim=3)
        # res.skin_pgs["L"], res.skin_all_pg, res.bottom_pgs ...
    """
    from apeGmsh.parts.plane_wave_box import build_plane_wave_box

    return build_plane_wave_box(
        self._parent,
        x=x, y=y, z=z,
        skin_thickness=skin_thickness,
        center=center,
        rotation_z_deg=rotation_z_deg,
        name=name,
        names=names,
        apply_transfinite=apply_transfinite,
    )

add_DRM_box_from_h5drm

add_DRM_box_from_h5drm(*, h5drm: str, crd_scale: float = 1000.0, buffer: int = 0, absorbing: bool = False, name: str | None = None, names: dict[str, str] | None = None, apply_transfinite: bool = True)

Build a structured soil box matched to an .h5drm station grid.

Reads a ShakerMaker-style .h5drm DRM dataset and builds, in the live session, a single transfinite hex box whose nodes land EXACTLY on the dataset stations (so OpenSees' H5DRM node-matching is trivial), tags the soil volume + the six outer boundary faces (the dataset "b" shell) as physical groups, and returns the frame contract the matching ops.pattern.H5DRM(...) consumes — so the user never re-derives the km→m / centred / z-down handshake. See ADR 0066.

The dataset-keyed sibling of the parametric :meth:add_DRM_box (an SSI inner/transition/outer layout, NOT keyed to a dataset). Geometry + PGs only — assign the soil material and elements via the bridge (ops.nDMaterial + ops.element.stdBrick(pg=result.soil_pg)).

Parameters

h5drm : str Path to the .h5drm dataset (DRM_Data/{xyz,internal} + DRM_Metadata/drmbox_x0). The station grid must be a complete, uniform, isotropic regular grid. crd_scale : float Station-units → model-units scale. ShakerMaker stations are in km, FE models in m ⇒ default 1000.0. buffer : int Number of exterior soil layers to add OUTWARD on the four sides + the bottom (never the free surface), at the same grid spacing. 0 (default) builds just the inner DRM box. A free DRM box diverges (rigid-body null-space excited by the residual), so a real run needs a buffer + a far boundary: the buffer hexes carry only NON-dataset nodes, so H5DRM excludes them from the effective-force set (H5DRMLoadPattern.cpp:580). Apply the boundary on result.exterior_pgs via the bridge (ops.fix for the validated fixed far field). absorbing : bool When True (requires buffer >= 1), wrap the buffered box in a one-element ASD absorbing skin (btype-tagged ghost layer) on the sides + bottom — the production-SSI boundary (ADR 0054). The skin sits on the buffer's outer (NON-dataset) faces, so it never lands on the DRM b shell. result.skin is then an AbsorbingSkinResult ready for ops.element.absorbing_boundary(skin=result.skin, ...) + the staged s.activate_absorbing() flip. name, names, apply_transfinite : PG-name prefix, per-PG override dict, and transfinite toggle — mirroring :meth:add_DRM_box.

Returns

DRMBoxFromH5Result soil_pg, boundary_pgs (by face key), boundary_all_pg, free_surface_pg, exterior_pgs (sides+bottom), the frame contract (crd_scale / transform / x0 / center), and the grid descriptor (origin / spacing / counts).

Example

::

drm = g.parts.add_DRM_box_from_h5drm("motions.h5drm")
g.mesh.generation.generate(dim=3)
fem = g.mesh.queries.get_fem_data(dim=3)
ops = apeSees(fem)
soil = ops.nDMaterial.ElasticIsotropic(E=E, nu=nu, rho=rho)
ops.element.stdBrick(pg=drm.soil_pg, material=soil)
with ops.pattern.H5DRM(h5drm="motions.h5drm"):   # defaults match drm
    pass
Source code in src/apeGmsh/core/_parts_registry.py
def add_DRM_box_from_h5drm(
    self,
    *,
    h5drm: str,
    crd_scale: float = 1000.0,
    buffer: int = 0,
    absorbing: bool = False,
    name: str | None = None,
    names: dict[str, str] | None = None,
    apply_transfinite: bool = True,
):
    """Build a structured soil box matched to an ``.h5drm`` station grid.

    Reads a ShakerMaker-style ``.h5drm`` DRM dataset and builds, in the live
    session, a single transfinite hex box whose nodes land EXACTLY on the
    dataset stations (so OpenSees' H5DRM node-matching is trivial), tags the
    soil volume + the six outer boundary faces (the dataset "b" shell) as
    physical groups, and returns the **frame contract** the matching
    ``ops.pattern.H5DRM(...)`` consumes — so the user never re-derives the
    km→m / centred / z-down handshake.  See ADR 0066.

    The dataset-keyed sibling of the parametric :meth:`add_DRM_box` (an SSI
    inner/transition/outer layout, NOT keyed to a dataset).  Geometry + PGs
    only — assign the soil material and elements via the bridge
    (``ops.nDMaterial`` + ``ops.element.stdBrick(pg=result.soil_pg)``).

    Parameters
    ----------
    h5drm : str
        Path to the ``.h5drm`` dataset (``DRM_Data/{xyz,internal}`` +
        ``DRM_Metadata/drmbox_x0``).  The station grid must be a complete,
        uniform, isotropic regular grid.
    crd_scale : float
        Station-units → model-units scale.  ShakerMaker stations are in km,
        FE models in m ⇒ default ``1000.0``.
    buffer : int
        Number of exterior soil layers to add OUTWARD on the four sides + the
        bottom (never the free surface), at the same grid spacing.  ``0``
        (default) builds just the inner DRM box.  A free DRM box diverges
        (rigid-body null-space excited by the residual), so a real run needs a
        buffer + a far boundary: the buffer hexes carry only NON-dataset
        nodes, so H5DRM excludes them from the effective-force set
        (H5DRMLoadPattern.cpp:580).  Apply the boundary on
        ``result.exterior_pgs`` via the bridge (``ops.fix`` for the validated
        fixed far field).
    absorbing : bool
        When ``True`` (requires ``buffer >= 1``), wrap the buffered box in a
        one-element **ASD absorbing skin** (btype-tagged ghost layer) on the
        sides + bottom — the production-SSI boundary (ADR 0054).  The skin
        sits on the buffer's outer (NON-dataset) faces, so it never lands on
        the DRM ``b`` shell.  ``result.skin`` is then an ``AbsorbingSkinResult``
        ready for ``ops.element.absorbing_boundary(skin=result.skin, ...)`` +
        the staged ``s.activate_absorbing()`` flip.
    name, names, apply_transfinite :
        PG-name prefix, per-PG override dict, and transfinite toggle —
        mirroring :meth:`add_DRM_box`.

    Returns
    -------
    DRMBoxFromH5Result
        ``soil_pg``, ``boundary_pgs`` (by face key), ``boundary_all_pg``,
        ``free_surface_pg``, ``exterior_pgs`` (sides+bottom), the frame
        contract (``crd_scale`` / ``transform`` / ``x0`` / ``center``), and
        the grid descriptor (``origin`` / ``spacing`` / ``counts``).

    Example
    -------
    ::

        drm = g.parts.add_DRM_box_from_h5drm("motions.h5drm")
        g.mesh.generation.generate(dim=3)
        fem = g.mesh.queries.get_fem_data(dim=3)
        ops = apeSees(fem)
        soil = ops.nDMaterial.ElasticIsotropic(E=E, nu=nu, rho=rho)
        ops.element.stdBrick(pg=drm.soil_pg, material=soil)
        with ops.pattern.H5DRM(h5drm="motions.h5drm"):   # defaults match drm
            pass
    """
    from apeGmsh.parts.h5drm_box import build_drm_box_from_h5drm

    return build_drm_box_from_h5drm(
        self._parent,
        h5drm=h5drm,
        crd_scale=crd_scale,
        buffer=buffer,
        absorbing=absorbing,
        name=name,
        names=names,
        apply_transfinite=apply_transfinite,
    )

add_absorbing_shell

add_absorbing_shell(*, box, element_size, skin_thickness=None, faces: tuple[str, ...] | None = None, layers: list[tuple[float, int]] | None = None, name: str | None = None, names: dict[str, str] | None = None, apply_transfinite: bool = True)

Weld a one-element ASDAbsorbingBoundary skin onto your own soil box.

The bring-your-own-box counterpart to :meth:add_plane_wave_box: you build the soil box (its placement, PGs, the material/structure you later put on it), and this wraps a one-element-thick absorbing skin onto its five truncation faces — the local +Z top is the free surface and is never shelled. Returns the same :class:AbsorbingSkinResult as :meth:add_plane_wave_box, so the bridge element (ops.element.absorbing_boundary) and the staged flip (s.activate_absorbing) consume it identically. See ADR 0054 (AB-1b).

The skin discretization is size-based and (re)applied to box + skin together after the weld: gmsh cannot report transfinite counts back and the boolean fragment renumbers entities, so the box's prior mesh state is irrelevant — this call makes box + skin one structured hex region.

Parameters

box : The soil box — a PG / label name or a volume handle. Must resolve to exactly one axis-aligned rectangular volume (fail-loud otherwise; rotated / curved / multi-volume boxes are out of scope for this slice). element_size : float | (sx, sy, sz) Target soil element size; sets the structured node counts on box+skin. skin_thickness : float | (tx, ty, tz) | None Absorbing-skin thickness. None (default) matches element_size per axis (one element thick). faces : tuple[str, ...] | None Restrict the skin to a subset of ("L","R","F","K","B") (e.g. omit a symmetry plane). None (default) shells all five truncation faces. layers : list[(depth, n_elements)] | None Stratify the box top → bottom (depths must sum to the box's z-extent). Slices the box into per-layer soil volumes and splits the lateral skin per layer, so each layer can take its own absorbing material via ops.element.absorbing_boundary(materials=[m0, m1, …]) (ADR 0054 AB-1c). None (default) = homogeneous. name, names, apply_transfinite : PG-name prefix, per-PG override dict, and transfinite toggle — mirroring :meth:add_plane_wave_box. When box is a name, soil_pg is reported as that name (no duplicate PG is created).

Returns

AbsorbingSkinResult PG names (soil_pg, skin_pgs by btype, skin_all_pg, bottom_pgs, free_surface_pg), axes, and placement.

Example

::

g.model.geometry.add_box(0, 0, -40, 20, 20, 40, label="soil")
res = g.parts.add_absorbing_shell(box="soil", element_size=2.5)
g.mesh.generation.generate(dim=3)
# res.skin_all_pg, res.bottom_pgs, res.free_surface_pg ...
Source code in src/apeGmsh/core/_parts_registry.py
def add_absorbing_shell(
    self,
    *,
    box,
    element_size,
    skin_thickness=None,
    faces: tuple[str, ...] | None = None,
    layers: list[tuple[float, int]] | None = None,
    name: str | None = None,
    names: dict[str, str] | None = None,
    apply_transfinite: bool = True,
):
    """Weld a one-element ASDAbsorbingBoundary skin onto your own soil box.

    The *bring-your-own-box* counterpart to :meth:`add_plane_wave_box`: you
    build the soil box (its placement, PGs, the material/structure you later
    put on it), and this wraps a one-element-thick absorbing **skin** onto its
    five truncation faces — the local ``+Z`` top is the free surface and is
    never shelled.  Returns the *same* :class:`AbsorbingSkinResult` as
    :meth:`add_plane_wave_box`, so the bridge element
    (``ops.element.absorbing_boundary``) and the staged flip
    (``s.activate_absorbing``) consume it identically.  See ADR 0054 (AB-1b).

    The skin discretization is **size-based** and (re)applied to box + skin
    together after the weld: gmsh cannot report transfinite counts back and
    the boolean ``fragment`` renumbers entities, so the box's prior mesh state
    is irrelevant — this call makes box + skin one structured hex region.

    Parameters
    ----------
    box :
        The soil box — a PG / label name or a volume handle.  Must resolve to
        exactly **one axis-aligned rectangular** volume (fail-loud otherwise;
        rotated / curved / multi-volume boxes are out of scope for this slice).
    element_size : float | (sx, sy, sz)
        Target soil element size; sets the structured node counts on box+skin.
    skin_thickness : float | (tx, ty, tz) | None
        Absorbing-skin thickness.  ``None`` (default) matches ``element_size``
        per axis (one element thick).
    faces : tuple[str, ...] | None
        Restrict the skin to a subset of ``("L","R","F","K","B")`` (e.g. omit a
        symmetry plane).  ``None`` (default) shells all five truncation faces.
    layers : list[(depth, n_elements)] | None
        Stratify the box top → bottom (depths must sum to the box's z-extent).
        Slices the box into per-layer soil volumes and splits the lateral skin
        per layer, so each layer can take its own absorbing material via
        ``ops.element.absorbing_boundary(materials=[m0, m1, …])`` (ADR 0054
        AB-1c).  ``None`` (default) = homogeneous.
    name, names, apply_transfinite :
        PG-name prefix, per-PG override dict, and transfinite toggle — mirroring
        :meth:`add_plane_wave_box`.  When ``box`` is a name, ``soil_pg`` is
        reported as that name (no duplicate PG is created).

    Returns
    -------
    AbsorbingSkinResult
        PG names (``soil_pg``, ``skin_pgs`` by btype, ``skin_all_pg``,
        ``bottom_pgs``, ``free_surface_pg``), ``axes``, and placement.

    Example
    -------
    ::

        g.model.geometry.add_box(0, 0, -40, 20, 20, 40, label="soil")
        res = g.parts.add_absorbing_shell(box="soil", element_size=2.5)
        g.mesh.generation.generate(dim=3)
        # res.skin_all_pg, res.bottom_pgs, res.free_surface_pg ...
    """
    from apeGmsh.parts.plane_wave_box import build_absorbing_shell

    return build_absorbing_shell(
        self._parent,
        box=box,
        element_size=element_size,
        skin_thickness=skin_thickness,
        faces=faces,
        layers=layers,
        name=name,
        names=names,
        apply_transfinite=apply_transfinite,
    )

add_plane_wave_box_2d

add_plane_wave_box_2d(*, x: tuple[float, int], y, skin_thickness=None, center: tuple[float, float] = (0.0, 0.0), rotation_z_deg: float = 0.0, name: str | None = None, names: dict[str, str] | None = None, apply_transfinite: bool = True)

Build a 2D plane-strain soil box wrapped by an absorbing skin.

The 2D sibling of :meth:add_plane_wave_box (ADR 0054, AB-5): a structured soil rectangle in the global X–Y plane at z = 0 (X lateral, Y vertical, free surface at the local top y = 0) plus a one-element-thick absorbing skin on its three truncation faces. Skin regions carry the 2D btypes — L = min-X, R = max-X, B = min-Y, corners BL/BR — and fan out to ASDAbsorbingBoundary2D quads via ops.element.absorbing_boundary(skin=…, thickness=…) (the 2D element needs the out-of-plane slab thickness).

Parameters

x : (size, n_elements) Lateral soil extent (symmetric, centred) and element count. y : (depth, n_elements) | list[(depth, n_elements)] Vertical soil extent (downward, free surface at the top). Pass a top → bottom list of layers for a stratified column; each layer gets its own soil + lateral skin PGs for per-layer absorbing materials (materials=[…]). skin_thickness : float | (tx, ty) | None Absorbing-skin thickness. None (default) matches the adjacent soil element size per face. center : (cx, cy) World location of the soil top-face centre (free surface). rotation_z_deg : float Must be 0 — the ASDAbsorbingBoundary2D element has no distortion handling (it sizes itself from sorted nodal x/y coordinates), so a rotated skin runs with silently wrong terms. name, names, apply_transfinite : PG-name prefix, per-PG override dict, and transfinite toggle.

Returns

AbsorbingSkinResult Same shape as the 3D result (ndm == 2; free_surface_pg is a dim-1 edge PG).

Example

::

res = g.parts.add_plane_wave_box_2d(x=(100, 20), y=(50, 10))
g.mesh.generation.generate(dim=2)
# res.skin_pgs -> {"B": ..., "L": ..., "R": ..., "BL": ..., "BR": ...}
Source code in src/apeGmsh/core/_parts_registry.py
def add_plane_wave_box_2d(
    self,
    *,
    x: tuple[float, int],
    y,
    skin_thickness=None,
    center: tuple[float, float] = (0.0, 0.0),
    rotation_z_deg: float = 0.0,
    name: str | None = None,
    names: dict[str, str] | None = None,
    apply_transfinite: bool = True,
):
    """Build a 2D plane-strain soil box wrapped by an absorbing skin.

    The 2D sibling of :meth:`add_plane_wave_box` (ADR 0054, AB-5): a
    structured soil rectangle in the global **X–Y plane at z = 0** (X
    lateral, Y vertical, free surface at the local top ``y = 0``) plus a
    one-element-thick absorbing skin on its three truncation faces.
    Skin regions carry the 2D btypes — ``L`` = min-X, ``R`` = max-X,
    ``B`` = min-Y, corners ``BL``/``BR`` — and fan out to
    ``ASDAbsorbingBoundary2D`` quads via
    ``ops.element.absorbing_boundary(skin=…, thickness=…)`` (the 2D
    element needs the out-of-plane slab thickness).

    Parameters
    ----------
    x : (size, n_elements)
        Lateral soil extent (symmetric, centred) and element count.
    y : (depth, n_elements) | list[(depth, n_elements)]
        Vertical soil extent (downward, free surface at the top).  Pass a
        top → bottom ``list`` of layers for a stratified column; each
        layer gets its own soil + lateral skin PGs for per-layer
        absorbing materials (``materials=[…]``).
    skin_thickness : float | (tx, ty) | None
        Absorbing-skin thickness.  ``None`` (default) matches the
        adjacent soil element size per face.
    center : (cx, cy)
        World location of the soil top-face centre (free surface).
    rotation_z_deg : float
        Must be ``0`` — the ASDAbsorbingBoundary2D element has **no**
        distortion handling (it sizes itself from sorted nodal x/y
        coordinates), so a rotated skin runs with silently wrong terms.
    name, names, apply_transfinite :
        PG-name prefix, per-PG override dict, and transfinite toggle.

    Returns
    -------
    AbsorbingSkinResult
        Same shape as the 3D result (``ndm == 2``; ``free_surface_pg``
        is a dim-1 edge PG).

    Example
    -------
    ::

        res = g.parts.add_plane_wave_box_2d(x=(100, 20), y=(50, 10))
        g.mesh.generation.generate(dim=2)
        # res.skin_pgs -> {"B": ..., "L": ..., "R": ..., "BL": ..., "BR": ...}
    """
    from apeGmsh.parts.plane_wave_box import build_plane_wave_box_2d

    return build_plane_wave_box_2d(
        self._parent,
        x=x, y=y,
        skin_thickness=skin_thickness,
        center=center,
        rotation_z_deg=rotation_z_deg,
        name=name,
        names=names,
        apply_transfinite=apply_transfinite,
    )

add_absorbing_shell_2d

add_absorbing_shell_2d(*, box, element_size, skin_thickness=None, faces: tuple[str, ...] | None = None, layers: list[tuple[float, int]] | None = None, name: str | None = None, names: dict[str, str] | None = None, apply_transfinite: bool = True)

Weld a one-element absorbing skin onto your own 2D soil rectangle.

The bring-your-own-box 2D entry (ADR 0054, AB-5), mirroring :meth:add_absorbing_shell: box must resolve to exactly one axis-aligned rectangular surface lying flat in a z = const plane. The skin goes on the L/R/B truncation edges (the top is the free surface); faces= restricts it (subset of ("L", "R", "B"), e.g. drop a symmetry edge). layers stratifies box + lateral skin top → bottom (depths must sum to the box's y-extent). Discretization is size-based and (re)applied to box + skin together after the weld, as in 3D.

Returns

AbsorbingSkinResult Same shape as the 3D result (ndm == 2).

Example

::

g.model.geometry.add_rectangle(0, -50, 0, 100, 50, label="soil")
res = g.parts.add_absorbing_shell_2d(box="soil", element_size=5.0)
g.mesh.generation.generate(dim=2)
Source code in src/apeGmsh/core/_parts_registry.py
def add_absorbing_shell_2d(
    self,
    *,
    box,
    element_size,
    skin_thickness=None,
    faces: tuple[str, ...] | None = None,
    layers: list[tuple[float, int]] | None = None,
    name: str | None = None,
    names: dict[str, str] | None = None,
    apply_transfinite: bool = True,
):
    """Weld a one-element absorbing skin onto your own 2D soil rectangle.

    The bring-your-own-box 2D entry (ADR 0054, AB-5), mirroring
    :meth:`add_absorbing_shell`: ``box`` must resolve to exactly one
    axis-aligned rectangular **surface** lying flat in a ``z = const``
    plane.  The skin goes on the ``L``/``R``/``B`` truncation edges (the
    top is the free surface); ``faces=`` restricts it (subset of
    ``("L", "R", "B")``, e.g. drop a symmetry edge).  ``layers``
    stratifies box + lateral skin top → bottom (depths must sum to the
    box's y-extent).  Discretization is size-based and (re)applied to
    box + skin together after the weld, as in 3D.

    Returns
    -------
    AbsorbingSkinResult
        Same shape as the 3D result (``ndm == 2``).

    Example
    -------
    ::

        g.model.geometry.add_rectangle(0, -50, 0, 100, 50, label="soil")
        res = g.parts.add_absorbing_shell_2d(box="soil", element_size=5.0)
        g.mesh.generation.generate(dim=2)
    """
    from apeGmsh.parts.plane_wave_box import build_absorbing_shell_2d

    return build_absorbing_shell_2d(
        self._parent,
        box=box,
        element_size=element_size,
        skin_thickness=skin_thickness,
        faces=faces,
        layers=layers,
        name=name,
        names=names,
        apply_transfinite=apply_transfinite,
    )

add_DRM_box

add_DRM_box(*, x_inner: tuple[float, int], x_layer: tuple[float, int], x_outer: tuple[float, int], y_inner: tuple[float, int], y_layer: tuple[float, int], y_outer: tuple[float, int], z_top: tuple[float, int], z_mid: tuple[float, int], z_bottom: tuple[float, int], center: tuple[float, float, float] = (0.0, 0.0, 0.0), rotation_z_deg: float = 0.0, name: str | None = None, names: dict[str, str] | None = None, apply_transfinite: bool = True, tag_line_pgs: bool = True)

Build, place, and tag a Domain-Reduction-Method soil box.

A DRM box is a layered solid with three concentric regions per lateral axis (inner core | transition layer | outer absorbing layer) and a downward Z stack (top | mid | bottom). The classic symmetric case has 5 * 5 * 3 = 75 axis-aligned hex sub-volumes, each meshed structured-hex with per-region element counts.

center=(0, 0, 0) puts the top-face centre of the inner box at the origin (free-surface convention). Rotation is applied CCW about +Z at center; the rotated frame survives every step (volume PGs, line PGs, transfinite cascade) because we classify by world-coords transformed back to the local frame.

Parameters

x_inner, x_layer, x_outer, y_inner, y_layer, y_outer : (size, n_elements) tuples — symmetric layered lateral axes. Each segment's element count drives the transfinite cascade. z_top, z_mid, z_bottom : (size, n_elements) tuples — downward Z stack with the free surface at z = 0 (inner-box top). center : World-coordinate location for the top-face centre of the inner box. rotation_z_deg : CCW rotation about +Z applied at center, in degrees. name : Instance label and default PG prefix. When None, uses "drm_box". PGs default to inner_box / transition_box / outer_box (and the matching lines_* curves); when name is given they become {name}_inner_box etc. names : Per-PG override dict. Keys: inner_pg, transition_pg, outer_pg, line_pg_<region>_<axis> (e.g. line_pg_inner_x, line_pg_top_z). Each override replaces the entire PG name (the name prefix is ignored for that key). apply_transfinite : When True (default), apply the structured-hex transfinite cascade to every sub-volume using the per-region element counts in axis_x / axis_y / axis_z. tag_line_pgs : When True (default), tag axis-parallel edges by region into curve PGs lines_{region}_{axis}. When False, result.line_pgs is empty.

Returns

DRMBoxResult Frozen summary with PG names, Axis1D descriptors, the applied center and rotation_z (in radians).

Example

::

res = g.parts.add_DRM_box(
    x_inner=(605, 10), x_layer=(10, 1), x_outer=(20, 2),
    y_inner=(605, 10), y_layer=(10, 1), y_outer=(20, 2),
    z_top=(50, 5), z_mid=(50, 5), z_bottom=(200, 20),
    center=(0, 0, 0),
)
g.mesh.generation.generate(dim=3)
# res.inner_pg == "inner_box", res.transition_pg == "transition_box",
# res.outer_pg == "outer_box"
Source code in src/apeGmsh/core/_parts_registry.py
def add_DRM_box(
    self,
    *,
    x_inner: tuple[float, int],
    x_layer: tuple[float, int],
    x_outer: tuple[float, int],
    y_inner: tuple[float, int],
    y_layer: tuple[float, int],
    y_outer: tuple[float, int],
    z_top: tuple[float, int],
    z_mid: tuple[float, int],
    z_bottom: tuple[float, int],
    center: tuple[float, float, float] = (0.0, 0.0, 0.0),
    rotation_z_deg: float = 0.0,
    name: str | None = None,
    names: dict[str, str] | None = None,
    apply_transfinite: bool = True,
    tag_line_pgs: bool = True,
):
    """Build, place, and tag a Domain-Reduction-Method soil box.

    A DRM box is a layered solid with three concentric regions
    per lateral axis (inner core | transition layer | outer
    absorbing layer) and a downward Z stack (top | mid | bottom).
    The classic symmetric case has ``5 * 5 * 3 = 75`` axis-aligned
    hex sub-volumes, each meshed structured-hex with per-region
    element counts.

    ``center=(0, 0, 0)`` puts the top-face centre of the inner
    box at the origin (free-surface convention).  Rotation is
    applied CCW about ``+Z`` at ``center``; the rotated frame
    survives every step (volume PGs, line PGs, transfinite
    cascade) because we classify by world-coords transformed
    back to the local frame.

    Parameters
    ----------
    x_inner, x_layer, x_outer, y_inner, y_layer, y_outer :
        ``(size, n_elements)`` tuples — symmetric layered lateral
        axes.  Each segment's element count drives the
        transfinite cascade.
    z_top, z_mid, z_bottom :
        ``(size, n_elements)`` tuples — downward Z stack with the
        free surface at ``z = 0`` (inner-box top).
    center :
        World-coordinate location for the top-face centre of the
        inner box.
    rotation_z_deg :
        CCW rotation about ``+Z`` applied at ``center``, in
        degrees.
    name :
        Instance label and default PG prefix.  When ``None``,
        uses ``"drm_box"``.  PGs default to ``inner_box`` /
        ``transition_box`` / ``outer_box`` (and the matching
        ``lines_*`` curves); when ``name`` is given they become
        ``{name}_inner_box`` etc.
    names :
        Per-PG override dict.  Keys: ``inner_pg``, ``transition_pg``,
        ``outer_pg``, ``line_pg_<region>_<axis>`` (e.g.
        ``line_pg_inner_x``, ``line_pg_top_z``).  Each override
        replaces the entire PG name (the ``name`` prefix is
        ignored for that key).
    apply_transfinite :
        When True (default), apply the structured-hex transfinite
        cascade to every sub-volume using the per-region element
        counts in ``axis_x`` / ``axis_y`` / ``axis_z``.
    tag_line_pgs :
        When True (default), tag axis-parallel edges by region
        into curve PGs ``lines_{region}_{axis}``.  When False,
        ``result.line_pgs`` is empty.

    Returns
    -------
    DRMBoxResult
        Frozen summary with PG names, Axis1D descriptors, the
        applied ``center`` and ``rotation_z`` (in radians).

    Example
    -------
    ::

        res = g.parts.add_DRM_box(
            x_inner=(605, 10), x_layer=(10, 1), x_outer=(20, 2),
            y_inner=(605, 10), y_layer=(10, 1), y_outer=(20, 2),
            z_top=(50, 5), z_mid=(50, 5), z_bottom=(200, 20),
            center=(0, 0, 0),
        )
        g.mesh.generation.generate(dim=3)
        # res.inner_pg == "inner_box", res.transition_pg == "transition_box",
        # res.outer_pg == "outer_box"
    """
    import math
    import numpy as np

    from apeGmsh.parts.drm_box import DRMBox, DRMBoxResult

    instance_label = name or "drm_box"

    # Resolve PG names — ``name`` acts as a prefix unless the user
    # supplied ``names`` overrides.  When ``name is None`` we keep
    # the bare defaults so the simple case stays terse.
    prefix = f"{name}_" if name else ""
    pg_defaults = {
        "inner_pg":      f"{prefix}inner_box",
        "transition_pg": f"{prefix}transition_box",
        "outer_pg":      f"{prefix}outer_box",
    }
    line_pg_defaults: dict[str, str] = {}
    # Lateral axes carry 3 regions; Z carries 3 regions of its
    # own.  The dict keys mirror the spec example
    # ``{'inner_x', 'layer_x', 'top_z'}``.
    for region in ("inner", "layer", "outer"):
        for axis in ("x", "y"):
            line_pg_defaults[f"{region}_{axis}"] = (
                f"{prefix}lines_{region}_{axis}"
            )
    for region in ("top", "mid", "bottom"):
        line_pg_defaults[f"{region}_z"] = (
            f"{prefix}lines_{region}_z"
        )

    overrides = dict(names or {})
    for k, default in pg_defaults.items():
        if k in overrides:
            pg_defaults[k] = str(overrides[k])
    # Line-PG override keys look like ``line_pg_inner_x``.
    for key in list(line_pg_defaults):
        override_key = f"line_pg_{key}"
        if override_key in overrides:
            line_pg_defaults[key] = str(overrides[override_key])

    # ── Build the DRM-box Part in its own session ────────────────
    # ``Part.begin()`` calls ``gmsh.model.add(part.name)``, which
    # makes the Part's model the current gmsh model.  The Part's
    # ``end()`` decrements the gmsh refcount but does NOT switch
    # the current model back, so without an explicit
    # ``setCurrent`` here ``self.add(drm)`` would importShapes into
    # the Part's model (doubling the volume count in the live
    # session).  Snapshot the assembly's model name first and
    # restore it after the Part's ``with`` block exits.
    assembly_model_name = self._parent.name
    drm = DRMBox(
        x_inner=x_inner, x_layer=x_layer, x_outer=x_outer,
        y_inner=y_inner, y_layer=y_layer, y_outer=y_outer,
        z_top=z_top, z_mid=z_mid, z_bottom=z_bottom,
        name=f"_drm_part_{instance_label}",
    )
    with drm:
        drm.build()
    gmsh.model.setCurrent(assembly_model_name)
    # ``drm`` now has an auto-persisted STEP tempfile.

    theta = math.radians(float(rotation_z_deg))
    rotate_arg: tuple[float, ...] | None
    if abs(theta) > 1e-15:
        # OCC rotate at world origin — then translate.  This
        # matches ``_apply_transforms``: rotate first about
        # axis through (0, 0, 0), then translate by ``center``.
        rotate_arg = (theta, 0.0, 0.0, 1.0)
    else:
        rotate_arg = None

    inst = self.add(
        drm,
        label=instance_label,
        translate=center,
        rotate=rotate_arg,
    )

    # Release the Part's tempfile — we've imported the geometry
    # and no longer need the on-disk STEP.  ``drm`` is otherwise
    # garbage-collected at function exit, but cleanup() here
    # avoids waiting for GC.
    drm.cleanup()

    # ── Classify each sub-volume in the local frame ─────────────
    cx, cy, cz = (float(v) for v in center)
    cos_t, sin_t = math.cos(theta), math.sin(theta)

    def to_local(world_xyz):
        """Inverse of: rotate CCW about +Z at origin, then translate by center."""
        wx, wy, wz = world_xyz
        # subtract translation
        dx, dy, dz = wx - cx, wy - cy, wz - cz
        # inverse rotation
        lx = cos_t * dx + sin_t * dy
        ly = -sin_t * dx + cos_t * dy
        lz = dz
        return lx, ly, lz

    # Volume-PG classifier — matches the canonical DRM layout
    # the user's notebook expressed via in_box selection:
    #
    #   * ``inner_box`` = the single inner-inner-top sub-volume
    #     (the geometric "inner box" where the embedded structure
    #     lives).
    #   * ``transition_box`` = the layer-bounded AABB
    #     ``[-x_LL,+x_LL] x [-y_LL,+y_LL] x [-(z_top+z_mid), 0]``
    #     minus the inner box.  i.e. sub-vols whose lateral region
    #     is ``inner`` or ``layer`` AND whose Z region is ``top`` or
    #     ``mid``, with the single ``inner`` cell carved out.
    #   * ``outer_box`` = everything else — the absorbing region,
    #     including the inner-inner-mid / inner-inner-bottom
    #     sub-vols below the structure (per the user's geometric
    #     AABB rule, those z layers are not inside the transition
    #     shell).
    inner_vols: list[int] = []
    transition_vols: list[int] = []
    outer_vols: list[int] = []
    # ``per_class_counts`` ⇒ list of (vol_tag, nx, ny, nz)
    per_vol_counts: list[tuple[int, int, int, int]] = []

    for vtag in inst.entities.get(3, []):
        com_world = self._parent.model.queries.center_of_mass(
            int(vtag), dim=3,
        )
        lx, ly, lz = to_local(com_world)
        rx = drm.axis_x.region_of(lx)
        ry = drm.axis_y.region_of(ly)
        rz = drm.axis_z.region_of(lz)
        nx = drm.axis_x.count_for(lx)
        ny = drm.axis_y.count_for(ly)
        nz = drm.axis_z.count_for(lz)
        per_vol_counts.append((int(vtag), nx, ny, nz))

        is_inner = (rx == "inner" and ry == "inner" and rz == "top")
        inside_transition_bbox = (
            rx in ("inner", "layer")
            and ry in ("inner", "layer")
            and rz in ("top", "mid")
        )
        if is_inner:
            inner_vols.append(int(vtag))
        elif inside_transition_bbox:
            transition_vols.append(int(vtag))
        else:
            outer_vols.append(int(vtag))

    physical = self._parent.physical
    if inner_vols:
        physical.add(3, inner_vols, name=pg_defaults["inner_pg"])
    if transition_vols:
        physical.add(3, transition_vols, name=pg_defaults["transition_pg"])
    if outer_vols:
        physical.add(3, outer_vols, name=pg_defaults["outer_pg"])

    # ── Optional: transfinite cascade per sub-volume ────────────
    if apply_transfinite:
        structured = self._parent.mesh.structured
        for vtag, nx, ny, nz in per_vol_counts:
            # Tuple form ``n=(nx, ny, nz)`` orders by principal
            # axis (closest-global-axis), so it is rotation-safe
            # by construction — the dict form would require
            # global-axis-aligned edges and raise here.  Axis1D
            # stores element counts; ``set_transfinite`` takes
            # node counts (``n_nodes - 1`` elements per curve).
            structured.set_transfinite(
                (3, vtag),
                n=(nx + 1, ny + 1, nz + 1),
                recombine=True,
            )

    # ── Optional: line PGs per (region, axis) ───────────────────
    line_pgs_out: dict[str, str] = {}
    if tag_line_pgs:
        from apeGmsh.parts.drm_box import classify_drm_box_lines

        all_curves = [int(t) for _d, t in gmsh.model.getEntities(1)]
        classified = classify_drm_box_lines(
            axis_x=drm.axis_x,
            axis_y=drm.axis_y,
            axis_z=drm.axis_z,
            center=(cx, cy, cz),
            rotation_z=theta,
            line_pg_names=line_pg_defaults,
            curve_tags=all_curves,
        )
        # Invert line_pg_defaults so we can pair back to region keys
        # for the result's ``line_pgs`` dict.
        name_to_key = {v: k for k, v in line_pg_defaults.items()}
        for pg_name, edge_tags in classified.items():
            physical.add(1, edge_tags, name=pg_name)
            line_pgs_out[name_to_key[pg_name]] = pg_name

    # ── Stash rebuild-required state on the Instance ────────────
    # The line-PG classifier is a pure function of (axes, center,
    # rotation, curve_tags), so a future boolean that mutates the
    # box can drop the stale PGs and replay the classifier against
    # the post-cut curves.  We persist: the axis construction
    # params (so Axis1D can be rebuilt), the line-PG name map
    # (so we know which PGs are owned by this Part), the center,
    # and rotation_z.  See ``rebuild_drm_box_line_pgs`` in
    # ``apeGmsh.parts.drm_box``.
    inst.properties.setdefault("drm_box", {}).update({
        "line_pgs": dict(line_pgs_out),
        "center": (cx, cy, cz),
        "rotation_z": float(theta),
    })

    return DRMBoxResult(
        inner_pg=pg_defaults["inner_pg"],
        transition_pg=pg_defaults["transition_pg"],
        outer_pg=pg_defaults["outer_pg"],
        line_pgs=line_pgs_out,
        axes={
            "x": drm.axis_x,
            "y": drm.axis_y,
            "z": drm.axis_z,
        },
        center=(cx, cy, cz),
        rotation_z=float(theta),
    )

import_step

import_step(file_path: str | Path, *, label: str | None = None, translate: tuple[float, float, float] = (0.0, 0.0, 0.0), rotate: tuple[float, ...] | None = None, highest_dim_only: bool = True, heal: bool | float | str = False, dedupe: bool | float = False, properties: dict[str, Any] | None = None) -> Instance

Import a STEP or IGES file as a named instance.

Parameters

file_path : path STEP (.step, .stp) or IGES (.iges, .igs) file. label : str, optional Auto-generated from file stem if omitted. translate, rotate : placement transforms. heal : bool, float, or "auto" Heal the imported CAD immediately after import — same semantics as :meth:g.model.io.load_step <_IO.load_step>: True / "auto" use a scale-aware tolerance, a float overrides, False (default) imports raw and emits a :class:WarnGeomImportHealth advisory if slivers are found. Best-effort for sidecar-carrying parts (healing renumbers, so anchors rebind against the healed geometry). dedupe : bool or float Merge coincident entities after import (and after heal). properties : arbitrary metadata.

Source code in src/apeGmsh/core/_parts_registry.py
def import_step(
    self,
    file_path: str | Path,
    *,
    label: str | None = None,
    translate: tuple[float, float, float] = (0.0, 0.0, 0.0),
    rotate: tuple[float, ...] | None = None,
    highest_dim_only: bool = True,
    heal: bool | float | str = False,
    dedupe: bool | float = False,
    properties: dict[str, Any] | None = None,
) -> Instance:
    """Import a STEP or IGES file as a named instance.

    Parameters
    ----------
    file_path : path
        STEP (.step, .stp) or IGES (.iges, .igs) file.
    label : str, optional
        Auto-generated from file stem if omitted.
    translate, rotate : placement transforms.
    heal : bool, float, or "auto"
        Heal the imported CAD immediately after import — same
        semantics as :meth:`g.model.io.load_step <_IO.load_step>`:
        ``True`` / ``"auto"`` use a scale-aware tolerance, a float
        overrides, ``False`` (default) imports raw and emits a
        :class:`WarnGeomImportHealth` advisory if slivers are found.
        Best-effort for sidecar-carrying parts (healing renumbers,
        so anchors rebind against the healed geometry).
    dedupe : bool or float
        Merge coincident entities after import (and after heal).
    properties : arbitrary metadata.
    """
    file_path = Path(file_path)
    if not file_path.exists():
        raise FileNotFoundError(f"CAD file not found: {file_path}")
    if label is None:
        self._counter += 1
        label = f"{file_path.stem}_{self._counter}"
    return self._import_cad(
        file_path=file_path,
        label=label,
        part_name=file_path.stem,
        translate=translate,
        rotate=rotate,
        highest_dim_only=highest_dim_only,
        heal=heal,
        dedupe=dedupe,
        properties=properties or {},
    )

build_node_map

build_node_map(node_tags: ndarray, node_coords: ndarray) -> dict[str, set[int]]

Partition mesh nodes by instance bounding box.

Returns {label: {node_tag, ...}}.

Source code in src/apeGmsh/core/_parts_registry.py
def build_node_map(
    self,
    node_tags: np.ndarray,
    node_coords: np.ndarray,
) -> dict[str, set[int]]:
    """Partition mesh nodes by instance bounding box.

    Returns ``{label: {node_tag, ...}}``.
    """
    tags = np.asarray(node_tags)
    coords = np.asarray(node_coords).reshape(-1, 3)
    return {
        label: self._nodes_in_bbox(tags, coords, inst.bbox)
        for label, inst in self._instances.items()
    }

build_face_map

build_face_map(node_map: dict[str, set[int]]) -> dict[str, np.ndarray]

Partition surface elements by instance node ownership.

Returns {label: face_connectivity_array}.

Source code in src/apeGmsh/core/_parts_registry.py
def build_face_map(
    self,
    node_map: dict[str, set[int]],
) -> dict[str, np.ndarray]:
    """Partition surface elements by instance node ownership.

    Returns ``{label: face_connectivity_array}``.
    """
    from ._compose_errors import raise_if_no_live_kernel
    raise_if_no_live_kernel(
        self._parent, "g.parts.build_face_map()",
        alternative=(
            "fem.elements, where fem = g.mesh.queries.get_fem_data() "
            "— the surface connectivity this partitions is already "
            "in the broker"
        ),
    )
    faces = self._collect_surface_faces()
    if faces.size == 0:
        return {label: np.empty((0, 0), dtype=int)
                for label in self._instances}

    out: dict[str, np.ndarray] = {}
    for label, nodes in node_map.items():
        if not nodes:
            out[label] = np.empty((0, faces.shape[1]), dtype=int)
            continue
        mask = np.all(np.isin(faces, list(nodes)), axis=1)
        out[label] = faces[mask]
    return out

get

get(label: str) -> Instance

Return the Instance registered under label.

Useful when you didn't store the return value of :meth:add / :meth:import_step and want to access an Instance later — e.g. to apply inst.edit.* transforms::

g.parts.add(beam, label="b1")
g.parts.get("b1").edit.translate(0, 0, 50)
Raises

KeyError If no instance is registered under label. The error message lists the available labels so you can spot a typo.

Source code in src/apeGmsh/core/_parts_registry.py
def get(self, label: str) -> Instance:
    """Return the Instance registered under ``label``.

    Useful when you didn't store the return value of
    :meth:`add` / :meth:`import_step` and want to access an
    Instance later — e.g. to apply ``inst.edit.*`` transforms::

        g.parts.add(beam, label="b1")
        g.parts.get("b1").edit.translate(0, 0, 50)

    Raises
    ------
    KeyError
        If no instance is registered under ``label``.  The error
        message lists the available labels so you can spot a typo.
    """
    if label not in self._instances:
        available = sorted(self._instances)
        raise KeyError(
            f"No instance labeled {label!r}.  "
            f"Available: {available}"
        )
    return self._instances[label]

labels

labels() -> list[str]

Return all instance labels in insertion order.

Source code in src/apeGmsh/core/_parts_registry.py
def labels(self) -> list[str]:
    """Return all instance labels in insertion order."""
    return list(self._instances.keys())

rename

rename(old_label: str, new_label: str) -> None

Rename an instance.

Raises

KeyError if old_label does not exist. ValueError if new_label already exists.

Source code in src/apeGmsh/core/_parts_registry.py
def rename(self, old_label: str, new_label: str) -> None:
    """Rename an instance.

    Raises
    ------
    KeyError   if *old_label* does not exist.
    ValueError if *new_label* already exists.
    """
    if old_label not in self._instances:
        raise KeyError(f"No part '{old_label}'.")
    if new_label in self._instances:
        raise ValueError(f"Part '{new_label}' already exists.")
    inst = self._instances.pop(old_label)
    inst.label = new_label
    self._instances[new_label] = inst

delete

delete(label: str) -> None

Remove an instance from the registry.

The entities remain in the Gmsh session — they become "untracked" and will appear under the Untracked group in the viewer's Parts tab.

Raises

KeyError if label does not exist.

Source code in src/apeGmsh/core/_parts_registry.py
def delete(self, label: str) -> None:
    """Remove an instance from the registry.

    The entities remain in the Gmsh session — they become
    "untracked" and will appear under the Untracked group
    in the viewer's Parts tab.

    Raises
    ------
    KeyError if *label* does not exist.
    """
    if label not in self._instances:
        raise KeyError(f"No part '{label}'.")
    self._instances.pop(label)

Instance

apeGmsh.core._parts_registry.Instance dataclass

Instance(label: str, part_name: str, file_path: Path | None = None, entities: dict[int, list[int]] = dict(), translate: tuple[float, float, float] = (0.0, 0.0, 0.0), rotate: tuple[float, ...] | None = None, properties: dict[str, Any] = dict(), bbox: tuple[float, float, float, float, float, float] | None = None, label_names: list[str] = list())

Bookkeeping record for one part placement.

Attributes

label : unique name inside the session part_name : name of the source Part or file stem file_path : CAD file that was imported (None for inline parts) entities : {dim: [tag, ...]} — updated in-place by fragment translate : applied translation (dx, dy, dz) rotate : applied rotation (angle_rad, ax, ay, az[, cx, cy, cz]) properties : arbitrary user metadata bbox : axis-aligned bounding box (xmin, ymin, zmin, xmax, ymax, zmax) label_names : label names created for this instance (Tier 1 naming, e.g. ["col_A.shaft", "col_A.top"]). Populated by _import_cad when the Part's CAD file has a .apegmsh.json sidecar carrying label definitions. These are NOT solver-facing physical groups — use g.labels.entities(name) to resolve entity tags, and g.labels.promote_to_physical(name) to create a solver PG when ready.

Part edit composite — part.edit

apeGmsh.core._part_edit.PartEdit

PartEdit(part: 'Part')

Whole-Part operations composite. Registered as part.edit.

Source code in src/apeGmsh/core/_part_edit.py
def __init__(self, part: "Part") -> None:
    self._part = part

translate

translate(dx: float, dy: float, dz: float) -> 'PartEdit'

Translate every entity in the Part by (dx, dy, dz).

Parameters

dx, dy, dz : float Translation components in model units.

Returns

PartEdit self for chaining.

Raises

RuntimeError If the Part's session is not active.

Source code in src/apeGmsh/core/_part_edit.py
def translate(self, dx: float, dy: float, dz: float) -> "PartEdit":
    """Translate every entity in the Part by ``(dx, dy, dz)``.

    Parameters
    ----------
    dx, dy, dz : float
        Translation components in model units.

    Returns
    -------
    PartEdit
        ``self`` for chaining.

    Raises
    ------
    RuntimeError
        If the Part's session is not active.
    """
    self._require_active("translate")
    if dx == 0.0 and dy == 0.0 and dz == 0.0:
        return self
    dimtags = self._all_dimtags()
    if dimtags:
        gmsh.model.occ.translate(dimtags, float(dx), float(dy), float(dz))
        gmsh.model.occ.synchronize()
    return self

rotate

rotate(angle: float, ax: float, ay: float, az: float, *, center: tuple[float, float, float] = (0.0, 0.0, 0.0)) -> 'PartEdit'

Rotate every entity by angle (radians) about an axis.

Parameters

angle : float Rotation angle in radians. Right-hand rule: thumb along (ax, ay, az), fingers curl positive. ax, ay, az : float Axis direction. Auto-normalized by gmsh. center : (cx, cy, cz), default (0, 0, 0) Point that the axis passes through.

Returns

PartEdit self for chaining.

Source code in src/apeGmsh/core/_part_edit.py
def rotate(
    self,
    angle: float,
    ax: float,
    ay: float,
    az: float,
    *,
    center: tuple[float, float, float] = (0.0, 0.0, 0.0),
) -> "PartEdit":
    """Rotate every entity by ``angle`` (radians) about an axis.

    Parameters
    ----------
    angle : float
        Rotation angle in **radians**.  Right-hand rule: thumb
        along ``(ax, ay, az)``, fingers curl positive.
    ax, ay, az : float
        Axis direction.  Auto-normalized by gmsh.
    center : (cx, cy, cz), default (0, 0, 0)
        Point that the axis passes through.

    Returns
    -------
    PartEdit
        ``self`` for chaining.
    """
    self._require_active("rotate")
    if angle == 0.0:
        return self
    dimtags = self._all_dimtags()
    if dimtags:
        cx, cy, cz = center
        gmsh.model.occ.rotate(
            dimtags,
            float(cx), float(cy), float(cz),
            float(ax), float(ay), float(az),
            float(angle),
        )
        gmsh.model.occ.synchronize()
    return self

mirror

mirror(*, plane: str | None = None, normal: tuple[float, float, float] | None = None, point: tuple[float, float, float] = (0.0, 0.0, 0.0)) -> 'PartEdit'

Reflect every entity across a plane.

Specify the plane in one of two equivalent ways:

  • plane="xy" / "xz" / "yz" — coordinate plane through point (default origin).
  • normal=(nx, ny, nz) — explicit plane normal; the plane passes through point perpendicular to this vector.

Pass exactly one of plane or normal.

Returns

PartEdit self for chaining.

Raises

ValueError If neither or both of plane / normal are given, or plane is not one of the recognized names.

Source code in src/apeGmsh/core/_part_edit.py
def mirror(
    self,
    *,
    plane: str | None = None,
    normal: tuple[float, float, float] | None = None,
    point: tuple[float, float, float] = (0.0, 0.0, 0.0),
) -> "PartEdit":
    """Reflect every entity across a plane.

    Specify the plane in one of two equivalent ways:

    * ``plane="xy"`` / ``"xz"`` / ``"yz"`` — coordinate plane
      through ``point`` (default origin).
    * ``normal=(nx, ny, nz)`` — explicit plane normal; the plane
      passes through ``point`` perpendicular to this vector.

    Pass exactly one of ``plane`` or ``normal``.

    Returns
    -------
    PartEdit
        ``self`` for chaining.

    Raises
    ------
    ValueError
        If neither or both of ``plane`` / ``normal`` are given,
        or ``plane`` is not one of the recognized names.
    """
    self._require_active("mirror")
    if (plane is None) == (normal is None):
        raise ValueError(
            "mirror() requires exactly one of `plane=` or `normal=`."
        )
    if plane is not None:
        named = {
            "xy": (0.0, 0.0, 1.0),
            "xz": (0.0, 1.0, 0.0),
            "yz": (1.0, 0.0, 0.0),
        }
        if plane not in named:
            raise ValueError(
                f"plane must be one of {sorted(named)}; got {plane!r}"
            )
        nx, ny, nz = named[plane]
    else:
        nx, ny, nz = (float(c) for c in normal)
    # Plane equation a·x + b·y + c·z + d = 0; d shifts the plane
    # through ``point``.
    px, py, pz = point
    d = -(nx * px + ny * py + nz * pz)
    dimtags = self._all_dimtags()
    if dimtags:
        gmsh.model.occ.mirror(dimtags, nx, ny, nz, d)
        gmsh.model.occ.synchronize()
    return self

scale

scale(factor: float, *, center: tuple[float, float, float] = (0.0, 0.0, 0.0)) -> 'PartEdit'

Uniform scale every entity by factor about center.

factor=2.0 doubles size, factor=0.001 is mm→m.

Source code in src/apeGmsh/core/_part_edit.py
def scale(
    self,
    factor: float,
    *,
    center: tuple[float, float, float] = (0.0, 0.0, 0.0),
) -> "PartEdit":
    """Uniform scale every entity by ``factor`` about ``center``.

    ``factor=2.0`` doubles size, ``factor=0.001`` is mm→m.
    """
    self._require_active("scale")
    if factor == 1.0:
        return self
    return self.dilate(factor, factor, factor, center=center)

dilate

dilate(sx: float, sy: float, sz: float, *, center: tuple[float, float, float] = (0.0, 0.0, 0.0)) -> 'PartEdit'

Non-uniform scale by (sx, sy, sz) about center.

Source code in src/apeGmsh/core/_part_edit.py
def dilate(
    self,
    sx: float,
    sy: float,
    sz: float,
    *,
    center: tuple[float, float, float] = (0.0, 0.0, 0.0),
) -> "PartEdit":
    """Non-uniform scale by ``(sx, sy, sz)`` about ``center``."""
    self._require_active("dilate")
    if sx == 1.0 and sy == 1.0 and sz == 1.0:
        return self
    dimtags = self._all_dimtags()
    if dimtags:
        cx, cy, cz = center
        gmsh.model.occ.dilate(
            dimtags,
            float(cx), float(cy), float(cz),
            float(sx), float(sy), float(sz),
        )
        gmsh.model.occ.synchronize()
    return self

affine

affine(matrix4x4) -> 'PartEdit'

Apply a general 4×4 affine transform.

Parameters

matrix4x4 : 16-element sequence, 4×4 nested list, or ndarray Row-major. Last row typically [0, 0, 0, 1] (gmsh ignores it but it must be present).

Source code in src/apeGmsh/core/_part_edit.py
def affine(self, matrix4x4) -> "PartEdit":
    """Apply a general 4×4 affine transform.

    Parameters
    ----------
    matrix4x4 : 16-element sequence, 4×4 nested list, or ndarray
        Row-major.  Last row typically ``[0, 0, 0, 1]`` (gmsh
        ignores it but it must be present).
    """
    self._require_active("affine")
    flat = _flatten_matrix(matrix4x4)
    if len(flat) != 16:
        raise ValueError(
            f"affine() requires a 4x4 matrix (16 values); got {len(flat)}"
        )
    dimtags = self._all_dimtags()
    if dimtags:
        gmsh.model.occ.affineTransform(dimtags, flat)
        gmsh.model.occ.synchronize()
    return self

delete

delete() -> None

Remove every entity from the Part's Gmsh session.

Useful when scrapping and rebuilding within the same with block. Labels that pointed at the deleted entities are now stale.

Returns

None

Source code in src/apeGmsh/core/_part_edit.py
def delete(self) -> None:
    """Remove every entity from the Part's Gmsh session.

    Useful when scrapping and rebuilding within the same ``with``
    block.  Labels that pointed at the deleted entities are now
    stale.

    Returns
    -------
    None
    """
    self._require_active("delete")
    dimtags = self._all_dimtags()
    if dimtags:
        gmsh.model.occ.remove(dimtags, recursive=True)
        gmsh.model.occ.synchronize()

copy

copy(*, label: str) -> 'Part'

Create a duplicate Part with a new label.

The duplicate is a brand-new :class:Part with its own STEP file (and sidecar copy if present), _owns_file=True so its tempfile is reclaimed when it's garbage-collected.

The duplicate is not entered as an active session — it sits on disk ready to be consumed by g.parts.add() or re-entered with with new_part: if you need to edit it further.

Works whether the source Part is currently active or not:

  • Active source — current geometry is dumped to a fresh tempfile via gmsh.write (does not disturb the source's file_path).
  • Inactive source — the existing STEP and sidecar are file-copied via shutil.
Parameters

label : str, required New Part name. If the name is already in use by another live Part in this process, a 4-char random suffix is appended and a warning is emitted.

Returns

Part New Part with has_file=True, not yet active.

Raises

ValueError If label is empty. RuntimeError If the source has no current geometry to copy.

Source code in src/apeGmsh/core/_part_edit.py
def copy(self, *, label: str) -> "Part":
    """Create a duplicate Part with a new label.

    The duplicate is a brand-new :class:`Part` with its own
    STEP file (and sidecar copy if present), `_owns_file=True`
    so its tempfile is reclaimed when it's garbage-collected.

    The duplicate is **not** entered as an active session — it
    sits on disk ready to be consumed by ``g.parts.add()`` or
    re-entered with ``with new_part:`` if you need to edit it
    further.

    Works whether the source Part is currently active or not:

    * **Active source** — current geometry is dumped to a fresh
      tempfile via ``gmsh.write`` (does not disturb the source's
      ``file_path``).
    * **Inactive source** — the existing STEP and sidecar are
      file-copied via ``shutil``.

    Parameters
    ----------
    label : str, required
        New Part name.  If the name is already in use by another
        live Part in this process, a 4-char random suffix is
        appended and a warning is emitted.

    Returns
    -------
    Part
        New Part with ``has_file=True``, not yet active.

    Raises
    ------
    ValueError
        If ``label`` is empty.
    RuntimeError
        If the source has no current geometry to copy.
    """
    if not isinstance(label, str) or not label:
        raise ValueError("copy() requires a non-empty `label=` argument.")

    # Lazy import to avoid circular dependency
    from .Part import Part
    import tempfile

    new_label = _resolve_unique_name(label)

    new_temp_dir = Path(
        tempfile.mkdtemp(prefix=f"apeGmsh_part_{new_label}_")
    )
    new_step = new_temp_dir / f"{new_label}.step"
    new_sidecar = sidecar_path(new_step)

    if self._part._active:
        # Source is live — dump current state to the new path.
        entities = self._all_dimtags()
        if not entities:
            shutil.rmtree(new_temp_dir, ignore_errors=True)
            raise RuntimeError(
                "copy() called on an active Part with no geometry."
            )
        gmsh.model.occ.synchronize()
        gmsh.write(str(new_step))
        # Write the sidecar from current PG anchors
        from ._part_anchors import collect_anchors, write_sidecar
        anchors = collect_anchors(gmsh)
        if anchors:
            write_sidecar(new_step, anchors, part_name=new_label)
    else:
        # Source is inactive — copy the existing files.
        self._require_has_file("copy")
        assert self._part.file_path is not None
        shutil.copy2(self._part.file_path, new_step)
        src_sidecar = sidecar_path(self._part.file_path)
        if src_sidecar.exists():
            shutil.copy2(src_sidecar, new_sidecar)

    new_part = Part(new_label)
    new_part.file_path = new_step.resolve()
    new_part._owns_file = True
    new_part._temp_dir = new_temp_dir
    new_part._register_finalizer()
    return new_part

pattern_linear

pattern_linear(*, label: str, n: int, dx: float, dy: float, dz: float) -> list['Part']

Create n translated copies along a line.

Each copy i (1..n) is shifted by (i*dx, i*dy, i*dz) from the source. The source itself is not modified and is not included in the returned list.

Source Part must be non-active (outside its with block).

Parameters

label : str Base name. Generated names are {label}_1{label}_n. Clashes get a random suffix per item, with a warning. n : int Number of copies (>= 1). dx, dy, dz : float Per-step translation increment.

Returns

list[Part] n new Parts, each with its translated geometry baked into its own STEP file.

Source code in src/apeGmsh/core/_part_edit.py
def pattern_linear(
    self,
    *,
    label: str,
    n: int,
    dx: float,
    dy: float,
    dz: float,
) -> list["Part"]:
    """Create ``n`` translated copies along a line.

    Each copy ``i`` (1..n) is shifted by ``(i*dx, i*dy, i*dz)``
    from the source.  The source itself is **not** modified and
    is **not** included in the returned list.

    Source Part must be **non-active** (outside its ``with`` block).

    Parameters
    ----------
    label : str
        Base name.  Generated names are ``{label}_1`` … ``{label}_n``.
        Clashes get a random suffix per item, with a warning.
    n : int
        Number of copies (>= 1).
    dx, dy, dz : float
        Per-step translation increment.

    Returns
    -------
    list[Part]
        ``n`` new Parts, each with its translated geometry baked
        into its own STEP file.
    """
    self._require_inactive("pattern_linear")
    self._require_has_file("pattern_linear")
    if not isinstance(n, int) or n < 1:
        raise ValueError(f"n must be a positive integer; got {n!r}")
    if not isinstance(label, str) or not label:
        raise ValueError("pattern_linear() requires a non-empty `label=`.")

    return [
        self._make_pattern_item(
            label=f"{label}_{i}",
            translate_offset=(i * dx, i * dy, i * dz),
            rotate_spec=None,
        )
        for i in range(1, n + 1)
    ]

pattern_polar

pattern_polar(*, label: str, n: int, axis: tuple[float, float, float], total_angle: float, center: tuple[float, float, float] = (0.0, 0.0, 0.0)) -> list['Part']

Create n rotated copies around an axis.

Each copy i (1..n) is rotated by i * total_angle / n about the axis through center. total_angle is in radians. For a full revolution use total_angle=2*pi; for n=4 evenly spaced this gives 90° increments.

Source Part must be non-active.

Parameters

label : str Base name; copies labeled {label}_1{label}_n. n : int Number of copies. axis : (ax, ay, az) Rotation axis direction. total_angle : float Total swept angle in radians (last copy at this angle). center : (cx, cy, cz), default (0, 0, 0) Point on the rotation axis.

Source code in src/apeGmsh/core/_part_edit.py
def pattern_polar(
    self,
    *,
    label: str,
    n: int,
    axis: tuple[float, float, float],
    total_angle: float,
    center: tuple[float, float, float] = (0.0, 0.0, 0.0),
) -> list["Part"]:
    """Create ``n`` rotated copies around an axis.

    Each copy ``i`` (1..n) is rotated by ``i * total_angle / n``
    about the axis through ``center``.  ``total_angle`` is in
    **radians**.  For a full revolution use ``total_angle=2*pi``;
    for ``n=4`` evenly spaced this gives 90° increments.

    Source Part must be **non-active**.

    Parameters
    ----------
    label : str
        Base name; copies labeled ``{label}_1`` … ``{label}_n``.
    n : int
        Number of copies.
    axis : (ax, ay, az)
        Rotation axis direction.
    total_angle : float
        Total swept angle in radians (last copy at this angle).
    center : (cx, cy, cz), default (0, 0, 0)
        Point on the rotation axis.
    """
    self._require_inactive("pattern_polar")
    self._require_has_file("pattern_polar")
    if not isinstance(n, int) or n < 1:
        raise ValueError(f"n must be a positive integer; got {n!r}")
    if not isinstance(label, str) or not label:
        raise ValueError("pattern_polar() requires a non-empty `label=`.")

    ax, ay, az = (float(c) for c in axis)
    cx, cy, cz = (float(c) for c in center)
    step = float(total_angle) / float(n)

    return [
        self._make_pattern_item(
            label=f"{label}_{i}",
            translate_offset=(0.0, 0.0, 0.0),
            rotate_spec=(i * step, ax, ay, az, cx, cy, cz),
        )
        for i in range(1, n + 1)
    ]

align_to

align_to(other: 'Part', *, source: str, target: str, on: 'str | tuple[str, ...]', offset: float = 0.0) -> 'PartEdit'

Translate this Part so its source label aligns with other's target label along the chosen axes.

Computes source centroid in this Part's live session, reads target centroid from other's STEP sidecar (so other must have been saved — auto-persist counts), then applies the masked translation via :meth:translate.

Parameters

other : Part Reference Part. Must have a saved sidecar (has_file true and a .apegmsh.json written next to the STEP). Passing an Instance is rejected — use :meth:Instance.edit.align_to for the assembly side. source : str Label name on this Part (the feature that moves). target : str Label name on other (the feature it lands on). on : {"x", "y", "z", "all"} or iterable of those Axes on which to match centroids. Other axes untouched. offset : float, default 0.0 Signed gap along the single on axis. Combining a non-zero offset with multi-axis on raises ValueError.

Returns

PartEdit self for chaining.

Raises

RuntimeError If this Part is not active or other has no sidecar. TypeError If other is not a Part (e.g. an Instance). LookupError If source or target cannot be resolved.

Source code in src/apeGmsh/core/_part_edit.py
def align_to(
    self,
    other: "Part",
    *,
    source: str,
    target: str,
    on: "str | tuple[str, ...]",
    offset: float = 0.0,
) -> "PartEdit":
    """Translate this Part so its ``source`` label aligns with
    ``other``'s ``target`` label along the chosen axes.

    Computes ``source`` centroid in this Part's **live session**,
    reads ``target`` centroid from ``other``'s STEP sidecar (so
    ``other`` must have been saved — auto-persist counts), then
    applies the masked translation via :meth:`translate`.

    Parameters
    ----------
    other : Part
        Reference Part.  Must have a saved sidecar (``has_file``
        true and a ``.apegmsh.json`` written next to the STEP).
        Passing an Instance is rejected — use
        :meth:`Instance.edit.align_to` for the assembly side.
    source : str
        Label name on this Part (the feature that moves).
    target : str
        Label name on ``other`` (the feature it lands on).
    on : {"x", "y", "z", "all"} or iterable of those
        Axes on which to match centroids.  Other axes untouched.
    offset : float, default 0.0
        Signed gap along the single ``on`` axis.  Combining a
        non-zero offset with multi-axis ``on`` raises ValueError.

    Returns
    -------
    PartEdit
        ``self`` for chaining.

    Raises
    ------
    RuntimeError
        If this Part is not active or ``other`` has no sidecar.
    TypeError
        If ``other`` is not a Part (e.g. an Instance).
    LookupError
        If ``source`` or ``target`` cannot be resolved.
    """
    from ._align import (
        compute_align_translation,
        label_centroid_from_sidecar,
        label_centroid_live,
    )

    self._require_active("align_to")
    # Duck-type check (not isinstance) so the call survives module
    # re-imports in test infra that purges apeGmsh from sys.modules.
    # Reject Instances by their distinguishing attribute.
    if hasattr(other, 'entities') and hasattr(other, 'label_names'):
        raise TypeError(
            "align_to() got an Instance for `other`; for "
            "Instance-to-Instance alignment use Instance.edit.align_to()."
        )
    if not (
        hasattr(other, 'has_file')
        and hasattr(other, 'file_path')
        and hasattr(other, 'name')
    ):
        raise TypeError(
            f"align_to() expects a Part as `other`; got "
            f"{type(other).__name__}."
        )
    if other is self._part:
        raise ValueError(
            "align_to() requires a different Part as `other`."
        )
    if not other.has_file:
        raise RuntimeError(
            f"align_to() requires `other` ({other.name!r}) to have "
            f"been saved.  Exit its `with` block (auto-persist) or "
            f"call other.save() first."
        )

    source_com = label_centroid_live(source)
    target_com = label_centroid_from_sidecar(target, other.file_path)

    dx, dy, dz = compute_align_translation(
        source_com, target_com, on, offset,
    )
    return self.translate(dx, dy, dz)

align_to_point

align_to_point(point: tuple[float, float, float], *, source: str, on: 'str | tuple[str, ...]', offset: float = 0.0) -> 'PartEdit'

Translate this Part so its source label centroid lands at point along the chosen axes.

Like :meth:align_to but the target is a coordinate rather than another Part's labeled feature. No sidecar lookup needed.

Parameters

point : (px, py, pz) World point in this Part's local frame (i.e. the same frame in which source lives). source : str Label on this Part. on : {"x", "y", "z", "all"} or iterable Axes to align. offset : float, default 0.0 Signed gap along the single on axis.

Source code in src/apeGmsh/core/_part_edit.py
def align_to_point(
    self,
    point: tuple[float, float, float],
    *,
    source: str,
    on: "str | tuple[str, ...]",
    offset: float = 0.0,
) -> "PartEdit":
    """Translate this Part so its ``source`` label centroid lands
    at ``point`` along the chosen axes.

    Like :meth:`align_to` but the target is a coordinate rather
    than another Part's labeled feature.  No sidecar lookup
    needed.

    Parameters
    ----------
    point : (px, py, pz)
        World point in this Part's local frame (i.e. the same
        frame in which ``source`` lives).
    source : str
        Label on this Part.
    on : {"x", "y", "z", "all"} or iterable
        Axes to align.
    offset : float, default 0.0
        Signed gap along the single ``on`` axis.
    """
    from ._align import (
        compute_align_translation,
        label_centroid_live,
    )

    self._require_active("align_to_point")
    source_com = label_centroid_live(source)
    target = (float(point[0]), float(point[1]), float(point[2]))
    dx, dy, dz = compute_align_translation(
        source_com, target, on, offset,
    )
    return self.translate(dx, dy, dz)

Instance edit composite — inst.edit

apeGmsh.core._instance_edit.InstanceEdit

InstanceEdit(instance: 'Instance', registry: 'PartsRegistry')

Operations on a placed :class:Instance. Registered as inst.edit.

Source code in src/apeGmsh/core/_instance_edit.py
def __init__(self, instance: "Instance", registry: "PartsRegistry") -> None:
    self._inst = instance
    self._registry = registry
    self._deleted = False

translate

translate(dx: float, dy: float, dz: float) -> 'InstanceEdit'

Translate the instance by (dx, dy, dz).

Returns self for chaining.

Source code in src/apeGmsh/core/_instance_edit.py
def translate(self, dx: float, dy: float, dz: float) -> "InstanceEdit":
    """Translate the instance by ``(dx, dy, dz)``.

    Returns ``self`` for chaining.
    """
    self._require_alive("translate")
    if dx == 0.0 and dy == 0.0 and dz == 0.0:
        return self
    dimtags = self._top_dimtags()
    if dimtags:
        gmsh.model.occ.translate(dimtags, float(dx), float(dy), float(dz))
        gmsh.model.occ.synchronize()
        self._refresh_bbox()
    return self

rotate

rotate(angle: float, ax: float, ay: float, az: float, *, center: tuple[float, float, float] = (0.0, 0.0, 0.0)) -> 'InstanceEdit'

Rotate the instance by angle (radians) about an axis.

See :meth:Part.edit.rotate for the parameter reference.

Source code in src/apeGmsh/core/_instance_edit.py
def rotate(
    self,
    angle: float,
    ax: float,
    ay: float,
    az: float,
    *,
    center: tuple[float, float, float] = (0.0, 0.0, 0.0),
) -> "InstanceEdit":
    """Rotate the instance by ``angle`` (radians) about an axis.

    See :meth:`Part.edit.rotate` for the parameter reference.
    """
    self._require_alive("rotate")
    if angle == 0.0:
        return self
    dimtags = self._top_dimtags()
    if dimtags:
        cx, cy, cz = center
        gmsh.model.occ.rotate(
            dimtags,
            float(cx), float(cy), float(cz),
            float(ax), float(ay), float(az),
            float(angle),
        )
        gmsh.model.occ.synchronize()
        self._refresh_bbox()
    return self

mirror

mirror(*, plane: str | None = None, normal: tuple[float, float, float] | None = None, point: tuple[float, float, float] = (0.0, 0.0, 0.0)) -> 'InstanceEdit'

Reflect the instance across a plane.

See :meth:Part.edit.mirror for the parameter reference.

Source code in src/apeGmsh/core/_instance_edit.py
def mirror(
    self,
    *,
    plane: str | None = None,
    normal: tuple[float, float, float] | None = None,
    point: tuple[float, float, float] = (0.0, 0.0, 0.0),
) -> "InstanceEdit":
    """Reflect the instance across a plane.

    See :meth:`Part.edit.mirror` for the parameter reference.
    """
    self._require_alive("mirror")
    if (plane is None) == (normal is None):
        raise ValueError(
            "mirror() requires exactly one of `plane=` or `normal=`."
        )
    if plane is not None:
        named = {
            "xy": (0.0, 0.0, 1.0),
            "xz": (0.0, 1.0, 0.0),
            "yz": (1.0, 0.0, 0.0),
        }
        if plane not in named:
            raise ValueError(
                f"plane must be one of {sorted(named)}; got {plane!r}"
            )
        nx, ny, nz = named[plane]
    else:
        nx, ny, nz = (float(c) for c in normal)
    px, py, pz = point
    d = -(nx * px + ny * py + nz * pz)
    dimtags = self._top_dimtags()
    if dimtags:
        gmsh.model.occ.mirror(dimtags, nx, ny, nz, d)
        gmsh.model.occ.synchronize()
        self._refresh_bbox()
    return self

scale

scale(factor: float, *, center: tuple[float, float, float] = (0.0, 0.0, 0.0)) -> 'InstanceEdit'

Uniform scale by factor about center.

Source code in src/apeGmsh/core/_instance_edit.py
def scale(
    self,
    factor: float,
    *,
    center: tuple[float, float, float] = (0.0, 0.0, 0.0),
) -> "InstanceEdit":
    """Uniform scale by ``factor`` about ``center``."""
    self._require_alive("scale")
    if factor == 1.0:
        return self
    return self.dilate(factor, factor, factor, center=center)

dilate

dilate(sx: float, sy: float, sz: float, *, center: tuple[float, float, float] = (0.0, 0.0, 0.0)) -> 'InstanceEdit'

Non-uniform scale by (sx, sy, sz) about center.

Source code in src/apeGmsh/core/_instance_edit.py
def dilate(
    self,
    sx: float,
    sy: float,
    sz: float,
    *,
    center: tuple[float, float, float] = (0.0, 0.0, 0.0),
) -> "InstanceEdit":
    """Non-uniform scale by ``(sx, sy, sz)`` about ``center``."""
    self._require_alive("dilate")
    if sx == 1.0 and sy == 1.0 and sz == 1.0:
        return self
    dimtags = self._top_dimtags()
    if dimtags:
        cx, cy, cz = center
        gmsh.model.occ.dilate(
            dimtags,
            float(cx), float(cy), float(cz),
            float(sx), float(sy), float(sz),
        )
        gmsh.model.occ.synchronize()
        self._refresh_bbox()
    return self

affine

affine(matrix4x4) -> 'InstanceEdit'

Apply a general 4×4 affine transform.

See :meth:Part.edit.affine for the parameter reference.

Source code in src/apeGmsh/core/_instance_edit.py
def affine(self, matrix4x4) -> "InstanceEdit":
    """Apply a general 4×4 affine transform.

    See :meth:`Part.edit.affine` for the parameter reference.
    """
    self._require_alive("affine")
    from ._part_edit import _flatten_matrix

    flat = _flatten_matrix(matrix4x4)
    if len(flat) != 16:
        raise ValueError(
            f"affine() requires a 4x4 matrix (16 values); got {len(flat)}"
        )
    dimtags = self._top_dimtags()
    if dimtags:
        gmsh.model.occ.affineTransform(dimtags, flat)
        gmsh.model.occ.synchronize()
        self._refresh_bbox()
    return self

delete

delete() -> None

Remove the instance's entities from the assembly session and unregister from g.parts._instances.

After delete(), subsequent calls on this edit object raise RuntimeError. The label is freed and may be reused by a fresh parts.add().

Source code in src/apeGmsh/core/_instance_edit.py
def delete(self) -> None:
    """Remove the instance's entities from the assembly session
    and unregister from ``g.parts._instances``.

    After ``delete()``, subsequent calls on this ``edit`` object
    raise ``RuntimeError``.  The label is freed and may be reused
    by a fresh ``parts.add()``.
    """
    self._require_alive("delete")
    all_dimtags = [
        (d, t) for d, ts in self._inst.entities.items() for t in ts
    ]
    if all_dimtags:
        gmsh.model.occ.remove(all_dimtags, recursive=True)
        gmsh.model.occ.synchronize()
    # Unregister from the parent registry so the label is free again
    self._registry._instances.pop(self._inst.label, None)
    # Wipe the entity map so any lingering references see an empty inst
    self._inst.entities = {}
    self._inst.bbox = None
    self._deleted = True

copy

copy(*, label: str) -> 'Instance'

Duplicate this instance's geometry into a new Instance.

Uses gmsh.model.occ.copy() to clone the dimtags (the new entities live in the same assembly session). All Part-level labels carried by this instance are recreated under the new instance's label prefix — so e.g. b1.top_flange becomes b2.top_flange on the copy.

Parameters

label : str, required New instance label. If the requested label is already taken in this session, a 4-character random hex suffix is appended and a warning emitted.

Returns

Instance The new Instance, registered in g.parts and ready for further edits.

Raises

RuntimeError If this instance has already been deleted. ValueError If label is empty.

Source code in src/apeGmsh/core/_instance_edit.py
def copy(self, *, label: str) -> "Instance":
    """Duplicate this instance's geometry into a new Instance.

    Uses ``gmsh.model.occ.copy()`` to clone the dimtags (the new
    entities live in the same assembly session).  All Part-level
    labels carried by this instance are recreated under the new
    instance's label prefix — so e.g. ``b1.top_flange`` becomes
    ``b2.top_flange`` on the copy.

    Parameters
    ----------
    label : str, required
        New instance label.  If the requested label is already
        taken in this session, a 4-character random hex suffix is
        appended and a warning emitted.

    Returns
    -------
    Instance
        The new Instance, registered in ``g.parts`` and ready
        for further edits.

    Raises
    ------
    RuntimeError
        If this instance has already been deleted.
    ValueError
        If ``label`` is empty.
    """
    self._require_alive("copy")
    if not isinstance(label, str) or not label:
        raise ValueError("copy() requires a non-empty `label=` argument.")

    from ._parts_registry import Instance
    # Resolve clash against existing instance labels
    new_label = _resolve_unique_instance_label(label, self._registry)

    src_dimtags = [
        (d, t) for d, ts in self._inst.entities.items() for t in ts
    ]
    if not src_dimtags:
        raise RuntimeError(
            "copy() called on an instance with no geometry."
        )

    new_dimtags = gmsh.model.occ.copy(src_dimtags)
    gmsh.model.occ.synchronize()

    # Build src_tag -> new_tag map per dim (gmsh preserves order)
    tag_map: dict[int, dict[int, int]] = {}
    for (sd, st), (nd, nt) in zip(src_dimtags, new_dimtags):
        tag_map.setdefault(sd, {})[st] = nt

    # New entities dict
    new_entities: dict[int, list[int]] = {}
    for (nd, nt) in new_dimtags:
        new_entities.setdefault(nd, []).append(nt)

    # Recreate labels under the new prefix
    new_label_names = self._rebrand_labels(
        tag_map, new_entities, new_label,
    )

    new_inst = Instance(
        label=new_label,
        part_name=self._inst.part_name,
        entities=new_entities,
        bbox=self._registry._compute_bbox(new_dimtags),
        label_names=new_label_names,
    )
    self._registry._register_instance(new_inst)
    return new_inst

pattern_linear

pattern_linear(*, label: str, n: int, dx: float, dy: float, dz: float) -> list['Instance']

Create n translated copies of this instance.

Each copy i (1..n) is shifted by (i*dx, i*dy, i*dz) from the source. The source itself is not modified.

Returns a list of n new :class:Instance objects, all registered in g.parts.

Source code in src/apeGmsh/core/_instance_edit.py
def pattern_linear(
    self,
    *,
    label: str,
    n: int,
    dx: float,
    dy: float,
    dz: float,
) -> list["Instance"]:
    """Create ``n`` translated copies of this instance.

    Each copy ``i`` (1..n) is shifted by ``(i*dx, i*dy, i*dz)``
    from the source.  The source itself is not modified.

    Returns a list of ``n`` new :class:`Instance` objects, all
    registered in ``g.parts``.
    """
    self._require_alive("pattern_linear")
    if not isinstance(n, int) or n < 1:
        raise ValueError(f"n must be a positive integer; got {n!r}")
    if not isinstance(label, str) or not label:
        raise ValueError("pattern_linear() requires a non-empty `label=`.")

    out: list["Instance"] = []
    for i in range(1, n + 1):
        new_inst = self.copy(label=f"{label}_{i}")
        new_inst.edit.translate(i * dx, i * dy, i * dz)
        out.append(new_inst)
    return out

pattern_polar

pattern_polar(*, label: str, n: int, axis: tuple[float, float, float], total_angle: float, center: tuple[float, float, float] = (0.0, 0.0, 0.0)) -> list['Instance']

Create n rotated copies of this instance.

Each copy i (1..n) is rotated by i * total_angle / n about axis through center. total_angle is in radians (2*pi for a full revolution).

Source code in src/apeGmsh/core/_instance_edit.py
def pattern_polar(
    self,
    *,
    label: str,
    n: int,
    axis: tuple[float, float, float],
    total_angle: float,
    center: tuple[float, float, float] = (0.0, 0.0, 0.0),
) -> list["Instance"]:
    """Create ``n`` rotated copies of this instance.

    Each copy ``i`` (1..n) is rotated by ``i * total_angle / n``
    about ``axis`` through ``center``.  ``total_angle`` is in
    radians (``2*pi`` for a full revolution).
    """
    self._require_alive("pattern_polar")
    if not isinstance(n, int) or n < 1:
        raise ValueError(f"n must be a positive integer; got {n!r}")
    if not isinstance(label, str) or not label:
        raise ValueError("pattern_polar() requires a non-empty `label=`.")

    ax, ay, az = (float(c) for c in axis)
    step = float(total_angle) / float(n)

    out: list["Instance"] = []
    for i in range(1, n + 1):
        new_inst = self.copy(label=f"{label}_{i}")
        new_inst.edit.rotate(i * step, ax, ay, az, center=center)
        out.append(new_inst)
    return out

align_to

align_to(other: 'Instance', *, source: str, target: str, on: 'str | tuple[str, ...]', offset: float = 0.0) -> 'InstanceEdit'

Translate this instance so its source label aligns with other's target label along the chosen axes.

Both instances must live in the same session (the assembly). Both centroids are read live from gmsh — no sidecar lookup needed.

Parameters

other : Instance Reference instance. Cross-Part alignment is rejected (Parts live in their own sessions; use Part.edit.align_to instead). source : str Label suffix on this instance (e.g. "top_flange"). Resolved to f"{self.label}.{source}". target : str Label suffix on other, resolved to f"{other.label}.{target}". on : {"x","y","z","all"} or iterable Axes on which to match centroids. offset : float, default 0.0 Signed gap along the (single) on axis.

Returns

InstanceEdit self for chaining.

Source code in src/apeGmsh/core/_instance_edit.py
def align_to(
    self,
    other: "Instance",
    *,
    source: str,
    target: str,
    on: "str | tuple[str, ...]",
    offset: float = 0.0,
) -> "InstanceEdit":
    """Translate this instance so its ``source`` label aligns
    with ``other``'s ``target`` label along the chosen axes.

    Both instances must live in the same session (the assembly).
    Both centroids are read live from gmsh — no sidecar lookup
    needed.

    Parameters
    ----------
    other : Instance
        Reference instance.  Cross-Part alignment is rejected
        (Parts live in their own sessions; use Part.edit.align_to
        instead).
    source : str
        Label suffix on this instance (e.g. ``"top_flange"``).
        Resolved to ``f"{self.label}.{source}"``.
    target : str
        Label suffix on ``other``, resolved to
        ``f"{other.label}.{target}"``.
    on : {"x","y","z","all"} or iterable
        Axes on which to match centroids.
    offset : float, default 0.0
        Signed gap along the (single) ``on`` axis.

    Returns
    -------
    InstanceEdit
        ``self`` for chaining.
    """
    from ._align import (
        compute_align_translation,
        label_centroid_live,
    )

    self._require_alive("align_to")
    # Duck-type check (resilient to module re-imports).
    if not (
        hasattr(other, 'entities')
        and hasattr(other, 'label_names')
        and hasattr(other, 'label')
    ):
        raise TypeError(
            f"align_to() expects an Instance as `other`; got "
            f"{type(other).__name__}.  For Part-to-Part alignment "
            f"use Part.edit.align_to() instead."
        )
    if other is self._inst:
        raise ValueError(
            "align_to() requires a different Instance as `other`."
        )

    source_suffix = _normalize_label_arg(source, self._inst.label)
    target_suffix = _normalize_label_arg(target, other.label)
    source_full = f"{self._inst.label}.{source_suffix}"
    target_full = f"{other.label}.{target_suffix}"

    source_com = label_centroid_live(source_full)
    target_com = label_centroid_live(target_full)

    dx, dy, dz = compute_align_translation(
        source_com, target_com, on, offset,
    )
    return self.translate(dx, dy, dz)

align_to_point

align_to_point(point: tuple[float, float, float], *, source: str, on: 'str | tuple[str, ...]', offset: float = 0.0) -> 'InstanceEdit'

Translate this instance so its source label centroid lands at point along the chosen axes.

Source code in src/apeGmsh/core/_instance_edit.py
def align_to_point(
    self,
    point: tuple[float, float, float],
    *,
    source: str,
    on: "str | tuple[str, ...]",
    offset: float = 0.0,
) -> "InstanceEdit":
    """Translate this instance so its ``source`` label centroid
    lands at ``point`` along the chosen axes.
    """
    from ._align import (
        compute_align_translation,
        label_centroid_live,
    )

    self._require_alive("align_to_point")
    source_suffix = _normalize_label_arg(source, self._inst.label)
    source_full = f"{self._inst.label}.{source_suffix}"
    source_com = label_centroid_live(source_full)
    target = (float(point[0]), float(point[1]), float(point[2]))
    dx, dy, dz = compute_align_translation(
        source_com, target, on, offset,
    )
    return self.translate(dx, dy, dz)

Labels

apeGmsh.core.Labels.Labels

Labels(parent: '_SessionBase')

Bases: _HasLogging

Geometry-time entity naming composite (g.labels).

Backed by Gmsh physical groups with an internal _label: prefix. See the module docstring for the two-tier naming architecture.

Source code in src/apeGmsh/core/Labels.py
def __init__(self, parent: "_SessionBase") -> None:
    self._parent = parent

add

add(dim: int, tags: list[int], name: str) -> int

Create a label for the given entities.

If a label with the same name and dimension already exists, the tags are merged into the existing PG rather than creating a duplicate.

Parameters

dim : int Entity dimension (0–3). tags : list[int] Entity tags to label. name : str Human-readable label name (without prefix).

Returns

int The Gmsh physical-group tag backing this label.

Source code in src/apeGmsh/core/Labels.py
def add(self, dim: int, tags: list[int], name: str) -> int:
    """Create a label for the given entities.

    If a label with the same name and dimension already exists,
    the tags are **merged** into the existing PG rather than
    creating a duplicate.

    Parameters
    ----------
    dim : int
        Entity dimension (0–3).
    tags : list[int]
        Entity tags to label.
    name : str
        Human-readable label name (without prefix).

    Returns
    -------
    int
        The Gmsh physical-group tag backing this label.
    """
    # Phase 3B.2d / ADR 0038 — labels round-trip via the
    # FEMData broker; mutating them post-extraction would diverge
    # the broker from gmsh.
    from ._compose_errors import chain_phase_guard
    chain_phase_guard(self._parent, f"g.labels.add({name!r})")
    prefixed = add_prefix(name)

    # Build a name→(dim, pg_tag) index in one pass over all label
    # PGs.  This replaces up to 4 separate _find_pg_tag scans with
    # a single O(n) scan + O(1) dict lookups.
    label_index = self._label_index()

    # Check if this label already exists at this dim — merge
    # rather than duplicate.
    existing_tag = label_index.get((dim, prefixed))
    if existing_tag is not None:
        existing_ents = list(
            gmsh.model.getEntitiesForPhysicalGroup(dim, existing_tag)
        )
        new_tags = set(int(t) for t in tags)
        truly_new = new_tags - set(existing_ents)
        if truly_new:
            warnings.warn(
                f"Label {name!r} (dim={dim}) already exists with "
                f"{len(existing_ents)} entity(ies). Merging "
                f"{len(truly_new)} new tag(s) into it. If this is "
                f"unintentional, use a different label name.",
                stacklevel=3,
            )
        merged = sorted(set(existing_ents) | new_tags)
        gmsh.model.removePhysicalGroups([(dim, existing_tag)])
        pg_tag = gmsh.model.addPhysicalGroup(dim, merged)
        gmsh.model.setPhysicalName(dim, pg_tag, prefixed)
        self._log(f"add({name!r}, dim={dim}) merged into pg_tag={pg_tag}")
        return pg_tag

    # Check if the same label name exists at a DIFFERENT dim —
    # warn about cross-dim shadowing.
    for other_dim in range(4):
        if other_dim == dim:
            continue
        if (other_dim, prefixed) in label_index:
            warnings.warn(
                f"Label {name!r} already exists at dim={other_dim}, "
                f"now also being created at dim={dim}. This may "
                f"cause ambiguous lookups when dim= is not specified.",
                stacklevel=3,
            )
            break

    pg_tag = gmsh.model.addPhysicalGroup(dim, [int(t) for t in tags])
    gmsh.model.setPhysicalName(dim, pg_tag, prefixed)
    self._log(f"add({name!r}, dim={dim}, tags={tags}) -> pg_tag={pg_tag}")
    return pg_tag

entities

entities(name: str, *, dim: int | None = None) -> list[int]

Return entity tags for a label.

Parameters

name : str Label name (without prefix). dim : int, optional Restrict to a single dimension. When None, searches all dimensions. If the label exists at exactly one dimension, returns those entities. If it exists at multiple dimensions, raises ValueError asking the caller to specify dim=.

Returns

list[int] Entity tags.

Raises

KeyError When no label with this name exists. ValueError When dim=None and the label exists at multiple dimensions.

Source code in src/apeGmsh/core/Labels.py
def entities(self, name: str, *, dim: int | None = None) -> list[int]:
    """Return entity tags for a label.

    Parameters
    ----------
    name : str
        Label name (without prefix).
    dim : int, optional
        Restrict to a single dimension.  When None, searches
        all dimensions.  If the label exists at exactly one
        dimension, returns those entities.  If it exists at
        multiple dimensions, raises ``ValueError`` asking the
        caller to specify ``dim=``.

    Returns
    -------
    list[int]
        Entity tags.

    Raises
    ------
    KeyError
        When no label with this name exists.
    ValueError
        When ``dim=None`` and the label exists at multiple
        dimensions.
    """
    self._require_kernel(f"g.labels.entities({name!r})")
    prefixed = add_prefix(name)

    if dim is not None:
        # Direct lookup at a specific dimension
        for pg_dim, pg_tag in gmsh.model.getPhysicalGroups(dim):
            pg_name = gmsh.model.getPhysicalName(pg_dim, pg_tag)
            if pg_name == prefixed:
                return [
                    int(t)
                    for t in gmsh.model.getEntitiesForPhysicalGroup(
                        pg_dim, pg_tag,
                    )
                ]
        available = self.get_all()
        raise KeyError(
            f"no label {name!r} found at dim={dim}. "
            f"Available labels: {available}"
        )

    # dim=None — search all dimensions, require unambiguous match
    matches: list[tuple[int, int]] = []  # (pg_dim, pg_tag)
    for d in range(4):
        for pg_dim, pg_tag in gmsh.model.getPhysicalGroups(d):
            if gmsh.model.getPhysicalName(pg_dim, pg_tag) == prefixed:
                matches.append((pg_dim, pg_tag))

    if not matches:
        available = self.get_all()
        raise KeyError(
            f"no label {name!r} found. Available labels: {available}"
        )

    if len(matches) == 1:
        pg_dim, pg_tag = matches[0]
        return [
            int(t)
            for t in gmsh.model.getEntitiesForPhysicalGroup(
                pg_dim, pg_tag,
            )
        ]

    dims_found = sorted(set(d for d, _ in matches))
    raise ValueError(
        f"Label {name!r} exists at multiple dimensions "
        f"{dims_found}. Specify dim= to disambiguate, e.g. "
        f"g.labels.entities({name!r}, dim={dims_found[-1]})"
    )

get_all

get_all(*, dim: int = -1) -> list[str]

Return all label names (without prefix).

Parameters

dim : int, default -1 Filter by dimension. -1 returns all dimensions.

Source code in src/apeGmsh/core/Labels.py
def get_all(self, *, dim: int = -1) -> list[str]:
    """Return all label names (without prefix).

    Parameters
    ----------
    dim : int, default -1
        Filter by dimension.  ``-1`` returns all dimensions.
    """
    self._require_kernel("g.labels.get_all()")
    names: list[str] = []
    for d, t in gmsh.model.getPhysicalGroups(dim):
        pg_name = gmsh.model.getPhysicalName(d, t)
        if is_label_pg(pg_name):
            names.append(strip_prefix(pg_name))
    return sorted(set(names))

summary

summary()

DataFrame describing every label in the model.

Mirrors :meth:PhysicalGroups.summary but returns only the internal label PGs (with the _label: prefix stripped).

Returns

pd.DataFrame indexed by (dim, pg_tag) with columns name, n_entities, entity_tags.

Source code in src/apeGmsh/core/Labels.py
def summary(self):
    """DataFrame describing every label in the model.

    Mirrors :meth:`PhysicalGroups.summary` but returns only the
    internal label PGs (with the ``_label:`` prefix stripped).

    Returns
    -------
    pd.DataFrame  indexed by ``(dim, pg_tag)`` with columns
    ``name``, ``n_entities``, ``entity_tags``.
    """
    self._require_kernel("g.labels.summary()")
    import pandas as pd
    rows: list[dict] = []
    for d, t in gmsh.model.getPhysicalGroups():
        pg_name = gmsh.model.getPhysicalName(d, t)
        if not is_label_pg(pg_name):
            continue
        entities = gmsh.model.getEntitiesForPhysicalGroup(d, t)
        rows.append({
            'dim'        : d,
            'pg_tag'     : t,
            'name'       : strip_prefix(pg_name),
            'n_entities' : len(entities),
            'entity_tags': ", ".join(str(x) for x in entities),
        })
    if not rows:
        return pd.DataFrame(
            columns=['dim', 'pg_tag', 'name', 'n_entities', 'entity_tags']
        )
    return (
        pd.DataFrame(rows)
        .set_index(['dim', 'pg_tag'])
        .sort_index()
    )

has

has(name: str, *, dim: int | None = None) -> bool

Return True if a label with this name exists.

Source code in src/apeGmsh/core/Labels.py
def has(self, name: str, *, dim: int | None = None) -> bool:
    """Return True if a label with this name exists."""
    # Guarded ahead of the delegation so the message names has(),
    # not the entities() call underneath it.
    self._require_kernel(f"g.labels.has({name!r})")
    try:
        self.entities(name, dim=dim)
        return True
    except KeyError:
        return False

remove

remove(name: str, *, dim: int | None = None) -> None

Delete a label (and its backing physical group).

Parameters

name : str Label name (without prefix). dim : int, optional Restrict to a single dimension. When None, removes the label at all dimensions where it exists.

Raises

KeyError When no label with this name exists.

Source code in src/apeGmsh/core/Labels.py
def remove(self, name: str, *, dim: int | None = None) -> None:
    """Delete a label (and its backing physical group).

    Parameters
    ----------
    name : str
        Label name (without prefix).
    dim : int, optional
        Restrict to a single dimension.  When None, removes the
        label at **all** dimensions where it exists.

    Raises
    ------
    KeyError
        When no label with this name exists.
    """
    # Frozen post-extraction for the same reason as add(): the
    # broker's LabelSet is a snapshot, so deleting the backing PG
    # here would leave the two disagreeing about what exists.
    from ._compose_errors import chain_phase_guard
    chain_phase_guard(self._parent, f"g.labels.remove({name!r})")
    prefixed = add_prefix(name)
    dims = [dim] if dim is not None else [0, 1, 2, 3]
    removed = False
    for d in dims:
        for pg_dim, pg_tag in list(gmsh.model.getPhysicalGroups(d)):
            if gmsh.model.getPhysicalName(pg_dim, pg_tag) == prefixed:
                gmsh.model.removePhysicalGroups([(pg_dim, pg_tag)])
                removed = True
    if not removed:
        raise KeyError(
            f"no label {name!r} found"
            + (f" at dim={dim}" if dim is not None else "")
            + f". Available labels: {self.get_all()}"
        )
    self._log(f"remove({name!r}, dim={dim})")

rename

rename(old_name: str, new_name: str, *, dim: int | None = None) -> None

Rename a label in place, preserving its entity membership.

Parameters

old_name : str Current label name (without prefix). new_name : str New label name (without prefix). dim : int, optional Restrict to a single dimension. When None, renames the label at all dimensions where it exists.

Raises

KeyError When no label with old_name exists.

Source code in src/apeGmsh/core/Labels.py
def rename(self, old_name: str, new_name: str, *, dim: int | None = None) -> None:
    """Rename a label in place, preserving its entity membership.

    Parameters
    ----------
    old_name : str
        Current label name (without prefix).
    new_name : str
        New label name (without prefix).
    dim : int, optional
        Restrict to a single dimension.  When None, renames the
        label at **all** dimensions where it exists.

    Raises
    ------
    KeyError
        When no label with *old_name* exists.
    """
    from ._compose_errors import chain_phase_guard
    chain_phase_guard(
        self._parent, f"g.labels.rename({old_name!r} -> {new_name!r})",
    )
    old_prefixed = add_prefix(old_name)
    new_prefixed = add_prefix(new_name)
    dims = [dim] if dim is not None else [0, 1, 2, 3]
    renamed = False
    for d in dims:
        for pg_dim, pg_tag in list(gmsh.model.getPhysicalGroups(d)):
            if gmsh.model.getPhysicalName(pg_dim, pg_tag) == old_prefixed:
                # Read entities, remove old PG, create new one
                ent_tags = list(
                    gmsh.model.getEntitiesForPhysicalGroup(pg_dim, pg_tag)
                )
                gmsh.model.removePhysicalGroups([(pg_dim, pg_tag)])
                new_pg = gmsh.model.addPhysicalGroup(pg_dim, [int(t) for t in ent_tags])
                gmsh.model.setPhysicalName(pg_dim, new_pg, new_prefixed)
                renamed = True
    if not renamed:
        raise KeyError(
            f"no label {old_name!r} found"
            + (f" at dim={dim}" if dim is not None else "")
            + f". Available labels: {self.get_all()}"
        )
    self._log(f"rename({old_name!r} -> {new_name!r}, dim={dim})")

promote_to_physical

promote_to_physical(label_name: str, *, pg_name: str | None = None, dim: int | None = None) -> int

Copy a label's entities into a solver-facing physical group.

The label remains intact — this is a copy, not a move. The new PG is visible to g.physical, fem.physical, and the OpenSees exporter.

Parameters

label_name : str Label to promote. pg_name : str, optional Name for the new physical group. Defaults to the label name (without prefix). dim : int, optional Dimension to promote. Required when the label exists at multiple dimensions.

Returns

int Physical-group tag of the new PG.

Source code in src/apeGmsh/core/Labels.py
def promote_to_physical(
    self,
    label_name: str,
    *,
    pg_name: str | None = None,
    dim: int | None = None,
) -> int:
    """Copy a label's entities into a solver-facing physical group.

    The label remains intact — this is a **copy**, not a move.
    The new PG is visible to ``g.physical``, ``fem.physical``,
    and the OpenSees exporter.

    Parameters
    ----------
    label_name : str
        Label to promote.
    pg_name : str, optional
        Name for the new physical group.  Defaults to the
        label name (without prefix).
    dim : int, optional
        Dimension to promote.  Required when the label exists
        at multiple dimensions.

    Returns
    -------
    int
        Physical-group tag of the new PG.
    """
    # Creates a solver-facing PG, so it is a PG mutation and frozen
    # on the same terms as g.physical.add().  Guarded ahead of the
    # entities() read below so a chain-phase caller gets the
    # mutation message rather than the query one.
    from ._compose_errors import chain_phase_guard
    chain_phase_guard(
        self._parent, f"g.labels.promote_to_physical({label_name!r})",
    )
    tags = self.entities(label_name, dim=dim)
    out_name = pg_name or label_name

    # Resolve the dim from the label's PG
    prefixed = add_prefix(label_name)
    resolved_dim = dim
    if resolved_dim is None:
        for d in [3, 2, 1, 0]:
            for pd, pt in gmsh.model.getPhysicalGroups(d):
                if gmsh.model.getPhysicalName(pd, pt) == prefixed:
                    resolved_dim = d
                    break
            if resolved_dim is not None:
                break
    if resolved_dim is None:
        raise KeyError(f"label {label_name!r} not found")

    pg_tag = gmsh.model.addPhysicalGroup(resolved_dim, tags)
    gmsh.model.setPhysicalName(resolved_dim, pg_tag, out_name)
    self._log(
        f"promote_to_physical({label_name!r}) -> "
        f"PG {out_name!r} (dim={resolved_dim}, {len(tags)} entities)"
    )
    return pg_tag

reverse_map

reverse_map(*, dim: int = -1) -> dict[DimTag, str]

Build a (dim, tag) -> label_name reverse lookup.

Useful when callers need to find labels for many entities at once without repeated entities() calls.

Parameters

dim : int, default -1 Filter by dimension. -1 returns all dimensions.

Source code in src/apeGmsh/core/Labels.py
def reverse_map(self, *, dim: int = -1) -> dict[DimTag, str]:
    """Build a ``(dim, tag) -> label_name`` reverse lookup.

    Useful when callers need to find labels for many entities at
    once without repeated ``entities()`` calls.

    Parameters
    ----------
    dim : int, default -1
        Filter by dimension.  ``-1`` returns all dimensions.
    """
    self._require_kernel("g.labels.reverse_map()")
    result: dict[DimTag, str] = {}
    for d, pg_tag in gmsh.model.getPhysicalGroups(dim):
        pg_name = gmsh.model.getPhysicalName(d, pg_tag)
        if not is_label_pg(pg_name):
            continue
        name = strip_prefix(pg_name)
        for t in gmsh.model.getEntitiesForPhysicalGroup(d, pg_tag):
            result[(int(d), int(t))] = name
    return result

labels_for_entity

labels_for_entity(dim: int, tag: int) -> list[str]

Return all label names that contain the given entity.

Source code in src/apeGmsh/core/Labels.py
def labels_for_entity(self, dim: int, tag: int) -> list[str]:
    """Return all label names that contain the given entity."""
    self._require_kernel("g.labels.labels_for_entity()")
    names: list[str] = []
    for d, pg_tag in gmsh.model.getPhysicalGroups(dim):
        pg_name = gmsh.model.getPhysicalName(d, pg_tag)
        if not is_label_pg(pg_name):
            continue
        ent_tags = gmsh.model.getEntitiesForPhysicalGroup(d, pg_tag)
        if tag in ent_tags:
            names.append(strip_prefix(pg_name))
    return names