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 physical groups.

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),
    )

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, 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. 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,
    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.
    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,
        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}``.
    """
    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
    from ._part_edit import _resolve_unique_name as _resolve_unique_part
    # 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.
    """
    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.
    """
    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.
    """
    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``.
    """
    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."""
    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.
    """
    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.
    """
    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.
    """
    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.
    """
    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."""
    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