Skip to content

Constraints — g.constraints

Solver-agnostic kinematic-constraint engine. Constraints are declared on geometry (part labels, optional entity scopes) and resolved on the mesh by g.mesh.queries.get_fem_data.

Two-stage pipeline

Stage 1 — declare before meshing. The factory methods on g.constraints (equal_dof, rigid_link, tie, …) store ConstraintDef dataclasses describing intent at the geometry level. These definitions carry no node tags and survive remeshing.

Stage 2 — resolve after meshing. ConstraintResolver walks the def list and produces concrete ConstraintRecord objects (actual node tags, weights, offset vectors). Records land on the FEM broker:

Record family Lives on
NodePairRecord fem.nodes.constraints
NodeGroupRecord fem.nodes.constraints
NodeToSurfaceRecord fem.nodes.constraints
InterpolationRecord fem.elements.constraints
SurfaceCouplingRecord fem.elements.constraints

Constraint taxonomy

Seven tiers, ordered by topology:

Tier Methods Record family
1 — Pair equal_dof, rigid_link, penalty NodePairRecord
2 — Group rigid_diaphragm, rigid_body, kinematic_coupling NodeGroupRecord
2b — Mixed node_to_surface, node_to_surface_spring NodeToSurfaceRecord
3 — Surface tie, distributing_coupling, embedded InterpolationRecord
4 — Contact tied_contact SurfaceCouplingRecord
5 — Fork contact, contact_plane, mortar (deprecated alias for contact(formulation="mortar", tie=True)) ContactRecord, ContactPlaneRecord
6 — Interface interface InterfaceRecord

Tiers 1 to 4 ultimately express the linear MPC equation u_slave = C · u_master, and differ in how C is built: node co-location (Tier 1), kinematic transformation around a master point (Tier 2), shape-function interpolation (Tier 3), or numerical integration on the interface (Tier 4). A labelled g.decouple_node (or its handle) is a valid RBE2/RBE3 master_label so BCs land on an ndf=6 work point. Tiers 5 and 6 are not equations at all — they resolve onto their own additive side-lists (fem.elements.contacts, fem.elements.interfaces) and emit elements, which is how they can carry a force that drops to zero.

Target identification

Most methods identify their master and slave sides by part label (a key of g.parts._instances). _add_def validates both labels against the registry and raises KeyError on a typo.

Optional master_entities / slave_entities arguments (lists of (dim, tag)) narrow the search to a subset of the part's entities — useful when a part has many surfaces and only one is the interface.

Exceptions to the part-label scheme:

  • node_to_surface and node_to_surface_spring take bare tags instead — the master is a Gmsh point entity (dim=0) and the slave is one or more surface entities (dim=2).
  • embedded uses host_label / embedded_label to mirror Abaqus's vocabulary; the lookup logic otherwise matches the part-label scheme.

Worked example

from apeGmsh import apeGmsh

with apeGmsh(model_name="frame") as g:
    # ... geometry + Parts already imported ...

    # Tier 1 — co-located nodes share x/y/z
    g.constraints.equal_dof("col", "beam", dofs=[1, 2, 3])

    # Tier 2 — slab nodes follow a centre-of-mass node
    g.constraints.rigid_diaphragm(
        "slab", "slab_master",
        master_point=(2.5, 2.5, 3.0),
        plane_normal=(0, 0, 1),
    )

    # Tier 3 — non-matching shell-to-solid interface
    g.constraints.tie(
        "shell_floor", "solid_column",
        master_entities=[(2, 17)],
        slave_entities=[(2, 41)],
        tolerance=5.0,
    )

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

    # Grouped emission — accumulates rigid_beam / rigid_diaphragm /
    # node_to_surface phantom links by master node.
    for master, slaves in fem.nodes.constraints.rigid_link_groups():
        for slave in slaves:
            ops.rigidLink("beam", master, slave)

Composite

apeGmsh.core.ConstraintsComposite.ConstraintsComposite

ConstraintsComposite(parent: '_ApeGmshSession')

Solver-agnostic kinematic-constraint composite — declare on geometry, resolve to nodes after meshing.

Two-stage pipeline
  1. Declare (pre-mesh): the factory methods on this composite (equal_dof, rigid_link, rigid_diaphragm, tie, …) store :class:~apeGmsh.solvers.Constraints.ConstraintDef dataclasses describing intent at the geometry level. Defs carry no node tags and survive remeshing.
  2. Resolve (post-mesh): :meth:resolve (called automatically by :meth:Mesh.queries.get_fem_data) walks the def list and hands each one to :class:~apeGmsh.solvers.Constraints.ConstraintResolver, which produces concrete :class:~apeGmsh.solvers.Constraints.ConstraintRecord objects — actual node tags, weights, and offset vectors.

The resolved records land on the FEM broker:

  • node-pair / node-group / node_to_surface records → fem.nodes.constraints
  • surface-coupling / interpolation records → fem.elements.constraints
Constraint taxonomy

Five tiers, ordered by topology and the role each plays in a structural model:

============= ===================================================== ================================= Tier Methods Record family ============= ===================================================== ================================= 1 — Pair :meth:equal_dof, :meth:rigid_link, NodePairRecord :meth:penalty 2 — Group :meth:rigid_diaphragm, :meth:rigid_body, NodeGroupRecord :meth:kinematic_coupling 2b — Mixed :meth:node_to_surface, NodeToSurfaceRecord :meth:node_to_surface_spring (+ phantom nodes) 3 — Surface :meth:tie, :meth:distributing_coupling, InterpolationRecord :meth:embedded 4 — Contact :meth:tied_contact SurfaceCouplingRecord 5 — Fork :meth:contact, :meth:mortar (deprecated alias) ContactRecord ============= ===================================================== =================================

All constraints ultimately express the linear MPC equation u_slave = C · u_master. Tiers differ in how C is built — by node co-location (Tier 1), kinematic transformation around a master point (Tier 2), shape-function interpolation (Tier 3), or numerical integration on the interface (Tier 4).

Target identification

Most methods identify their master and slave sides by name — a part label (a key of g.parts._instances), a physical group (g.physical / .to_physical), or a label (g.labels). :meth:_add_def validates both names and raises KeyError on a typo::

g.constraints.tie(master_label="column",
                  slave_label="slab",
                  master_entities=[(2, 13)],   # optional scope
                  slave_entities=[(2, 17)])

A physical-group model therefore constrains without building Parts (tie("A_top", "B_bot") just works), matching how g.loads / g.masses already resolve names. Precedence: a Part registered under the name wins (the part node/face map is consulted first); otherwise the name resolves through the shared label→PG→part geometry resolver. A name that is simultaneously a Part and a physical group binds the Part's node set.

Optional master_entities / slave_entities (list of (dim, tag)) narrow the search to a subset of the target's entities — useful when a target has many surfaces and only one is the interface.

Exceptions to the part-label scheme ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

  • :meth:node_to_surface and :meth:node_to_surface_spring take bare tags instead. The master is a Gmsh point entity (dim=0) and slave is one or more surface entities (dim=2). Both arguments accept int, str, or (dim, tag); label validation is skipped.
  • :meth:embedded uses host_label / embedded_label to mirror the host/embedded vocabulary, but the lookup logic otherwise matches the part-label scheme.
Resolution semantics

:meth:resolve is dependency-injected — it never imports PartsRegistry. The caller (typically Mesh.queries.get_fem_data) supplies:

  • node_map: {part_label → set[int]} of mesh node tags
  • face_map: {part_label → ndarray(F, n_per_face)} built only when surface constraints (Tier 3 / 4) are present.
See Also

apeGmsh.solvers.Constraints : Module-level taxonomy and theory. apeGmsh.solvers._constraint_defs : Stage-1 dataclasses with full per-method theory. apeGmsh.solvers._constraint_resolver.ConstraintResolver : Stage-2 implementation. apeGmsh.mesh._record_set.NodeConstraintSet : Iteration helpers (rigid_link_groups, equal_dofs, rigid_diaphragms, pairs).

Examples

Declare a mix of constraints, mesh, and read out grouped rigid-link masters for OpenSees emission::

with apeGmsh(model_name="frame") as g:
    # Tier 1 — co-located nodes share x/y/z
    g.constraints.equal_dof("col", "beam", dofs=[1, 2, 3])

    # Tier 2 — slab nodes follow the centre-of-mass node
    g.constraints.rigid_diaphragm(
        "slab", "slab_master",
        master_point=(2.5, 2.5, 3.0),
        plane_normal=(0, 0, 1),
    )

    # Tier 3 — non-matching shell-to-solid interface
    g.constraints.tie(
        "shell", "solid",
        master_entities=[(2, 17)],
        slave_entities=[(2, 41)],
    )

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

    for master, slaves in fem.nodes.constraints.rigid_link_groups():
        for slave in slaves:
            ops.rigidLink("beam", master, slave)
Source code in src/apeGmsh/core/ConstraintsComposite.py
def __init__(self, parent: "_ApeGmshSession") -> None:
    self._parent = parent
    self.constraint_defs: list[ConstraintDef] = []
    self.constraint_records: list[ConstraintRecord] = []
    # Single-point constraints (BCDef) are kept apart from
    # constraint_defs: they carry no master/slave, are not in
    # _DISPATCH, and resolve to fem.nodes.sp (not
    # fem.nodes.constraints).  Mixing them into constraint_defs
    # would break _add_def validation and the resolve() dispatch.
    self._bc_defs: list[BCDef] = []
    # Contact interactions (ContactDef) are kept apart from
    # constraint_defs: they are NOT in the interpolation _DISPATCH
    # (they emit contactSurface/contact commands + the LadrunoContact
    # handler, not embeddedNode/element records) and resolve to an
    # additive fem.elements.contacts list — mirroring g.reinforce /
    # g.embed rather than the MP-constraint dispatch.
    self.contact_defs: list[ContactDef] = []
    self.contact_records: list[ContactRecord] = []
    self.contact_plane_defs: list[ContactPlaneDef] = []
    self.contact_plane_records: list[ContactPlaneRecord] = []
    # Oriented coincident-pair zeroLength interfaces (ADR 0093).
    # Another additive side-list, like the contacts above: they
    # resolve to fem.elements.interfaces, not the MP dispatch.
    self.interface_defs: list[InterfaceDef] = []
    self.interface_records: list[InterfaceRecord] = []
    # Shared phantom-node-tag high-water mark. The MP lane's
    # ConstraintResolver mints node_to_surface phantoms from
    # max(node_tags)+1 (``_next_phantom_tag``); the interface
    # resolver mints its own from a different code path. A model
    # carrying both would collide unless one counter crosses
    # between them — resolve() publishes the MP lane's final value
    # here and resolve_interfaces() starts above it.
    self._phantom_tag_high_water: int | None = None

contact

contact(master, slave, *, formulation='nts', kn=None, kt=None, mu=None, eps_n=None, eps_t=None, cohesion=None, tau_max=None, aug_tol=None, max_aug=None, ngp=None, tie=False, thickness=None, outward=None, soft=None, visc=None, consistent_tan=False, geom_tan=False, cell=None, edge_edge=False, edge_kn=None, edge_band=None, edge_mu=None, edge_kt=None, edge_cohesion=None, edge_tau_max=None, edge_consistent_tan=False, edge_soft=None, edge_alm=False, edge_aug_tol=None, master_entities=None, slave_entities=None, name=None) -> ContactDef

Declare a face-to-face contact between two meshed surfaces (fork contactSurface + contact + LadrunoContact handler).

Parameters

master, slave : str The two surface PG / part labels in contact. The master is faceted (-master); the slave is a node set (NTS, -slave) or faceted (mortar, -slave-segments). formulation : {"nts", "mortar"} "nts" = node-to-segment penalty; "mortar" = segment-to-segment ALM (the non-matching-mesh accuracy lane). kn, kt, mu : float, optional NTS normal/tangential penalty + Coulomb friction (kn may be "auto"). Rejected for mortar. eps_n, eps_t : float | "auto", optional Mortar ALM normal/tangential penalty. Rejected for NTS. cohesion, tau_max : float, optional Mortar friction-cone adhesion + Tresca cap. aug_tol, max_aug, ngp : optional Mortar Uzawa tolerance / max augmentations / slave-facet Gauss order. tie : bool Permanent mesh-tie bond (mortar only; excludes friction). thickness : float, optional 2D mortar only — the plane-model out-of-plane thickness h (-thickness; fork default 1.0). The mortar lane's interval integrals produce force per unit thickness, so the fork applies h once, at its 2D injection site, to eps_n/eps_t/ visc/cohesion/tau_max and the tie stiffness. Keep the three thickness conventions apart: the ELEMENT thickness (ops.element.FourNodeQuad(thickness=…)) is baked into element stiffness and contact never re-reads it; this h scales the EXPLICIT penalties above; and eps_n="auto" is deliberately NOT h-scaled (it already absorbs the element thickness through getInitialStiff(), so re-scaling would be an h² error). An eps_t="auto" (or an eps_t defaulted from eps_n under friction) inherits eps_n's provenance, so it h-scales only when eps_n is explicit. The NTS lane has no -thickness at all, and a 3D model is refused by name here. soft : float | bool, optional Explicit-only Courant-stable SOFT penalty (-soft): True ⇒ the fork default SOFSCL (0.10); a float ⇒ an explicit SOFSCL. Needs a base penalty (kn/eps_n); excludes tie. NTS=SOFT=1, mortar=SOFT=2. See :class:ContactDef. visc : float, optional Viscous normal-stabilisation coefficient μ_c (-visc); excludes tie. consistent_tan : bool Non-symmetric consistent friction tangent (-consistanttan) — needs an unsymmetric solver (FullGeneral / UmfPack / BandGeneral). geom_tan : bool NTS ∂n/∂u geometric normal tangent (-geomtan) for curved / large-sliding interfaces. NTS-only. cell : float, optional Broad-phase cell-size scale (-cell): the spatial-hash bucket size as a fraction of the median segment diagonal (must be > 0). A performance knob — omit for the fork default. Both formulations. edge_edge : bool Enable the perpendicular edge-edge contact fallback (-edgeedge, ADR-57 E2). Mortar-only. See :class:ContactDef. edge_kn : float | "auto", optional Edge-edge normal penalty (-edgeKn); None ⇒ the mortar penalty. edge_band : float, optional Edge-edge gap activation band (-edgeBand). edge_mu, edge_kt, edge_cohesion, edge_tau_max : float, optional Edge-edge Coulomb/Tresca friction (-edgeMu/-edgeKt/ -edgeCohesion/-edgeTauMax). edge_consistent_tan : bool Edge-edge non-symmetric Csl friction tangent (-edgeConsistentTan). edge_soft : float | bool, optional Edge-edge explicit Courant-stable SOFT penalty (-edgeSoft). edge_alm : bool Edge-edge commit-cycle augmented Lagrangian (-edgeAlm). edge_aug_tol : float, optional Edge-edge ALM tolerance (-edgeAugTol). outward : (float, float, float) | (float, float) | "winding", optional None (default) → no -outward is emitted; the fork derives a correct per-facet normal (right for separated bodies and curved / closed / solid masters). Set an explicit direction ONLY for an initially-coincident (zero-gap) FLAT contact, where the fork's per-pair sign reference is in-plane and ambiguous. A single global outward is wrong on a non-flat master. See :class:ContactDef.

**In a 2D model** this is a 2-vector ``(ox, oy)``, and a flush
interface REQUIRES one (or ``"winding"``) — the fork's 2D lanes
orient from an interface-level centroid vote that is ambiguous
there and aborts. ``outward="winding"`` (2D NTS only) declares the
side through the master chain's own winding instead of a
direction, so it also orients curved and closed masters; it needs
a fork build carrying ``-outward winding``.

master_entities, slave_entities : list of (dim, tag), optional Restrict each side to specific Gmsh entities. name : str, optional Friendly name (round-trips into the emitted deck comment).

Returns

ContactDef

Source code in src/apeGmsh/core/ConstraintsComposite.py
def contact(
    self, master, slave, *,
    formulation="nts",
    kn=None, kt=None, mu=None,
    eps_n=None, eps_t=None,
    cohesion=None, tau_max=None,
    aug_tol=None, max_aug=None, ngp=None,
    tie=False, thickness=None, outward=None,
    soft=None, visc=None, consistent_tan=False, geom_tan=False,
    cell=None,
    edge_edge=False, edge_kn=None, edge_band=None,
    edge_mu=None, edge_kt=None, edge_cohesion=None, edge_tau_max=None,
    edge_consistent_tan=False, edge_soft=None, edge_alm=False,
    edge_aug_tol=None,
    master_entities=None, slave_entities=None,
    name=None,
) -> ContactDef:
    """Declare a face-to-face contact between two meshed surfaces
    (fork `contactSurface` + `contact` + `LadrunoContact` handler).

    Parameters
    ----------
    master, slave : str
        The two surface PG / part labels in contact. The master is
        faceted (`-master`); the slave is a node set (NTS, `-slave`) or
        faceted (mortar, `-slave-segments`).
    formulation : {"nts", "mortar"}
        ``"nts"`` = node-to-segment penalty; ``"mortar"`` =
        segment-to-segment ALM (the non-matching-mesh accuracy lane).
    kn, kt, mu : float, optional
        NTS normal/tangential penalty + Coulomb friction (``kn`` may be
        ``"auto"``). Rejected for mortar.
    eps_n, eps_t : float | "auto", optional
        Mortar ALM normal/tangential penalty. Rejected for NTS.
    cohesion, tau_max : float, optional
        Mortar friction-cone adhesion + Tresca cap.
    aug_tol, max_aug, ngp : optional
        Mortar Uzawa tolerance / max augmentations / slave-facet Gauss order.
    tie : bool
        Permanent mesh-tie bond (mortar only; excludes friction).
    thickness : float, optional
        **2D mortar only** — the plane-model out-of-plane thickness ``h``
        (``-thickness``; fork default 1.0). The mortar lane's interval
        integrals produce force per unit thickness, so the fork applies
        ``h`` once, at its 2D injection site, to ``eps_n``/``eps_t``/
        ``visc``/``cohesion``/``tau_max`` and the tie stiffness. Keep the
        three thickness conventions apart: the ELEMENT thickness
        (``ops.element.FourNodeQuad(thickness=…)``) is baked into element
        stiffness and contact never re-reads it; this ``h`` scales the
        EXPLICIT penalties above; and ``eps_n="auto"`` is deliberately NOT
        h-scaled (it already absorbs the element thickness through
        ``getInitialStiff()``, so re-scaling would be an h² error). An
        ``eps_t="auto"`` (or an eps_t defaulted from eps_n under
        friction) inherits ``eps_n``'s provenance, so it h-scales only
        when ``eps_n`` is explicit. The NTS lane has no ``-thickness``
        at all, and a 3D model is refused by name here.
    soft : float | bool, optional
        Explicit-only Courant-stable SOFT penalty (``-soft``): ``True`` ⇒
        the fork default SOFSCL (0.10); a float ⇒ an explicit SOFSCL. Needs
        a base penalty (``kn``/``eps_n``); excludes ``tie``. NTS=SOFT=1,
        mortar=SOFT=2. See :class:`ContactDef`.
    visc : float, optional
        Viscous normal-stabilisation coefficient μ_c (``-visc``); excludes
        ``tie``.
    consistent_tan : bool
        Non-symmetric consistent friction tangent (``-consistanttan``) —
        needs an unsymmetric solver (FullGeneral / UmfPack / BandGeneral).
    geom_tan : bool
        NTS ∂n/∂u geometric normal tangent (``-geomtan``) for curved /
        large-sliding interfaces. NTS-only.
    cell : float, optional
        Broad-phase cell-size scale (``-cell``): the spatial-hash bucket size
        as a fraction of the median segment diagonal (must be > 0). A
        performance knob — omit for the fork default. Both formulations.
    edge_edge : bool
        Enable the perpendicular edge-edge contact fallback (``-edgeedge``,
        ADR-57 E2). **Mortar-only.** See :class:`ContactDef`.
    edge_kn : float | "auto", optional
        Edge-edge normal penalty (``-edgeKn``); ``None`` ⇒ the mortar penalty.
    edge_band : float, optional
        Edge-edge gap activation band (``-edgeBand``).
    edge_mu, edge_kt, edge_cohesion, edge_tau_max : float, optional
        Edge-edge Coulomb/Tresca friction (``-edgeMu``/``-edgeKt``/
        ``-edgeCohesion``/``-edgeTauMax``).
    edge_consistent_tan : bool
        Edge-edge non-symmetric Csl friction tangent (``-edgeConsistentTan``).
    edge_soft : float | bool, optional
        Edge-edge explicit Courant-stable SOFT penalty (``-edgeSoft``).
    edge_alm : bool
        Edge-edge commit-cycle augmented Lagrangian (``-edgeAlm``).
    edge_aug_tol : float, optional
        Edge-edge ALM tolerance (``-edgeAugTol``).
    outward : (float, float, float) | (float, float) | "winding", optional
        ``None`` (default) → no ``-outward`` is emitted; the fork derives a
        correct per-facet normal (right for separated bodies and curved /
        closed / solid masters). Set an explicit direction ONLY for an
        initially-coincident (zero-gap) FLAT contact, where the fork's
        per-pair sign reference is in-plane and ambiguous. A single global
        outward is wrong on a non-flat master. See :class:`ContactDef`.

        **In a 2D model** this is a 2-vector ``(ox, oy)``, and a flush
        interface REQUIRES one (or ``"winding"``) — the fork's 2D lanes
        orient from an interface-level centroid vote that is ambiguous
        there and aborts. ``outward="winding"`` (2D NTS only) declares the
        side through the master chain's own winding instead of a
        direction, so it also orients curved and closed masters; it needs
        a fork build carrying ``-outward winding``.
    master_entities, slave_entities : list of (dim, tag), optional
        Restrict each side to specific Gmsh entities.
    name : str, optional
        Friendly name (round-trips into the emitted deck comment).

    Returns
    -------
    ContactDef
    """
    # Silent-failures slice 2 (PR #889) supersedes the ADR 0086 D4
    # draft guard: gate only from_h5/compose sessions — a LIVE
    # session legitimately re-resolves contact defs at the next
    # extraction. For a permanent bond on a composed assembly use
    # g.constraints.tie(method='mortar', enforce='equation').
    from ._compose_errors import raise_if_from_h5_session
    raise_if_from_h5_session(self._parent, "g.constraints.contact()")
    defn = ContactDef(
        master_label=master, slave_label=slave,
        master_entities=master_entities, slave_entities=slave_entities,
        formulation=formulation,
        kn=kn, kt=kt, mu=mu,
        eps_n=eps_n, eps_t=eps_t,
        cohesion=cohesion, tau_max=tau_max,
        aug_tol=aug_tol, max_aug=max_aug, ngp=ngp,
        tie=tie, thickness=thickness,
        outward=_normalise_outward(outward),
        soft=soft, visc=visc,
        consistent_tan=consistent_tan, geom_tan=geom_tan,
        cell=cell,
        edge_edge=edge_edge, edge_kn=edge_kn, edge_band=edge_band,
        edge_mu=edge_mu, edge_kt=edge_kt, edge_cohesion=edge_cohesion,
        edge_tau_max=edge_tau_max, edge_consistent_tan=edge_consistent_tan,
        edge_soft=edge_soft, edge_alm=edge_alm, edge_aug_tol=edge_aug_tol,
        name=name,
    )
    self.contact_defs.append(defn)
    return defn

resolve_contacts

resolve_contacts(node_tags, node_coords) -> list[ContactRecord]

Resolve every :meth:contact def to a :class:ContactRecord.

Pulls the master faceted surface (+ slave node set / faceted surface) from the live Gmsh session, dropping higher-order facets to corners, mirroring the additive g.reinforce / g.embed resolve. The outward normal is carried through only when the user set it explicitly — the fork kernel derives a correct per-facet normal otherwise (see the outward note below). node_tags / node_coords are accepted for signature parity with the sibling resolvers. Serial-only (the fork contact subsystem is not parallel).

The model dimension is read once, here, and threaded — it is the single branch point of the 2D lane, mirroring :meth:resolve_interfaces. In a 2D model the master is a dim-1 curve, collected as line segments and CHAINED head-to-tail into the fork's stride-2 pair list; in a 3D model nothing below changes.

Source code in src/apeGmsh/core/ConstraintsComposite.py
def resolve_contacts(self, node_tags, node_coords) -> list[ContactRecord]:
    """Resolve every :meth:`contact` def to a :class:`ContactRecord`.

    Pulls the master faceted surface (+ slave node set / faceted surface)
    from the live Gmsh session, dropping higher-order facets to corners,
    mirroring the additive g.reinforce / g.embed resolve. The outward
    normal is carried through only when the user set it explicitly — the
    fork kernel derives a correct per-facet normal otherwise (see the
    outward note below). ``node_tags`` / ``node_coords`` are accepted for
    signature parity with the sibling resolvers. Serial-only (the fork
    contact subsystem is not parallel).

    **The model dimension is read once, here, and threaded** — it is the
    single branch point of the 2D lane, mirroring
    :meth:`resolve_interfaces`. In a 2D model the master is a dim-1
    curve, collected as line segments and CHAINED head-to-tail into the
    fork's stride-2 pair list; in a 3D model nothing below changes.
    """
    records: list[ContactRecord] = []
    if not self.contact_defs:
        self.contact_records = records
        return records

    import gmsh
    model_dim = int(gmsh.model.getDimension())

    parts = getattr(self._parent, "parts", None)
    if parts is None:
        raise RuntimeError(
            "contact: g.parts is unavailable to collect surface faces.")

    # The whole-domain scratch every 2D chain walk needs. Built LAZILY
    # on the first surface (so its refusals still carry that
    # interaction's label, byte-identical to the per-call build) and
    # then shared: without it each surface pays its own Python-level
    # pass over every domain element, and the mortar lane walks two
    # surfaces per contact.
    frames = None
    if model_dim == 2:
        domain_tags, domain_conn = self._collect_domain_elements(
            verb="contact")
        xyz = {
            int(t): np.asarray(c, dtype=float)
            for t, c in zip(np.asarray(node_tags, dtype=int).ravel(),
                            np.asarray(node_coords, dtype=float))
        }

    for defn in self.contact_defs:
        m_ents = (defn.master_entities
                  or self._entities_for_label(defn.master_label))
        s_ents = (defn.slave_entities
                  or self._entities_for_label(defn.slave_label))
        _refuse_contact_entity_dim(
            m_ents, defn.master_label, model_dim=model_dim, role="master")

        if model_dim != 2 and defn.thickness is not None:
            raise ValueError(
                f"contact: interaction "
                f"{defn.name or defn.master_label!r} declares "
                f"thickness={defn.thickness!r}, but the model is "
                f"{model_dim}D. `-thickness` is the 2D plane-model "
                f"out-of-plane thickness; a 3D mortar deck's thickness "
                f"lives in its ELEMENTS, and the fork FATALs on a 3D "
                f"pair carrying it. Drop thickness=.")

        if model_dim == 2:
            _refuse_contact_entity_dim(
                s_ents, defn.slave_label,
                model_dim=model_dim, role="slave",
                formulation=defn.formulation)
            if frames is None:
                from apeGmsh._kernel.geometry._boundary_chain import (
                    domain_frames,
                )
                frames = domain_frames(
                    domain_tags, domain_conn, xyz,
                    f" {defn.master_label!r}", verb="contact",
                    role="master")
            master_faces, master_nps = self._chain_2d_segments(
                m_ents, defn.master_label, xyz, domain_tags, domain_conn,
                role="master", frames=frames)
        else:
            master_faces = parts._collect_surface_faces(m_ents)
            if master_faces.size == 0:
                raise ValueError(
                    f"contact: master label {defn.master_label!r} resolved to "
                    f"entities but carries no surface mesh faces (is it meshed?).")
            master_faces, master_nps = _drop_to_corner_facets(master_faces)

        if defn.formulation == "nts":
            slave_nodes = self._collect_node_set(s_ents, defn.slave_label)
            slave_faces, slave_nps = None, 0
        elif model_dim == 2:
            # 2D mortar — the slave is `-slave-segments 2`, the SAME
            # chained stride-2 pair list as the master and with the same
            # hole hazard, so it goes through the same walk (an unchained
            # slave listing is silently legal fork-side too). Its
            # direction is not load-bearing — the fork's orientation vote
            # reads sigma off the MASTER segments and uses the slave tags
            # only for the centroid datum — but winding it against its own
            # material costs nothing and keeps one code path.
            slave_faces, slave_nps = self._chain_2d_segments(
                s_ents, defn.slave_label, xyz, domain_tags, domain_conn,
                role="slave", frames=frames)
            slave_nodes = None
        else:  # mortar — faceted slave
            slave_faces = parts._collect_surface_faces(s_ents)
            if slave_faces.size == 0:
                raise ValueError(
                    f"contact: mortar slave label {defn.slave_label!r} "
                    f"carries no surface mesh faces (is it meshed?).")
            slave_faces, slave_nps = _drop_to_corner_facets(slave_faces)
            slave_nodes = None

        # Outward normal: pass it ONLY when the user explicitly set it.
        # The fork contact kernel computes a correct PER-FACET normal from
        # each facet's connectivity and uses the supplied -outward purely as
        # a single global SIGN reference (LadrunoContactProjection.h
        # normalOriented). On a curved/closed/solid-part master a single
        # outward silently skips facets ~perpendicular to it and inverts
        # (→ inward, wrong contact) facets opposed to it; omitting it lets
        # the kernel use its correct per-pair (slave − segment-centroid)
        # sign reference. So never auto-derive a global outward here.
        #
        # 2D CARVE-OUT. The paragraph above settles 3D and does not reach
        # 2D, where the fork orients from ONE interface-level centroid
        # vote and aborts when it is ambiguous — flush interfaces (the
        # workhorse 2D case) and strongly curved / closed masters. The 2D
        # answer is `outward="winding"`: apeGmsh still derives no VECTOR
        # (the reasoning above is not overturned, it is out of scope) —
        # the side is declared through the master chain's ORIENTATION,
        # which the chained walk fixed against each segment's own
        # material. That is exact per segment, so it carries the cases a
        # single direction structurally cannot. The price is that the
        # fork's centroid vote is then bypassed, which is why the
        # wrong-side guard below exists.
        outward = _normalise_outward(defn.outward)
        if isinstance(outward, str) and model_dim != 2:
            raise ValueError(
                f"contact: interaction "
                f"{defn.name or defn.master_label!r} declares "
                f"outward='winding', but the model is {model_dim}D. "
                f"Declared-winding orientation is the fork's 2D NTS lane "
                f"only — a 3D master is a facet SET with no head-to-tail "
                f"chain to wind, and the 3D kernel already derives a "
                f"correct per-facet normal from connectivity. Drop "
                f"outward= in 3D, or pass an explicit (ox, oy, oz) for a "
                f"coincident flat interface.")
        if outward is not None and not isinstance(outward, str):
            outward = tuple(float(x) for x in outward)
            if model_dim == 2 and len(outward) == 3 \
                    and outward[2] != 0.0:
                raise ValueError(
                    f"contact: interaction "
                    f"{defn.name or defn.master_label!r} declares "
                    f"outward={outward!r} with a non-zero oz, but the "
                    f"model is 2D — a 2D outward lies in the plane. The "
                    f"fork takes only the 2-component form on a 2D "
                    f"surface (the 3-component 3D form is rejected "
                    f"there), so oz has nowhere to go. Pass "
                    f"outward=(ox, oy).")

        if model_dim == 2:
            # The two orientation guards apeGmsh owes a 2D deck, on
            # BOTH lanes: refuse the flush interface the fork cannot
            # orient, and refuse the master that faces away from the
            # slave — which nothing fork-side refuses (its vote only
            # picks a SIGN, and declared winding bypasses the vote
            # outright).
            #
            # Both take the slave as NODE TAGS, which the NTS lane has
            # directly and the mortar lane carries inside its own chain
            # (deduplicated — the fork's own vote dedups the
            # `-slave-segments` list for exactly this reason: every
            # interior chain vertex appears twice, and a
            # duplicate-weighted centroid can cross the magnitude floor).
            if slave_nodes is not None:
                slave_tags = slave_nodes
            else:
                slave_tags = sorted(
                    {int(t) for t in
                     np.asarray(slave_faces, dtype=np.int64).ravel()})
            if outward is None:
                _refuse_flush_without_orientation(
                    defn, master_faces, slave_tags, xyz,
                    lref_segments=slave_faces)
            from apeGmsh._kernel.geometry._boundary_chain import (
                refuse_wrong_side_master,
            )
            refuse_wrong_side_master(
                master_faces, xyz, slave_tags,
                _contact_label(defn), verb="contact")

        records.append(ContactRecord(
            kind="contact", name=defn.name,
            formulation=defn.formulation,
            master_faces=master_faces, master_nps=master_nps,
            slave_nodes=slave_nodes,
            slave_faces=slave_faces, slave_nps=slave_nps,
            outward=outward,
            kn=defn.kn, kt=defn.kt, mu=defn.mu,
            eps_n=defn.eps_n, eps_t=defn.eps_t,
            cohesion=defn.cohesion, tau_max=defn.tau_max,
            aug_tol=defn.aug_tol, max_aug=defn.max_aug, ngp=defn.ngp,
            tie=defn.tie, thickness=defn.thickness,
            soft=defn.soft, visc=defn.visc,
            consistent_tan=defn.consistent_tan, geom_tan=defn.geom_tan,
            cell=defn.cell,
            edge_edge=defn.edge_edge, edge_kn=defn.edge_kn,
            edge_band=defn.edge_band, edge_mu=defn.edge_mu,
            edge_kt=defn.edge_kt, edge_cohesion=defn.edge_cohesion,
            edge_tau_max=defn.edge_tau_max,
            edge_consistent_tan=defn.edge_consistent_tan,
            edge_soft=defn.edge_soft, edge_alm=defn.edge_alm,
            edge_aug_tol=defn.edge_aug_tol,
        ))

    self.contact_records = records
    return records

contact_plane

contact_plane(slave, *, normal, point, kn, visc=None, soft=None, slave_entities=None, name=None) -> ContactPlaneDef

Declare a rigid analytical-plane contact (fork contactPlane).

The meshed slave surface contacts a fixed infinite rigid plane (normal + point) with normal penalty kn — frictionless, no master mesh. Use it for a rigid floor / wall / foundation where the counter-body needn't be meshed. Optional visc (viscous normal stabilisation) and soft (explicit Courant-stable SOFT penalty; True ⇒ the fork default SOFSCL 0.10, or a float SOFSCL). Fork-only at run time.

Parameters

slave : str The meshed surface PG / part label whose nodes contact the plane. In a 2D model it is the meshed boundary CURVE (a dim-1 PG) or a point set — naming the dim-2 plane collects every interior node of the body and is refused by name. normal : (float, float, float) | (float, float) The plane's outward unit normal (toward the slave / open side). In a 2D model it is the 2-vector (nx, ny); it is z-padded internally and emitted as the fork's permanently-valid zero-padded 9-argument form, so there is only ever one grammar to read. point : (float, float, float) | (float, float) Any point on the plane; (px, py) in a 2D model. kn : float Normal penalty stiffness (required — there is no "auto" on contactPlane). visc : float, optional Viscous normal-stabilisation coefficient μ_c (-visc). soft : float | bool, optional Explicit-only Courant-stable SOFT penalty (-soft). slave_entities : list of (dim, tag), optional Restrict the slave to specific Gmsh entities. name : str, optional Friendly name (round-trips into the emitted deck comment).

Returns

ContactPlaneDef

Source code in src/apeGmsh/core/ConstraintsComposite.py
def contact_plane(
    self, slave, *, normal, point, kn, visc=None, soft=None,
    slave_entities=None, name=None,
) -> ContactPlaneDef:
    """Declare a rigid analytical-plane contact (fork ``contactPlane``).

    The meshed ``slave`` surface contacts a fixed infinite **rigid plane**
    (``normal`` + ``point``) with normal penalty ``kn`` — frictionless, no
    master mesh. Use it for a rigid floor / wall / foundation where the
    counter-body needn't be meshed. Optional ``visc`` (viscous normal
    stabilisation) and ``soft`` (explicit Courant-stable SOFT penalty;
    ``True`` ⇒ the fork default SOFSCL 0.10, or a float SOFSCL). Fork-only
    at run time.

    Parameters
    ----------
    slave : str
        The meshed surface PG / part label whose nodes contact the plane.
        **In a 2D model** it is the meshed boundary CURVE (a dim-1 PG) or
        a point set — naming the dim-2 plane collects every interior node
        of the body and is refused by name.
    normal : (float, float, float) | (float, float)
        The plane's outward unit normal (toward the slave / open side).
        **In a 2D model** it is the 2-vector ``(nx, ny)``; it is z-padded
        internally and emitted as the fork's permanently-valid zero-padded
        9-argument form, so there is only ever one grammar to read.
    point : (float, float, float) | (float, float)
        Any point on the plane; ``(px, py)`` in a 2D model.
    kn : float
        Normal penalty stiffness (**required** — there is no ``"auto"`` on
        ``contactPlane``).
    visc : float, optional
        Viscous normal-stabilisation coefficient μ_c (``-visc``).
    soft : float | bool, optional
        Explicit-only Courant-stable SOFT penalty (``-soft``).
    slave_entities : list of (dim, tag), optional
        Restrict the slave to specific Gmsh entities.
    name : str, optional
        Friendly name (round-trips into the emitted deck comment).

    Returns
    -------
    ContactPlaneDef
    """
    from ._compose_errors import raise_if_from_h5_session
    raise_if_from_h5_session(
        self._parent, "g.constraints.contact_plane()")
    defn = ContactPlaneDef(
        slave_label=slave, slave_entities=slave_entities,
        normal=tuple(normal), point=tuple(point),
        kn=kn, visc=visc, soft=soft, name=name,
    )
    self.contact_plane_defs.append(defn)
    return defn

resolve_contact_planes

resolve_contact_planes(node_tags, node_coords) -> list[ContactPlaneRecord]

Resolve every :meth:contact_plane def to a :class:ContactPlaneRecord — the slave node set is pulled from the live Gmsh session (mirroring the NTS slave of :meth:resolve_contacts). node_tags / node_coords are accepted for signature parity with the sibling resolvers. Serial-only (the fork contact subsystem is not parallel).

The model dimension is read once, here, and threaded — the :meth:resolve_contacts idiom. This lane has no master mesh and no facets, so the whole 2D difference is the slave gate below plus the refusal of an out-of-plane normal / point: the rigid-plane lane keeps the fork's ndf >= ndm (its adapter couples the first ndm DOFs by construction, which is what lets a 3D ndf-6 shell sit on a plane), so unlike the NTS/mortar lanes there is nothing else to branch on.

Source code in src/apeGmsh/core/ConstraintsComposite.py
def resolve_contact_planes(
    self, node_tags, node_coords,
) -> list[ContactPlaneRecord]:
    """Resolve every :meth:`contact_plane` def to a
    :class:`ContactPlaneRecord` — the slave node set is pulled from the live
    Gmsh session (mirroring the NTS slave of :meth:`resolve_contacts`).
    ``node_tags`` / ``node_coords`` are accepted for signature parity with
    the sibling resolvers. Serial-only (the fork contact subsystem is not
    parallel).

    **The model dimension is read once, here, and threaded** — the
    :meth:`resolve_contacts` idiom. This lane has no master mesh and no
    facets, so the whole 2D difference is the slave gate below plus the
    refusal of an out-of-plane normal / point: the rigid-plane lane keeps
    the fork's ``ndf >= ndm`` (its adapter couples the first ``ndm`` DOFs
    by construction, which is what lets a 3D ndf-6 shell sit on a plane),
    so unlike the NTS/mortar lanes there is nothing else to branch on.
    """
    records: list[ContactPlaneRecord] = []
    if not self.contact_plane_defs:
        self.contact_plane_records = records
        return records

    import gmsh
    model_dim = int(gmsh.model.getDimension())

    for defn in self.contact_plane_defs:
        s_ents = (defn.slave_entities
                  or self._entities_for_label(defn.slave_label))
        _refuse_contact_entity_dim(
            s_ents, defn.slave_label, model_dim=model_dim,
            role="slave", verb="contact_plane")
        if model_dim == 2:
            _refuse_contact_plane_out_of_plane(defn)
        slave_nodes = self._collect_node_set(s_ents, defn.slave_label)
        if not slave_nodes:
            raise ValueError(
                f"contact_plane: slave label {defn.slave_label!r} resolved "
                f"to no surface nodes (is it meshed?).")
        records.append(ContactPlaneRecord(
            kind="contact_plane", name=defn.name,
            slave_nodes=slave_nodes,
            normal=tuple(float(x) for x in defn.normal),
            point=tuple(float(x) for x in defn.point),
            kn=defn.kn, visc=defn.visc, soft=defn.soft,
        ))

    self.contact_plane_records = records
    return records

interface

interface(master, slave, *, normal, tangential, thickness, tolerance=1e-06, slave_ndf=None, master_entities=None, slave_entities=None, name=None) -> InterfaceDef

Declare an oriented coincident-pair zeroLength interface (ADR 0093).

One zeroLength spring per coincident (master, slave) node pair, with the local axes taken per pair from the master face geometry — so a curved master's normal follows the face from wall to crown instead of collapsing to one average frame — and the normal / tangential laws scaled by each pair's tributary area. The point of the verb is a unilateral (compression-only, separation allowed) and strength-capped interface: with a bilateral bond a converging ground drives the liner's demand without bound.

2D line masters only in v1; a 3D model or a surface master raises :class:NotImplementedError (ADR 0093 D2).

Parameters

master, slave : str The master curve PG / part label — a free boundary of the meshed 2D continuum — and the node-for-node coincident slave label. The two node sets must be disjoint. normal : NormalLaw Per-area normal law: NormalLaw(kind="ent"|"epp_gap"| "elastic", k_per_area=..., ...). Declarative kernel data, translated to a typed uniaxial material (scaled by A_trib) only at emit. tangential : TangentialLaw Per-area tangential law: TangentialLaw(kind="epp"| "elastic", k_per_area=..., tau_b=...). thickness : float Out-of-plane thickness (required, > 0) — A_trib = ell_trib * thickness. tolerance : float Coincidence radius for the node pairing. A slave with no master inside it is an error, never a silent skip. slave_ndf : {None, 2, 3} The ndf the slave wire will be declared with. None / 2 ⇒ the slave matches the 2D continuum and the pair connects directly; 3 ⇒ a beam slave, so each pair gets the phantom bridge of ADR 0093 D4 (the fork refuses a mixed-ndf zeroLength). Explicit by design — see :class:~apeGmsh._kernel.defs.constraints.InterfaceDef. master_entities, slave_entities : list of (dim, tag), optional Restrict each side to specific Gmsh entities. name : str, optional Friendly name (carried onto every resolved record).

Returns

InterfaceDef

Source code in src/apeGmsh/core/ConstraintsComposite.py
def interface(
    self, master, slave, *,
    normal, tangential, thickness,
    tolerance=1e-6, slave_ndf=None,
    master_entities=None, slave_entities=None,
    name=None,
) -> InterfaceDef:
    """Declare an oriented coincident-pair ``zeroLength`` interface
    (ADR 0093).

    One ``zeroLength`` spring per coincident (master, slave) node
    pair, with the local axes taken **per pair** from the master
    face geometry — so a curved master's normal follows the face
    from wall to crown instead of collapsing to one average frame —
    and the normal / tangential laws scaled by each pair's
    tributary area. The point of the verb is a *unilateral*
    (compression-only, separation allowed) and *strength-capped*
    interface: with a bilateral bond a converging ground drives the
    liner's demand without bound.

    2D line masters only in v1; a 3D model or a surface master
    raises :class:`NotImplementedError` (ADR 0093 D2).

    Parameters
    ----------
    master, slave : str
        The master curve PG / part label — a **free boundary** of
        the meshed 2D continuum — and the node-for-node coincident
        slave label. The two node sets must be disjoint.
    normal : NormalLaw
        Per-area normal law: ``NormalLaw(kind="ent"|"epp_gap"|
        "elastic", k_per_area=..., ...)``. Declarative kernel data,
        translated to a typed uniaxial material (scaled by
        ``A_trib``) only at emit.
    tangential : TangentialLaw
        Per-area tangential law: ``TangentialLaw(kind="epp"|
        "elastic", k_per_area=..., tau_b=...)``.
    thickness : float
        Out-of-plane thickness (**required**, ``> 0``) —
        ``A_trib = ell_trib * thickness``.
    tolerance : float
        Coincidence radius for the node pairing. A slave with no
        master inside it is an error, never a silent skip.
    slave_ndf : {None, 2, 3}
        The ndf the slave wire will be declared with. ``None`` /
        ``2`` ⇒ the slave matches the 2D continuum and the pair
        connects directly; ``3`` ⇒ a beam slave, so each pair gets
        the phantom bridge of ADR 0093 D4 (the fork refuses a
        mixed-ndf ``zeroLength``). Explicit by design — see
        :class:`~apeGmsh._kernel.defs.constraints.InterfaceDef`.
    master_entities, slave_entities : list of (dim, tag), optional
        Restrict each side to specific Gmsh entities.
    name : str, optional
        Friendly name (carried onto every resolved record).

    Returns
    -------
    InterfaceDef
    """
    # Same gate as contact(): a from_h5 / composed session has no
    # live geometry to re-derive the per-pair frames from (ADR 0086
    # precedent, silent-failures slice 2).
    from ._compose_errors import raise_if_from_h5_session
    raise_if_from_h5_session(self._parent, "g.constraints.interface()")
    self._refuse_3d_interface()
    defn = InterfaceDef(
        master_label=master, slave_label=slave,
        master_entities=master_entities, slave_entities=slave_entities,
        normal=normal, tangential=tangential,
        thickness=thickness, tolerance=tolerance,
        slave_ndf=slave_ndf, name=name,
    )
    self.interface_defs.append(defn)
    return defn

resolve_interfaces

resolve_interfaces(node_tags, node_coords) -> list

Resolve every :meth:interface def to :class:InterfaceRecord\ s.

Gathers the live-Gmsh inputs — both node sets, the master's boundary line elements, and the model's 2D domain elements — and hands the geometry math to :func:~apeGmsh._kernel.resolvers._interface_resolver.resolve_interface_records (pure kernel, no Gmsh), mirroring how :meth:resolve_contacts gathers and delegates.

Must run after :meth:resolve so the interface phantom tags start above the MP lane's phantom high-water mark; the factory orders them that way.

Source code in src/apeGmsh/core/ConstraintsComposite.py
def resolve_interfaces(self, node_tags, node_coords) -> list:
    """Resolve every :meth:`interface` def to :class:`InterfaceRecord`\\ s.

    Gathers the live-Gmsh inputs — both node sets, the master's
    boundary line elements, and the model's 2D domain elements —
    and hands the geometry math to
    :func:`~apeGmsh._kernel.resolvers._interface_resolver.resolve_interface_records`
    (pure kernel, no Gmsh), mirroring how :meth:`resolve_contacts`
    gathers and delegates.

    Must run **after** :meth:`resolve` so the interface phantom tags
    start above the MP lane's phantom high-water mark; the factory
    orders them that way.
    """
    from apeGmsh._kernel.resolvers._interface_resolver import (
        resolve_interface_records,
    )

    records: list = []
    if not self.interface_defs:
        self.interface_records = records
        return records

    import gmsh
    model_dim = int(gmsh.model.getDimension())
    if model_dim != 2:
        raise NotImplementedError(
            f"interface: the model is {model_dim}D and only 2D line "
            f"masters are implemented (ADR 0093 D2).")

    domain_tags, domain_conn = self._collect_domain_elements()
    # Start above BOTH the model's own node tags and any phantom the
    # MP lane already minted this resolve (see
    # ``_phantom_tag_high_water``).
    next_phantom = int(np.max(np.asarray(node_tags, dtype=int))) + 1
    if self._phantom_tag_high_water is not None:
        next_phantom = max(next_phantom, int(self._phantom_tag_high_water))

    for defn in self.interface_defs:
        m_ents = (defn.master_entities
                  or self._entities_for_label(defn.master_label))
        s_ents = (defn.slave_entities
                  or self._entities_for_label(defn.slave_label))
        bad_dims = sorted({int(d) for d, _ in m_ents} - {1})
        if bad_dims:
            raise NotImplementedError(
                f"interface: master label {defn.master_label!r} "
                f"resolves to entities of dimension {bad_dims}, but "
                f"only 2D line (dim-1) masters are implemented — a "
                f"surface master is deferred (ADR 0093 D2).")

        master_nodes = self._collect_node_set(
            m_ents, defn.master_label, kind="interface", role="master")
        slave_nodes = self._collect_node_set(
            s_ents, defn.slave_label, kind="interface", role="slave")
        edges = self._collect_master_edges(m_ents, defn.master_label)

        recs, next_phantom = resolve_interface_records(
            node_tags, node_coords,
            master_nodes=master_nodes,
            slave_nodes=slave_nodes,
            master_edges=edges,
            domain_elem_tags=domain_tags,
            domain_elem_nodes=domain_conn,
            normal_law=defn.normal,
            tangential_law=defn.tangential,
            thickness=defn.thickness,
            tolerance=defn.tolerance,
            slave_ndf=defn.slave_ndf,
            ndm=model_dim,
            phantom_tag_start=next_phantom,
            name=defn.name,
        )
        records.extend(recs)

    # Deliberately NOT written back to ``_phantom_tag_high_water``:
    # that field carries the MP lane's mark *for this extraction*,
    # and ``resolve()`` refreshes it every time. Folding this run's
    # advance into it would make a second ``get_fem_data()`` on the
    # same session mint different phantom tags — a tag-determinism
    # break (ADR 0027). Nothing downstream of here mints phantoms.
    self.interface_records = records
    return records

bc

bc(target=None, *, pg=None, label=None, tag=None, dofs=None, name=None) -> BCDef

Homogeneous single-point constraint — fix a pattern to ground.

The natural (essential / Dirichlet) boundary condition: every mesh node in the resolved pattern gets ops.fix(node, *mask) downstream. There is no master and no slave — unlike every other method on this composite, this is a constraint to ground, not between two parts. It resolves into fem.nodes.sp (homogeneous :class:SPRecord\ s) — the same broker channel as g.displacements.surfacenot fem.nodes.constraints.

Because it is a permanent constraint (not a pattern-scoped quantity), it lives here on g.constraints rather than on g.displacements: there is no load-pattern context to accidentally scope it into, and the downstream emitter places it in the model → bcs → patterns deck order via ops.fix.

Parameters

target : str or list[(dim, tag)] Pattern to fix. Resolved label → physical group → raw tags (or a mesh selection) — the same flexible target model as g.displacements.surface. Pass pg= / label= / tag= instead to force a specific resolution path. dofs : list[int], optional Restraint mask (1 = constrained, 0 = free), in DOF order [ux, uy, uz, rx, ry, rz]. Default [1, 1, 1] (pin all translations). This is the OpenSees ops.fix / face_sp convention — not the index-list convention used by :meth:equal_dof (dofs=[1,2,3]). name : str, optional Friendly name shown in summaries / the viewer.

Returns

BCDef The stored definition; the same object is appended to self._bc_defs.

Warnings

Resolution is dimension-agnostic — a point, edge, surface, or volume pattern all just contribute their mesh nodes. Pointing a BC at a volume physical group therefore fixes every interior node of the solid, which is almost never intended; target a boundary surface/edge instead.

Examples

::

g.constraints.bc("base_face")                  # pin x,y,z
g.constraints.bc(pg="Supports", dofs=[1, 1, 0])
g.constraints.bc(label="col.base",
                 dofs=[1, 1, 1, 1, 1, 1])       # full fixity
Source code in src/apeGmsh/core/ConstraintsComposite.py
def bc(self, target=None, *, pg=None, label=None, tag=None,
       dofs=None, name=None) -> BCDef:
    """Homogeneous single-point constraint — fix a pattern to ground.

    The natural (essential / Dirichlet) boundary condition: every
    mesh node in the resolved pattern gets ``ops.fix(node, *mask)``
    downstream. There is **no master and no slave** — unlike every
    other method on this composite, this is a constraint *to
    ground*, not between two parts. It resolves into
    ``fem.nodes.sp`` (homogeneous :class:`SPRecord`\\ s) — the same
    broker channel as ``g.displacements.surface`` — **not**
    ``fem.nodes.constraints``.

    Because it is a *permanent* constraint (not a pattern-scoped
    quantity), it lives here on ``g.constraints`` rather than on
    ``g.displacements``: there is no load-pattern context to accidentally
    scope it into, and the downstream emitter places it in the
    ``model → bcs → patterns`` deck order via ``ops.fix``.

    Parameters
    ----------
    target : str or list[(dim, tag)]
        Pattern to fix. Resolved label → physical group → raw
        tags (or a mesh selection) — the same flexible target
        model as ``g.displacements.surface``. Pass ``pg=`` / ``label=``
        / ``tag=`` instead to force a specific resolution path.
    dofs : list[int], optional
        Restraint **mask** (``1`` = constrained, ``0`` = free), in
        DOF order ``[ux, uy, uz, rx, ry, rz]``. Default
        ``[1, 1, 1]`` (pin all translations). This is the
        OpenSees ``ops.fix`` / ``face_sp`` convention — **not**
        the index-list convention used by
        :meth:`equal_dof` (``dofs=[1,2,3]``).
    name : str, optional
        Friendly name shown in summaries / the viewer.

    Returns
    -------
    BCDef
        The stored definition; the same object is appended to
        ``self._bc_defs``.

    Warnings
    --------
    Resolution is dimension-agnostic — a point, edge, surface, or
    volume pattern all just contribute their mesh nodes. Pointing
    a BC at a **volume** physical group therefore fixes *every
    interior node of the solid*, which is almost never intended;
    target a boundary surface/edge instead.

    Examples
    --------
    ::

        g.constraints.bc("base_face")                  # pin x,y,z
        g.constraints.bc(pg="Supports", dofs=[1, 1, 0])
        g.constraints.bc(label="col.base",
                         dofs=[1, 1, 1, 1, 1, 1])       # full fixity
    """
    t, src = self._parent.loads._coalesce_target(
        target, pg=pg, label=label, tag=tag)
    defn = BCDef(target=t, target_source=src,
                 dofs=list(dofs) if dofs is not None else [1, 1, 1],
                 name=name)
    self._bc_defs.append(defn)
    # Phase 3B.2d / ADR 0038 — chain-phase routing.  See
    # ``MassesComposite._add_def`` for the contract.
    from apeGmsh._kernel.resolvers._chain_phase_router import (
        try_chain_phase_route,
    )
    try_chain_phase_route(self._parent, defn)
    bump = getattr(self._parent, "_bump_fem_counter", None)
    if bump is not None:
        bump()
    return defn

resolve_bcs

resolve_bcs(node_tags, *, node_map=None) -> list

Resolve every :meth:bc def to homogeneous SPRecord\ s.

Mirrors the load/SP resolution path: each BCDef target is run through the loads composite's dimension-agnostic _target_nodes (label → PG → tag → mesh-selection, any dim), then one SPRecord(value=0.0, is_homogeneous=True) is emitted per restrained DOF per node.

Fails loud — consistent with :meth:_resolve_nodes and the resolver contract — if a pattern resolves to zero mesh nodes: a BC that silently binds nothing is worse than one that errors.

Source code in src/apeGmsh/core/ConstraintsComposite.py
def resolve_bcs(self, node_tags, *, node_map=None) -> list:
    """Resolve every :meth:`bc` def to homogeneous ``SPRecord``\\ s.

    Mirrors the load/SP resolution path: each ``BCDef`` target is
    run through the loads composite's dimension-agnostic
    ``_target_nodes`` (label → PG → tag → mesh-selection, any
    dim), then one ``SPRecord(value=0.0, is_homogeneous=True)`` is
    emitted per restrained DOF per node.

    Fails loud — consistent with :meth:`_resolve_nodes` and the
    resolver contract — if a pattern resolves to zero mesh nodes:
    a BC that silently binds nothing is worse than one that errors.
    """
    from apeGmsh._kernel.records._loads import SPRecord

    if not self._bc_defs:
        return []

    loads = self._parent.loads
    all_nodes = {int(t) for t in node_tags}
    out: list = []
    for defn in self._bc_defs:
        nodes = loads._target_nodes(
            defn.target, node_map or {}, all_nodes,
            source=defn.target_source, expected_dim=None,
        )
        if not nodes:
            raise ValueError(
                f"bc: target {defn.target!r} (source="
                f"{defn.target_source!r}) resolved to zero mesh "
                f"nodes — check it is meshed and names a real "
                f"label / physical group / entity. Refusing to "
                f"emit an empty boundary condition.")
        for nid in sorted(nodes):
            for d_idx, mask in enumerate(defn.dofs):
                if mask != 1:
                    continue
                out.append(SPRecord(
                    name=defn.name,
                    node_id=int(nid),
                    dof=d_idx + 1,
                    value=0.0,
                    is_homogeneous=True,
                ))
    return out

equal_dof

equal_dof(master_label, slave_label, *, master_entities=None, slave_entities=None, dofs=None, tolerance=1e-06, name=None) -> EqualDOFDef

Tie matching DOFs between co-located node pairs.

At resolution time the resolver finds every master node whose coordinates match a slave node within tolerance and emits one :class:~apeGmsh.solvers.Constraints.NodePairRecord per match. Each pair becomes ops.equalDOF(master, slave, *dofs) downstream — i.e. u_slave[i] = u_master[i] for every i in dofs.

Use this for conformal interfaces only — meshes that share nodes at the boundary. For non-matching meshes use :meth:tie.

Parameters

master_label : str Part, physical-group, or label name whose nodes drive the constraint. slave_label : str Part, physical-group, or label name whose matching nodes are slaved. master_entities, slave_entities : list of (dim, tag), optional Restrict the node search to specific Gmsh entities of each side. Useful when only one face of a multi-face part is the interface. dofs : list[int], optional 1-based DOF indices to constrain (1=ux, 2=uy, 3=uz, 4=rx, 5=ry, 6=rz). None (default) means all DOFs available — the actual count depends on the model's ndf. tolerance : float, default 1e-6 Maximum distance (in model units) between two nodes for them to be treated as co-located. Unit-sensitive: 1e-3 for millimetre models, 1e-6 for metre models. name : str, optional Friendly name shown in :meth:summary and the viewer.

Returns

EqualDOFDef The stored definition; the same object is appended to self.constraint_defs.

Raises

KeyError If master_label or slave_label is not in g.parts.

See Also

tie : Non-matching mesh equivalent (shape-function projection). rigid_link : Add a kinematic offset on top of co-location.

Examples

Translational continuity between a column and a beam at a joint::

g.constraints.equal_dof(
    "column", "beam",
    dofs=[1, 2, 3],
    tolerance=1e-3,        # mm model
)
Source code in src/apeGmsh/core/ConstraintsComposite.py
def equal_dof(self, master_label, slave_label, *, master_entities=None,
              slave_entities=None, dofs=None, tolerance=1e-6,
              name=None) -> EqualDOFDef:
    """Tie matching DOFs between **co-located** node pairs.

    At resolution time the resolver finds every master node
    whose coordinates match a slave node within ``tolerance``
    and emits one
    :class:`~apeGmsh.solvers.Constraints.NodePairRecord` per
    match. Each pair becomes ``ops.equalDOF(master, slave, *dofs)``
    downstream — i.e. ``u_slave[i] = u_master[i]`` for every
    ``i`` in ``dofs``.

    Use this for **conformal** interfaces only — meshes that share
    nodes at the boundary. For non-matching meshes use :meth:`tie`.

    Parameters
    ----------
    master_label : str
        Part, physical-group, or label name whose nodes drive the
        constraint.
    slave_label : str
        Part, physical-group, or label name whose matching nodes
        are slaved.
    master_entities, slave_entities : list of (dim, tag), optional
        Restrict the node search to specific Gmsh entities of
        each side. Useful when only one face of a multi-face
        part is the interface.
    dofs : list[int], optional
        1-based DOF indices to constrain (``1=ux, 2=uy, 3=uz,
        4=rx, 5=ry, 6=rz``). ``None`` (default) means *all DOFs
        available* — the actual count depends on the model's
        ``ndf``.
    tolerance : float, default 1e-6
        Maximum distance (in model units) between two nodes for
        them to be treated as co-located. **Unit-sensitive**:
        ``1e-3`` for millimetre models, ``1e-6`` for metre
        models.
    name : str, optional
        Friendly name shown in :meth:`summary` and the viewer.

    Returns
    -------
    EqualDOFDef
        The stored definition; the same object is appended to
        ``self.constraint_defs``.

    Raises
    ------
    KeyError
        If ``master_label`` or ``slave_label`` is not in
        ``g.parts``.

    See Also
    --------
    tie : Non-matching mesh equivalent (shape-function projection).
    rigid_link : Add a kinematic offset on top of co-location.

    Examples
    --------
    Translational continuity between a column and a beam at a
    joint::

        g.constraints.equal_dof(
            "column", "beam",
            dofs=[1, 2, 3],
            tolerance=1e-3,        # mm model
        )
    """
    return self._add_def(EqualDOFDef(
        master_label=master_label, slave_label=slave_label,
        master_entities=master_entities, slave_entities=slave_entities,
        dofs=dofs, tolerance=tolerance, name=name))

equal_dof_mixed

equal_dof_mixed(master_label, slave_label, *, dof_pairs, master_entities=None, slave_entities=None, tolerance=1e-06, name=None) -> EqualDOFMixedDef

Tie differently-numbered DOFs between co-located node pairs.

The mixed analog of :meth:equal_dof: rather than tying DOF i to DOF i, each (retained_dof, constrained_dof) couple in dof_pairs is tied explicitly — so master ux can drive slave rz, etc. Resolves like equal_dof (one record per co-located pair) and emits ops.equalDOF_Mixed(R, C, numDOF, RDOF1, CDOF1, ...) per pair.

Use this for conformal interfaces where the two sides expose the coupled quantity under different DOF indices (e.g. tying a solid's translation to a shell's drilling rotation). For matching DOFs use :meth:equal_dof; for non-matching meshes use :meth:tie.

Parameters

master_label : str Part label whose nodes are retained (the R node). slave_label : str Part label whose matching nodes are constrained (C). dof_pairs : list of (int, int) (retained_dof, constrained_dof) couples, 1-based (1=ux, 2=uy, 3=uz, 4=rx, 5=ry, 6=rz). Required and non-empty; the two members of a couple may differ. master_entities, slave_entities : list of (dim, tag), optional Restrict the node search to specific Gmsh entities of each side. tolerance : float, default 1e-6 Co-location distance (model units). Unit-sensitive — see :meth:equal_dof. name : str, optional Friendly name shown in :meth:summary and the viewer.

Returns

EqualDOFMixedDef The stored definition; also appended to self.constraint_defs.

See Also

equal_dof : Same-DOF co-located tie (the common case).

Examples

Tie a solid face's z-translation to a shell edge's drilling DOF::

g.constraints.equal_dof_mixed(
    "solid", "shell",
    dof_pairs=[(3, 6)],    # master uz → slave rz
    tolerance=1e-3,        # mm model
)
Source code in src/apeGmsh/core/ConstraintsComposite.py
def equal_dof_mixed(self, master_label, slave_label, *, dof_pairs,
                    master_entities=None, slave_entities=None,
                    tolerance=1e-6, name=None) -> EqualDOFMixedDef:
    """Tie *differently-numbered* DOFs between **co-located** node pairs.

    The mixed analog of :meth:`equal_dof`: rather than tying DOF
    ``i`` to DOF ``i``, each ``(retained_dof, constrained_dof)``
    couple in ``dof_pairs`` is tied explicitly — so master ``ux``
    can drive slave ``rz``, etc. Resolves like ``equal_dof`` (one
    record per co-located pair) and emits
    ``ops.equalDOF_Mixed(R, C, numDOF, RDOF1, CDOF1, ...)`` per pair.

    Use this for **conformal** interfaces where the two sides expose
    the coupled quantity under different DOF indices (e.g. tying a
    solid's translation to a shell's drilling rotation). For matching
    DOFs use :meth:`equal_dof`; for non-matching meshes use :meth:`tie`.

    Parameters
    ----------
    master_label : str
        Part label whose nodes are **retained** (the ``R`` node).
    slave_label : str
        Part label whose matching nodes are **constrained** (``C``).
    dof_pairs : list of (int, int)
        ``(retained_dof, constrained_dof)`` couples, 1-based
        (``1=ux, 2=uy, 3=uz, 4=rx, 5=ry, 6=rz``). Required and
        non-empty; the two members of a couple may differ.
    master_entities, slave_entities : list of (dim, tag), optional
        Restrict the node search to specific Gmsh entities of each side.
    tolerance : float, default 1e-6
        Co-location distance (model units). **Unit-sensitive** — see
        :meth:`equal_dof`.
    name : str, optional
        Friendly name shown in :meth:`summary` and the viewer.

    Returns
    -------
    EqualDOFMixedDef
        The stored definition; also appended to ``self.constraint_defs``.

    See Also
    --------
    equal_dof : Same-DOF co-located tie (the common case).

    Examples
    --------
    Tie a solid face's z-translation to a shell edge's drilling DOF::

        g.constraints.equal_dof_mixed(
            "solid", "shell",
            dof_pairs=[(3, 6)],    # master uz → slave rz
            tolerance=1e-3,        # mm model
        )
    """
    return self._add_def(EqualDOFMixedDef(
        master_label=master_label, slave_label=slave_label,
        master_entities=master_entities, slave_entities=slave_entities,
        dof_pairs=[tuple(p) for p in dof_pairs],
        tolerance=tolerance, name=name))
rigid_link(master_label, slave_label, *, link_type='beam', master_point=None, slave_entities=None, tolerance=1e-06, name=None) -> RigidLinkDef

Rigid bar between a master node and one or more slave nodes.

Each slave node is constrained to follow the master through a rigid offset arm r = x_slave − x_master::

link_type="beam":     u_s = u_m + θ_m × r,   θ_s = θ_m
link_type="rod":      u_s = u_m + θ_m × r,   θ_s free

Use "beam" for fully rigid kinematic offsets (eccentric connections, lumped-mass arms, fictitious rigid extensions). Use "rod" when you want to transmit translation but leave the slave free to rotate — e.g. pinned eccentric supports.

master_label : str Part, physical-group, or label name that owns the master node. The master is identified inside this part either by master_point (proximity match) or by being the unique node when the part collapses to a single point. slave_label : str Part, physical-group, or label name whose nodes become slaves. link_type : "beam" or "rod", default "beam" "beam" couples 6 DOFs with rotational offset; "rod" couples translations only. master_point : (x, y, z), optional Explicit master coordinates. If None, the resolver picks the master node by proximity within tolerance. slave_entities : list of (dim, tag), optional Restrict the slave node search to specific entities. tolerance : float, default 1e-6 Proximity tolerance for master-node detection. name : str, optional Friendly name.

RigidLinkDef

KeyError If either label is not in g.parts.

kinematic_coupling : Same idea, but lets you pick which DOFs to couple instead of the fixed beam/rod sets. node_to_surface : When the slave side has only translational DOFs (3-DOF solid nodes).

Lumped-mass arm at the top of a tower::

g.constraints.rigid_link(
    "tower_top", "lumped_mass",
    link_type="beam",
    master_point=(0, 0, 30.0),
)
Source code in src/apeGmsh/core/ConstraintsComposite.py
def rigid_link(self, master_label, slave_label, *, link_type="beam",
               master_point=None, slave_entities=None,
               tolerance=1e-6, name=None) -> RigidLinkDef:
    """Rigid bar between a master node and one or more slave nodes.

    Each slave node is constrained to follow the master through
    a rigid offset arm ``r = x_slave − x_master``::

        link_type="beam":     u_s = u_m + θ_m × r,   θ_s = θ_m
        link_type="rod":      u_s = u_m + θ_m × r,   θ_s free

    Use ``"beam"`` for fully rigid kinematic offsets (eccentric
    connections, lumped-mass arms, fictitious rigid extensions).
    Use ``"rod"`` when you want to transmit translation but leave
    the slave free to rotate — e.g. pinned eccentric supports.

    Parameters
    ----------
    master_label : str
        Part, physical-group, or label name that owns the master
        node. The master is
        identified inside this part either by ``master_point``
        (proximity match) or by being the unique node when the
        part collapses to a single point.
    slave_label : str
        Part, physical-group, or label name whose nodes become
        slaves.
    link_type : ``"beam"`` or ``"rod"``, default ``"beam"``
        ``"beam"`` couples 6 DOFs with rotational offset;
        ``"rod"`` couples translations only.
    master_point : (x, y, z), optional
        Explicit master coordinates. If ``None``, the resolver
        picks the master node by proximity within ``tolerance``.
    slave_entities : list of (dim, tag), optional
        Restrict the slave node search to specific entities.
    tolerance : float, default 1e-6
        Proximity tolerance for master-node detection.
    name : str, optional
        Friendly name.

    Returns
    -------
    RigidLinkDef

    Raises
    ------
    KeyError
        If either label is not in ``g.parts``.

    See Also
    --------
    kinematic_coupling : Same idea, but lets you pick which DOFs
        to couple instead of the fixed beam/rod sets.
    node_to_surface : When the slave side has only translational
        DOFs (3-DOF solid nodes).

    Examples
    --------
    Lumped-mass arm at the top of a tower::

        g.constraints.rigid_link(
            "tower_top", "lumped_mass",
            link_type="beam",
            master_point=(0, 0, 30.0),
        )
    """
    return self._add_def(RigidLinkDef(
        master_label=master_label, slave_label=slave_label,
        link_type=link_type, master_point=master_point,
        slave_entities=slave_entities, tolerance=tolerance, name=name))

penalty

penalty(master_label, slave_label, *, stiffness=10000000000.0, dofs=None, tolerance=1e-06, name=None) -> PenaltyDef

Soft-spring (penalty) coupling between co-located node pairs.

Numerically approximates :meth:equal_dof as stiffness → ∞. The resolver still requires master and slave nodes to be co-located within tolerance, but downstream the constraint is enforced by inserting a stiff spring element between each pair instead of a hard MPC.

Use this when:

  • The hard equal_dof constraint causes the constraint-handler to ill-condition the reduced stiffness matrix (typical with mismatched DOF spaces).
  • You want a tunable interface compliance — e.g. a soft contact at a bearing pad.
Parameters

master_label : str Part label of the master side. slave_label : str Part label of the slave side. stiffness : float, default 1e10 Penalty spring stiffness in force/length units. Pick ~3–6 orders of magnitude above the stiffest neighbouring element diagonal — overshoot causes ill-conditioning, undershoot leaks displacement. dofs : list[int], optional 1-based DOFs to penalise. None = all available. tolerance : float, default 1e-6 Spatial co-location tolerance. name : str, optional Friendly name.

Returns

PenaltyDef

Raises

KeyError If either label is not in g.parts.

See Also

equal_dof : Hard MPC equivalent (no tunable stiffness).

Source code in src/apeGmsh/core/ConstraintsComposite.py
def penalty(self, master_label, slave_label, *, stiffness=1e10,
            dofs=None, tolerance=1e-6, name=None) -> PenaltyDef:
    """Soft-spring (penalty) coupling between co-located node pairs.

    Numerically approximates :meth:`equal_dof` as
    ``stiffness → ∞``. The resolver still requires master and
    slave nodes to be co-located within ``tolerance``, but
    downstream the constraint is enforced by inserting a stiff
    spring element between each pair instead of a hard MPC.

    Use this when:

    * The hard ``equal_dof`` constraint causes the
      constraint-handler to ill-condition the reduced stiffness
      matrix (typical with mismatched DOF spaces).
    * You want a tunable interface compliance — e.g. a soft
      contact at a bearing pad.

    Parameters
    ----------
    master_label : str
        Part label of the master side.
    slave_label : str
        Part label of the slave side.
    stiffness : float, default 1e10
        Penalty spring stiffness in force/length units. Pick
        ~3–6 orders of magnitude above the stiffest neighbouring
        element diagonal — overshoot causes ill-conditioning,
        undershoot leaks displacement.
    dofs : list[int], optional
        1-based DOFs to penalise. ``None`` = all available.
    tolerance : float, default 1e-6
        Spatial co-location tolerance.
    name : str, optional
        Friendly name.

    Returns
    -------
    PenaltyDef

    Raises
    ------
    KeyError
        If either label is not in ``g.parts``.

    See Also
    --------
    equal_dof : Hard MPC equivalent (no tunable stiffness).
    """
    return self._add_def(PenaltyDef(
        master_label=master_label, slave_label=slave_label,
        stiffness=stiffness, dofs=dofs, tolerance=tolerance, name=name))

rigid_diaphragm

rigid_diaphragm(master_label, slave_label, *, master_point=(0.0, 0.0, 0.0), plane_normal=(0.0, 0.0, 1.0), constrained_dofs=None, plane_tolerance=1.0, name=None) -> RigidDiaphragmDef

In-plane rigid floor — slaves follow master in the diaphragm plane.

Classic use: each floor of a multi-storey building. All slab nodes within plane_tolerance of the diaphragm plane share in-plane translation and rotation about the out-of-plane axis with the master node, while remaining free in the out-of-plane direction.

Resolution emits a single :class:~apeGmsh.solvers.Constraints.NodeGroupRecord with one master and many slaves. Downstream this becomes ops.rigidDiaphragm(perpDirn, master, *slaves).

Parameters

master_label : str Part, physical-group, or label name that contains (or whose proximity will select) the master node — typically a centre-of-mass point. slave_label : str Part, physical-group, or label name whose nodes are gathered into the diaphragm. master_point : (x, y, z), default (0, 0, 0) Coordinates of the master node. Used to disambiguate when the master part has more than one node. plane_normal : (nx, ny, nz), default (0, 0, 1) Unit normal to the diaphragm plane. (0, 0, 1) is a horizontal floor; (0, 1, 0) is a vertical wall, etc. constrained_dofs : list[int], optional DOFs slaved to the master. Default for a horizontal floor (Z up) is [1, 2, 6] — ux, uy, rz. For a vertical wall use [1, 3, 5]. plane_tolerance : float, default 1.0 Perpendicular distance (in model units) from the diaphragm plane within which a slave node is collected. Unit-sensitive — set this to a fraction of slab thickness. name : str, optional Friendly name.

Returns

RigidDiaphragmDef

Raises

KeyError If either label is not in g.parts.

See Also

kinematic_coupling : When you need a different DOF subset than [1, 2, 6] and don't need plane filtering. rigid_body : When all 6 DOFs must follow the master.

Examples

A horizontal slab at z = 3.0 m::

g.constraints.rigid_diaphragm(
    "slab", "slab_master",
    master_point=(2.5, 2.5, 3.0),
    plane_normal=(0, 0, 1),
    constrained_dofs=[1, 2, 6],
    plane_tolerance=0.05,
)
Source code in src/apeGmsh/core/ConstraintsComposite.py
def rigid_diaphragm(self, master_label, slave_label, *,
                    master_point=(0., 0., 0.),
                    plane_normal=(0., 0., 1.),
                    constrained_dofs=None, plane_tolerance=1.0,
                    name=None) -> RigidDiaphragmDef:
    """In-plane rigid floor — slaves follow master in the
    diaphragm plane.

    Classic use: each floor of a multi-storey building. All
    slab nodes within ``plane_tolerance`` of the diaphragm
    plane share in-plane translation and rotation about the
    out-of-plane axis with the master node, while remaining
    free in the out-of-plane direction.

    Resolution emits a single
    :class:`~apeGmsh.solvers.Constraints.NodeGroupRecord`
    with one master and many slaves. Downstream this becomes
    ``ops.rigidDiaphragm(perpDirn, master, *slaves)``.

    Parameters
    ----------
    master_label : str
        Part, physical-group, or label name that contains (or whose
        proximity will
        select) the master node — typically a centre-of-mass
        point.
    slave_label : str
        Part, physical-group, or label name whose nodes are
        gathered into the diaphragm.
    master_point : (x, y, z), default (0, 0, 0)
        Coordinates of the master node. Used to disambiguate
        when the master part has more than one node.
    plane_normal : (nx, ny, nz), default (0, 0, 1)
        Unit normal to the diaphragm plane. ``(0, 0, 1)`` is a
        horizontal floor; ``(0, 1, 0)`` is a vertical wall, etc.
    constrained_dofs : list[int], optional
        DOFs slaved to the master. Default for a horizontal
        floor (Z up) is ``[1, 2, 6]`` — ux, uy, rz. For a
        vertical wall use ``[1, 3, 5]``.
    plane_tolerance : float, default 1.0
        Perpendicular distance (in model units) from the
        diaphragm plane within which a slave node is
        collected. **Unit-sensitive** — set this to a fraction
        of slab thickness.
    name : str, optional
        Friendly name.

    Returns
    -------
    RigidDiaphragmDef

    Raises
    ------
    KeyError
        If either label is not in ``g.parts``.

    See Also
    --------
    kinematic_coupling : When you need a different DOF subset
        than ``[1, 2, 6]`` and don't need plane filtering.
    rigid_body : When all 6 DOFs must follow the master.

    Examples
    --------
    A horizontal slab at z = 3.0 m::

        g.constraints.rigid_diaphragm(
            "slab", "slab_master",
            master_point=(2.5, 2.5, 3.0),
            plane_normal=(0, 0, 1),
            constrained_dofs=[1, 2, 6],
            plane_tolerance=0.05,
        )
    """
    return self._add_def(RigidDiaphragmDef(
        master_label=master_label, slave_label=slave_label,
        master_point=master_point, plane_normal=plane_normal,
        constrained_dofs=constrained_dofs or [1, 2, 6],
        plane_tolerance=plane_tolerance, name=name))

rigid_body

rigid_body(master_label, slave_label, *, master_point=(0.0, 0.0, 0.0), as_element=False, mass=None, omega=None, name=None) -> RigidBodyDef

Fully rigid cluster — every slave DOF follows the master.

All six DOFs (ux, uy, uz, rx, ry, rz) of every node in the slave part follow the master node through a rigid transformation::

u_s = u_m + θ_m × (x_s − x_m)
θ_s = θ_m

Use this for genuinely rigid pieces (bearing blocks, lumped rigid masses) where the slave region must not deform.

Parameters

master_label : str Part, physical-group, or label name that contains (or whose proximity selects) the master node. slave_label : str Part, physical-group, or label name whose nodes are gathered into the rigid body. master_point : (x, y, z), default (0, 0, 0) Coordinates of the master node. as_element : bool, default False Emit the fork element LadrunoRigidBody over the whole node set {master, *slaves} (class tag 33015, 3D only) instead of the default rigidLink chain. The element gives a private centre-of-mass node, condensed body mass, and explicit-dynamics support that the rigidLink chain cannot. Fork-only: deck emission works on any build; running needs the Ladruno fork. mass : float or None Total body mass for as_element (-mass); None condenses it from the slaves' nodal mass. Only valid with as_element=True. omega : (wx, wy, wz) or None Initial body-frame angular velocity for as_element (-omega) — an explicit-dynamics initial condition (the body spins from t=0). Only valid with as_element=True. name : str, optional Friendly name.

Returns

RigidBodyDef

Raises

KeyError If either label is not in g.parts. ValueError If mass/omega is set without as_element=True, or mass < 0.

See Also

kinematic_coupling : Same topology but with a user-selectable DOF subset. rigid_diaphragm : In-plane variant with plane filtering.

Source code in src/apeGmsh/core/ConstraintsComposite.py
def rigid_body(self, master_label, slave_label, *,
               master_point=(0., 0., 0.), as_element=False, mass=None,
               omega=None, name=None) -> RigidBodyDef:
    """Fully rigid cluster — every slave DOF follows the master.

    All six DOFs (``ux, uy, uz, rx, ry, rz``) of every node in
    the slave part follow the master node through a rigid
    transformation::

        u_s = u_m + θ_m × (x_s − x_m)
        θ_s = θ_m

    Use this for genuinely rigid pieces (bearing blocks, lumped
    rigid masses) where the slave region must not deform.

    Parameters
    ----------
    master_label : str
        Part, physical-group, or label name that contains (or whose
        proximity selects)
        the master node.
    slave_label : str
        Part, physical-group, or label name whose nodes are
        gathered into the rigid
        body.
    master_point : (x, y, z), default (0, 0, 0)
        Coordinates of the master node.
    as_element : bool, default False
        Emit the fork ``element LadrunoRigidBody`` over the whole node
        set ``{master, *slaves}`` (class tag 33015, **3D only**)
        instead of the default ``rigidLink`` chain. The element gives
        a private centre-of-mass node, condensed body mass, and
        explicit-dynamics support that the rigidLink chain cannot.
        Fork-only: deck emission works on any build; running needs the
        Ladruno fork.
    mass : float or None
        Total body mass for ``as_element`` (``-mass``); ``None``
        condenses it from the slaves' nodal mass. Only valid with
        ``as_element=True``.
    omega : (wx, wy, wz) or None
        Initial body-frame angular velocity for ``as_element``
        (``-omega``) — an explicit-dynamics initial condition (the body
        spins from t=0). Only valid with ``as_element=True``.
    name : str, optional
        Friendly name.

    Returns
    -------
    RigidBodyDef

    Raises
    ------
    KeyError
        If either label is not in ``g.parts``.
    ValueError
        If ``mass``/``omega`` is set without ``as_element=True``, or
        ``mass < 0``.

    See Also
    --------
    kinematic_coupling : Same topology but with a user-selectable
        DOF subset.
    rigid_diaphragm : In-plane variant with plane filtering.
    """
    return self._add_def(RigidBodyDef(
        master_label=master_label, slave_label=slave_label,
        master_point=master_point, as_element=as_element, mass=mass,
        omega=(None if omega is None else tuple(omega)), name=name))

kinematic_coupling

kinematic_coupling(master_label, slave_label, *, master_point=(0.0, 0.0, 0.0), dofs=None, k=None, k_alpha=None, host=None, kr=None, enforce='penalty', bipenalty_dtcr=None, bipenalty_wcap=None, absolute=False, name=None) -> KinematicCouplingDef

RBE2 / kinematic coupling — a reference node rigidly drives a node set.

Emits the Ladruno-fork element LadrunoKinematicCoupling (class tag 33012): a penalty rigid-body driver with the correct moment-arm transport u_i = u_R + θ_R × d_i, so an offset reference is coupled rigidly. This replaces the previous equalDOF-per-slave expansion, which ignored the lever arm (correct only for coincident nodes). Fork-only: the deck emits on any build, but running it needs the Ladruno fork — stock OpenSees fails loud at the element line (it does not know class tag 33012).

Reach for this when the region must move as a rigid body (a loading platen, a rigid offset / connection block, a rigid diaphragm over an arbitrary node set). To introduce a load at a point while the region stays flexible, use a distributing coupling (RBE3) instead.

Parameters

master_label : str | DecoupledNodeDef Part, physical-group, or label name that owns the reference (master) node — or a g.decouple_node handle / its label= (ADR 0049 OQ2). The reference node must carry the rotational DOFs (ndf 6 in 3D / 3 in 2D); the fork refuses a too-small reference at setDomain. slave_label : str | DecoupledNodeDef Part, physical-group, or label name whose nodes are slaved (may mix 3- and 6-DOF nodes). A g.decouple_node handle is accepted the same way as master_label. master_point : (x, y, z), default (0, 0, 0) Coordinates of the reference node when the master role resolves to a multi-node set (nearest-in-set). Ignored when the role is a single decoupled node — that node's own coordinates are used. dofs : list[int], optional 1-based dependent components tied on each slave (-dof). None (default) ties every DOF the slave has — the right choice for a mixed 3/6-DOF slave set; pass an explicit list to restrict, e.g. [1, 2, 3] for translations only or [3] for a vertical-only follower. k : float | "auto", optional Translational penalty stiffness (-k). None ⇒ the fork default (1e12). "auto" scales it off a representative host element's stiffness diagonal (K_t = k_alpha · max|K_host(i,i)|) — requires host. k_alpha : float, optional Multiplier for k="auto" (-kAlpha; fork default 1e3). Only valid together with k="auto". host : int, optional Representative host element for k="auto" / bipenalty_wcap (-host) as a FEM element id — the bridge translates it to the emitted OpenSees tag at emit time. Pick a typical element of the coupled part (e.g. one touching the slave surface). kr : float, optional Rotational penalty stiffness (-kr). None ⇒ fork-derived K_t·ℓ² (keeps the translation/rotation conditioning matched). enforce : "penalty" | "al", default "penalty" "al" = augmented Lagrangian (near-exact rigidity at moderate k; implicit only — cannot combine with the bipenalty knobs). bipenalty_dtcr : float, optional Explicit-dynamics critical-time-step target (-bipenalty -dtcr); lumps a penalty mass on any massless tied DOF so the stiff tie doesn't collapse the explicit step. None ⇒ off (the master is usually a massed node). bipenalty_wcap : float, optional Bipenalty via the host frequency (-bipenalty -wcap): m_p = K_t/(β·ω_host)² with β = this value — sets the penalty-mode frequency at β·ω_host instead of a hard dt budget. Requires host; mutually exclusive with bipenalty_dtcr. absolute : bool, default False Keep the absolute tie (-absolute) — skip the default g0 stress-free birth (a coupling added to a deformed model is otherwise born force-free). name : str, optional Friendly name (also the stage-claim key for s.kinematic_coupling).

Returns

KinematicCouplingDef

Raises

KeyError If either label is not in g.parts / PGs / labels and is not a labelled g.decouple_node. ValueError On an invalid knob (enforce not in {penalty, al}; non-positive k/kr/bipenalty_dtcr/bipenalty_wcap; al + a bipenalty knob; k="auto" or bipenalty_wcap without host; k_alpha without k="auto"; a dangling host no knob consumes; bipenalty_dtcr + bipenalty_wcap); a g.decouple_node handle without label=; a label that names both a decoupled node and a Part/PG; an ambiguous duplicate decoupled label.

Source code in src/apeGmsh/core/ConstraintsComposite.py
def kinematic_coupling(self, master_label, slave_label, *,
                       master_point=(0., 0., 0.), dofs=None,
                       k=None, k_alpha=None, host=None,
                       kr=None, enforce="penalty",
                       bipenalty_dtcr=None, bipenalty_wcap=None,
                       absolute=False,
                       name=None) -> KinematicCouplingDef:
    """RBE2 / kinematic coupling — a reference node rigidly drives a
    node set.

    Emits the Ladruno-fork ``element LadrunoKinematicCoupling`` (class
    tag 33012): a penalty rigid-body driver with the correct moment-arm
    transport ``u_i = u_R + θ_R × d_i``, so an *offset* reference is
    coupled rigidly. This replaces the previous ``equalDOF``-per-slave
    expansion, which ignored the lever arm (correct only for coincident
    nodes). **Fork-only:** the deck emits on any build, but running it
    needs the Ladruno fork — stock OpenSees fails loud at the element
    line (it does not know class tag 33012).

    Reach for this when the region must move as a **rigid body** (a
    loading platen, a rigid offset / connection block, a rigid
    diaphragm over an arbitrary node set). To *introduce a load at a
    point while the region stays flexible*, use a distributing coupling
    (RBE3) instead.

    Parameters
    ----------
    master_label : str | DecoupledNodeDef
        Part, physical-group, or label name that owns the reference
        (master) node — **or** a ``g.decouple_node`` handle / its
        ``label=`` (ADR 0049 OQ2). The reference node must carry the
        rotational DOFs (ndf 6 in 3D / 3 in 2D); the fork refuses a
        too-small reference at ``setDomain``.
    slave_label : str | DecoupledNodeDef
        Part, physical-group, or label name whose nodes are slaved
        (may mix 3- and 6-DOF nodes). A ``g.decouple_node`` handle
        is accepted the same way as ``master_label``.
    master_point : (x, y, z), default (0, 0, 0)
        Coordinates of the reference node when the master role
        resolves to a multi-node set (nearest-in-set). **Ignored**
        when the role is a single decoupled node — that node's own
        coordinates are used.
    dofs : list[int], optional
        1-based dependent components tied on each slave (``-dof``).
        ``None`` (default) ties *every DOF the slave has* — the right
        choice for a mixed 3/6-DOF slave set; pass an explicit list to
        restrict, e.g. ``[1, 2, 3]`` for translations only or
        ``[3]`` for a vertical-only follower.
    k : float | ``"auto"``, optional
        Translational penalty stiffness (``-k``). ``None`` ⇒ the fork
        default (``1e12``). ``"auto"`` scales it off a representative
        host element's stiffness diagonal
        (``K_t = k_alpha · max|K_host(i,i)|``) — requires ``host``.
    k_alpha : float, optional
        Multiplier for ``k="auto"`` (``-kAlpha``; fork default ``1e3``).
        Only valid together with ``k="auto"``.
    host : int, optional
        Representative host element for ``k="auto"`` / ``bipenalty_wcap``
        (``-host``) as a **FEM element id** — the bridge translates it to
        the emitted OpenSees tag at emit time. Pick a typical element of
        the coupled part (e.g. one touching the slave surface).
    kr : float, optional
        Rotational penalty stiffness (``-kr``). ``None`` ⇒ fork-derived
        ``K_t·ℓ²`` (keeps the translation/rotation conditioning matched).
    enforce : ``"penalty"`` | ``"al"``, default ``"penalty"``
        ``"al"`` = augmented Lagrangian (near-exact rigidity at moderate
        ``k``; **implicit only** — cannot combine with the bipenalty
        knobs).
    bipenalty_dtcr : float, optional
        Explicit-dynamics critical-time-step target (``-bipenalty
        -dtcr``); lumps a penalty mass on any massless tied DOF so the
        stiff tie doesn't collapse the explicit step. ``None`` ⇒ off
        (the master is usually a massed node).
    bipenalty_wcap : float, optional
        Bipenalty via the host frequency (``-bipenalty -wcap``):
        ``m_p = K_t/(β·ω_host)²`` with ``β`` = this value — sets the
        penalty-mode frequency at ``β·ω_host`` instead of a hard ``dt``
        budget. Requires ``host``; mutually exclusive with
        ``bipenalty_dtcr``.
    absolute : bool, default False
        Keep the **absolute** tie (``-absolute``) — skip the default
        ``g0`` stress-free birth (a coupling added to a deformed model
        is otherwise born force-free).
    name : str, optional
        Friendly name (also the stage-claim key for ``s.kinematic_coupling``).

    Returns
    -------
    KinematicCouplingDef

    Raises
    ------
    KeyError
        If either label is not in ``g.parts`` / PGs / labels and is not
        a labelled ``g.decouple_node``.
    ValueError
        On an invalid knob (``enforce`` not in {penalty, al};
        non-positive ``k``/``kr``/``bipenalty_dtcr``/``bipenalty_wcap``;
        ``al`` + a bipenalty knob; ``k="auto"`` or ``bipenalty_wcap``
        without ``host``; ``k_alpha`` without ``k="auto"``; a dangling
        ``host`` no knob consumes; ``bipenalty_dtcr`` + ``bipenalty_wcap``);
        a ``g.decouple_node`` handle without ``label=``; a label that
        names both a decoupled node and a Part/PG; an ambiguous
        duplicate decoupled label.
    """
    master_label = _as_role_label(master_label, "master_label")
    slave_label = _as_role_label(slave_label, "slave_label")
    return self._add_def(KinematicCouplingDef(
        master_label=master_label, slave_label=slave_label,
        master_point=master_point, dofs=dofs,
        control=CouplingControl(
            k=k, k_alpha=k_alpha, host=host, kr=kr, enforce=enforce,
            bipenalty_dtcr=bipenalty_dtcr, bipenalty_wcap=bipenalty_wcap,
            absolute=absolute,
        ),
        name=name))

tie

tie(master_label, slave_label, *, master_entities=None, slave_entities=None, dofs=None, tolerance=1.0, stiffness='auto', stiffness_p=None, rotational=False, pressure=False, enforce='penalty', control=None, method='collocation', outward=None, name=None) -> TieDef

Non-matching mesh tie via shape-function interpolation.

For each slave node, the resolver finds the closest master element face, projects the node onto it, and constrains its DOFs to the master corner DOFs through that face's shape functions::

u_slave = Σ N_i(ξ, η) · u_master_i

where (ξ, η) are the projected parametric coordinates and N_i are the master face's shape functions (tri3, quad4, tri6, quad8 supported). This is what Abaqus *TIE does — it preserves displacement continuity across non-matching meshes.

Resolution emits one :class:~apeGmsh.mesh.records.InterpolationRecord per successfully projected slave node. The emitted OpenSees coupling depends on enforce= (see below): a penalty ASDEmbeddedNodeElement (default), the fork LadrunoEmbeddedNode, or an exact equationConstraint (EQ_Constraint).

Parameters

master_label : str Part, physical-group, or label name of the master surface (the side whose mesh will provide the shape functions). slave_label : str Part, physical-group, or label name of the slave surface (whose nodes are projected). master_entities : list of (dim, tag), optional Restrict the master surface to specific Gmsh entities. Strongly recommended when the master part has more than one face. slave_entities : list of (dim, tag), optional Restrict the slave surface to specific entities. dofs : list[int], optional DOFs to tie. None (default) ties all translational DOFs available — typically [1, 2, 3]. tolerance : float, default 1.0 Maximum allowed projection distance from a slave node to the master surface. Slave nodes farther than this are skipped with a warning (an all-skipped tie raises) — set generously if the two meshes have a small geometric gap, but not so large that the wrong face is selected. Unit-sensitive. stiffness : float or "auto", default "auto" Penalty stiffness K of the emitted ASDEmbeddedNodeElement (penalty routes only; ignored by "equation"). "auto" resolves at emit from the host material: K = α·E_host·L_char (α = 1e3, E from the element's material, L from the master-face size) — a few orders above the host element stiffness, which is all the penalty needs. A numeric value is unit-dependent and must be calibrated against a known solution: the pre-slice-B default 1e18 (the OpenSees C++ default) destroys the conditioning in N/mm/MPa (E ≈ 2e5) and Newton stalls, while 1e101e12 converge with sub-percent stiffness error; emit still warns when a record carries 1e18. stiffness_p : float, optional Separate rotational/pressure penalty (-KP); None ⇒ the element falls back to K. Same unit caveat. rotational, pressure : bool, default False Extend the coupling to rotational / pressure DOFs (-rot / -p); penalty routes only. enforce : {"penalty", "penalty_al", "equation"}, default "penalty" Coupling route (ADR 0068). "penalty"ASDEmbeddedNodeElement penalty element (tunable K, handler-independent). "equation" → exact equationConstraint (EQ_Constraint), translations only, enforced by the Lagrange (implicit) / LadrunoProjection (explicit, Δt-neutral) handler — auto-selected at emit, and penalty-only knobs (rotational/pressure/stiffness_p) are rejected. "penalty_al" → fork LadrunoEmbeddedNode (penalty + augmented-Lagrange + bipenalty, translations only), configured via control= (see below). control : CouplingControl, optional LadrunoEmbeddedNode penalty/AL/bipenalty knobs — only valid with enforce="penalty_al" (reuses the RBE2/RBE3 :class:CouplingControl: -k/-kAlpha/-host/ -enforce al/-bipenalty/-absolute). None ⇒ the fork element's own defaults. method : {"collocation", "mortar"}, default "collocation" Weight-computation method (ADR 0086). "collocation" is the classic node-to-face projection above. "mortar" integrates the interface over the slave/master facet overlaps with a dual (biorthogonal) slave basis, so neither side's interpolation order is imposed on the other — the fix for order-mismatched interfaces (e.g. hex20 faces tied onto hex8 faces, where collocation over-constrains the quadratic side). Requires enforce="equation" (v1), works on composed assemblies (chain phase), and is fail-loud end to end: a flat, coincident, convex interface is required and every degenerate case raises MortarTieError — a mortar tie never silently resolves to nothing. tolerance becomes the out-of-plane coincidence tolerance. Interface edges must be straight (every midside node at its edge midpoint) and no master facet may overlap another — both are hard errors, because the kernel integrates on the corner polygon and its coverage check counts multiplicity. tri6 SLAVE facets are refused (dual-basis degeneracy) — swap the sides or use collocation. outward : (ox, oy, oz), optional method="mortar" only: interface-plane normal override. Normally derived from master facet winding; needed only when the winding sum cancels (the kernel raises naming this knob — there is no silent zero-force path). name : str, optional Friendly name.

Returns

TieDef

Raises

KeyError If either label is not in g.parts.

See Also

equal_dof : Conformal-mesh equivalent (no interpolation). tied_contact : Bidirectional surface-to-surface tie. mortar : Deprecated alias for a fork mortar mesh-tie (contact(formulation="mortar", tie=True)).

Notes

Master/slave choice matters for accuracy. As a rule:

  • The master should have the finer mesh (more shape functions to project onto).
  • The slave should have the coarser mesh (fewer projection operations).
Examples

Shell-to-solid tie at a column-top interface::

g.constraints.tie(
    "shell_floor", "solid_column",
    master_entities=[(2, 17)],     # column top face
    slave_entities=[(2, 41)],      # shell bottom face
    tolerance=5.0,                 # mm gap
)
Source code in src/apeGmsh/core/ConstraintsComposite.py
def tie(self, master_label, slave_label, *, master_entities=None,
        slave_entities=None, dofs=None, tolerance=1.0,
        stiffness="auto", stiffness_p=None,
        rotational=False, pressure=False,
        enforce="penalty", control=None,
        method="collocation", outward=None,
        name=None) -> TieDef:
    """Non-matching mesh tie via shape-function interpolation.

    For each slave node, the resolver finds the closest master
    element face, projects the node onto it, and constrains its
    DOFs to the master corner DOFs through that face's shape
    functions::

        u_slave = Σ N_i(ξ, η) · u_master_i

    where ``(ξ, η)`` are the projected parametric coordinates
    and ``N_i`` are the master face's shape functions (tri3,
    quad4, tri6, quad8 supported). This is what Abaqus
    ``*TIE`` does — it preserves displacement continuity across
    non-matching meshes.

    Resolution emits one
    :class:`~apeGmsh.mesh.records.InterpolationRecord` per
    successfully projected slave node. The emitted OpenSees coupling
    depends on ``enforce=`` (see below): a penalty
    ``ASDEmbeddedNodeElement`` (default), the fork
    ``LadrunoEmbeddedNode``, or an exact ``equationConstraint``
    (EQ_Constraint).

    Parameters
    ----------
    master_label : str
        Part, physical-group, or label name of the master surface
        (the side whose mesh
        will provide the shape functions).
    slave_label : str
        Part, physical-group, or label name of the slave surface
        (whose nodes are
        projected).
    master_entities : list of (dim, tag), optional
        Restrict the master surface to specific Gmsh
        entities. **Strongly recommended** when the master
        part has more than one face.
    slave_entities : list of (dim, tag), optional
        Restrict the slave surface to specific entities.
    dofs : list[int], optional
        DOFs to tie. ``None`` (default) ties all translational
        DOFs available — typically ``[1, 2, 3]``.
    tolerance : float, default 1.0
        Maximum allowed projection distance from a slave node
        to the master surface. Slave nodes farther than this
        are skipped with a warning (an all-skipped tie raises) —
        set generously if the two meshes have a small geometric
        gap, but not so large that the wrong face is selected.
        **Unit-sensitive.**
    stiffness : float or ``"auto"``, default ``"auto"``
        Penalty stiffness ``K`` of the emitted
        ``ASDEmbeddedNodeElement`` (penalty routes only; ignored by
        ``"equation"``). ``"auto"`` resolves at emit from the host
        material: ``K = α·E_host·L_char`` (α = 1e3, E from the
        element's material, L from the master-face size) — a few
        orders above the host element stiffness, which is all the
        penalty needs. **A numeric value is unit-dependent and must
        be calibrated against a known solution**: the pre-slice-B
        default ``1e18`` (the OpenSees C++ default) destroys the
        conditioning in N/mm/MPa (E ≈ 2e5) and Newton stalls, while
        ``1e10``–``1e12`` converge with sub-percent stiffness
        error; emit still warns when a record carries 1e18.
    stiffness_p : float, optional
        Separate rotational/pressure penalty (``-KP``); ``None`` ⇒
        the element falls back to ``K``. Same unit caveat.
    rotational, pressure : bool, default False
        Extend the coupling to rotational / pressure DOFs
        (``-rot`` / ``-p``); penalty routes only.
    enforce : {"penalty", "penalty_al", "equation"}, default "penalty"
        Coupling route (ADR 0068). ``"penalty"`` →
        ``ASDEmbeddedNodeElement`` penalty element (tunable ``K``,
        handler-independent). ``"equation"`` → exact
        ``equationConstraint`` (EQ_Constraint), translations only,
        enforced by the ``Lagrange`` (implicit) / ``LadrunoProjection``
        (explicit, Δt-neutral) handler — auto-selected at emit, and
        penalty-only knobs (``rotational``/``pressure``/``stiffness_p``)
        are rejected. ``"penalty_al"`` → fork ``LadrunoEmbeddedNode``
        (penalty + augmented-Lagrange + bipenalty, translations only),
        configured via ``control=`` (see below).
    control : CouplingControl, optional
        LadrunoEmbeddedNode penalty/AL/bipenalty knobs — only valid with
        ``enforce="penalty_al"`` (reuses the RBE2/RBE3
        :class:`CouplingControl`: ``-k``/``-kAlpha``/``-host``/
        ``-enforce al``/``-bipenalty``/``-absolute``). ``None`` ⇒ the
        fork element's own defaults.
    method : {"collocation", "mortar"}, default "collocation"
        Weight-computation method (ADR 0086). ``"collocation"`` is
        the classic node-to-face projection above. ``"mortar"``
        integrates the interface over the slave/master facet
        overlaps with a dual (biorthogonal) slave basis, so
        **neither side's interpolation order is imposed on the
        other** — the fix for order-mismatched interfaces (e.g.
        hex20 faces tied onto hex8 faces, where collocation
        over-constrains the quadratic side). Requires
        ``enforce="equation"`` (v1), works on composed assemblies
        (chain phase), and is fail-loud end to end: a flat,
        coincident, convex interface is required and every
        degenerate case raises ``MortarTieError`` — a mortar tie
        never silently resolves to nothing. ``tolerance`` becomes
        the out-of-plane coincidence tolerance. Interface edges must
        be straight (every midside node at its edge midpoint) and no
        master facet may overlap another — both are hard errors,
        because the kernel integrates on the corner polygon and its
        coverage check counts multiplicity. tri6 SLAVE facets are
        refused (dual-basis degeneracy) — swap the sides or use
        collocation.
    outward : (ox, oy, oz), optional
        ``method="mortar"`` only: interface-plane normal override.
        Normally derived from master facet winding; needed only
        when the winding sum cancels (the kernel raises naming
        this knob — there is no silent zero-force path).
    name : str, optional
        Friendly name.

    Returns
    -------
    TieDef

    Raises
    ------
    KeyError
        If either label is not in ``g.parts``.

    See Also
    --------
    equal_dof : Conformal-mesh equivalent (no interpolation).
    tied_contact : Bidirectional surface-to-surface tie.
    mortar : Deprecated alias for a fork mortar mesh-tie
        (``contact(formulation="mortar", tie=True)``).

    Notes
    -----
    Master/slave choice matters for accuracy. As a rule:

    * The master should have the **finer** mesh (more shape
      functions to project onto).
    * The slave should have the **coarser** mesh (fewer
      projection operations).

    Examples
    --------
    Shell-to-solid tie at a column-top interface::

        g.constraints.tie(
            "shell_floor", "solid_column",
            master_entities=[(2, 17)],     # column top face
            slave_entities=[(2, 41)],      # shell bottom face
            tolerance=5.0,                 # mm gap
        )
    """
    return self._add_def(TieDef(
        master_label=master_label, slave_label=slave_label,
        master_entities=master_entities, slave_entities=slave_entities,
        dofs=dofs, tolerance=tolerance, name=name,
        stiffness=stiffness, stiffness_p=stiffness_p,
        rotational=rotational, pressure=pressure, enforce=enforce,
        control=control, method=method, outward=outward))

distributing_coupling

distributing_coupling(master_label, slave_label, *, master_point=(0.0, 0.0, 0.0), weighting='uniform', k=None, k_alpha=None, host=None, kr=None, enforce='penalty', bipenalty_dtcr=None, bipenalty_wcap=None, absolute=False, name=None) -> DistributingCouplingDef

RBE3 / distributing coupling — distribute a load at a reference point over a node set while the set stays flexible.

Emits the Ladruno-fork element LadrunoDistributingCoupling (class tag 33011): the reference (dependent) node R is the weighted-average rigid-body fit of the independent set, and a force/moment at R is distributed to the set as a statically-equivalent pattern (Σ Fᵢ = F, Σ rᵢ × Fᵢ = M) adding no stiffness to the independents. This is the proper RBE3 — it replaces the prior NotImplementedError stub (whose predecessor emitted a mechanically-wrong kinematic mean). It is the flexible counterpart of :meth:kinematic_coupling (RBE2, which holds the set rigid). Fork-only: the deck emits on any build, but running it needs the Ladruno fork — stock OpenSees fails loud at the element line (it does not know class tag 33011).

Reach for this to introduce/transmit a load or BC at a point while the region stays flexible (a column base on a footing, an actuator head on a face, a beam/shell moment into a solid face); use :meth:kinematic_coupling (RBE2) when the region must move as a rigid body, or :meth:tie for a compatible face interpolation.

Parameters

master_label : str | DecoupledNodeDef Part, physical-group, or label name owning the reference (dependent) node R — or a g.decouple_node handle / its label= (ADR 0049 OQ2). R must carry the rotational DOFs (ndf 6 in 3D / 3 in 2D) to transmit a moment; the fork refuses a too-small reference at setDomain. slave_label : str | DecoupledNodeDef Part, physical-group, or label name whose nodes form the independent set (translations-only is fine — no rotational stiffness is injected). A g.decouple_node handle is accepted the same way as master_label. master_point : (x, y, z), default (0, 0, 0) Coordinates of the reference node R when the master role resolves to a multi-node set (nearest-in-set). Ignored when the role is a single decoupled node — that node's own coordinates are used. weighting : "uniform" | "area", default "uniform" "uniform" ⇒ equal weights (-w omitted, the fork element's default). "area" ⇒ apeGmsh computes each independent node's tributary area over the slave surface (each face's area split equally among its nodes — the same lumping model as g.loads surface-tributary resolution) and emits -w w1..wN, so a force at R distributes like a uniform traction on the surface. Requires the slave label (or slave_entities) to resolve to meshed surface faces; an independent node on no slave face fails loud. k : float | "auto", optional Translational penalty stiffness (-k). None ⇒ the fork default (1e12). "auto" scales it off a representative host element's stiffness diagonal (K_t = k_alpha · max|K_host(i,i)|) — requires host. Note the force distribution is exact for any penalty (the RBE3 property); k only relaxes the kinematic fit of the reference node. k_alpha : float, optional Multiplier for k="auto" (-kAlpha; fork default 1e3). Only valid together with k="auto". host : int, optional Representative host element for k="auto" / bipenalty_wcap (-host) as a FEM element id — the bridge translates it to the emitted OpenSees tag at emit time. RBE3 has no single host by construction; name ONE typical element among the independents' parents — it is read ONLY to scale the penalties. kr : float, optional Rotational penalty stiffness (-kr). None ⇒ fork-derived. enforce : "penalty" | "al", default "penalty" "al" = augmented Lagrangian — recovers a near-exact weighted fit of R at moderate k (implicit only; cannot combine with the bipenalty knobs). bipenalty_dtcr : float, optional Explicit-dynamics critical-time-step target (-bipenalty -dtcr). The reference node is massless by construction, so an explicit run needs this (or it has a zero stable step). None ⇒ off. bipenalty_wcap : float, optional Bipenalty via the host frequency (-bipenalty -wcap): m_p = K_t/(β·ω_host)² with β = this value. Requires host; mutually exclusive with bipenalty_dtcr. absolute : bool, default False Keep the absolute tie (-absolute) — skip the default g0 stress-free birth. name : str, optional Friendly name (also the stage-claim key for s.distributing).

Returns

DistributingCouplingDef

Raises

ValueError On an invalid knob (enforce not in {penalty, al}; non-positive k/kr/bipenalty_dtcr/bipenalty_wcap; al + a bipenalty knob; k="auto" or bipenalty_wcap without host; k_alpha without k="auto"; a dangling host no knob consumes; bipenalty_dtcr + bipenalty_wcap; weighting not in {uniform, area}); a g.decouple_node handle without label=; a label that names both a decoupled node and a Part/PG; an ambiguous duplicate decoupled label.

Source code in src/apeGmsh/core/ConstraintsComposite.py
def distributing_coupling(self, master_label, slave_label, *,
                          master_point=(0., 0., 0.),
                          weighting="uniform",
                          k=None, k_alpha=None, host=None,
                          kr=None, enforce="penalty",
                          bipenalty_dtcr=None, bipenalty_wcap=None,
                          absolute=False,
                          name=None) -> DistributingCouplingDef:
    """RBE3 / distributing coupling — distribute a load at a reference
    point over a node set while the set stays flexible.

    Emits the Ladruno-fork ``element LadrunoDistributingCoupling``
    (class tag 33011): the reference (dependent) node R is the
    weighted-average rigid-body fit of the independent set, and a
    force/moment at R is distributed to the set as a
    statically-equivalent pattern (``Σ Fᵢ = F``, ``Σ rᵢ × Fᵢ = M``)
    **adding no stiffness** to the independents. This is the proper
    RBE3 — it replaces the prior `NotImplementedError` stub (whose
    predecessor emitted a mechanically-wrong kinematic mean). It is
    the flexible counterpart of :meth:`kinematic_coupling` (RBE2,
    which holds the set rigid). **Fork-only:** the deck emits on any
    build, but running it needs the Ladruno fork — stock OpenSees
    fails loud at the element line (it does not know class tag 33011).

    Reach for this to **introduce/transmit a load or BC at a point
    while the region stays flexible** (a column base on a footing, an
    actuator head on a face, a beam/shell moment into a solid face);
    use :meth:`kinematic_coupling` (RBE2) when the region must move as
    a rigid body, or :meth:`tie` for a compatible face interpolation.

    Parameters
    ----------
    master_label : str | DecoupledNodeDef
        Part, physical-group, or label name owning the reference
        (dependent) node R — **or** a ``g.decouple_node`` handle / its
        ``label=`` (ADR 0049 OQ2). R must carry the rotational DOFs
        (ndf 6 in 3D / 3 in 2D) to transmit a moment; the fork refuses
        a too-small reference at ``setDomain``.
    slave_label : str | DecoupledNodeDef
        Part, physical-group, or label name whose nodes form the
        **independent** set (translations-only is fine — no rotational
        stiffness is injected). A ``g.decouple_node`` handle is
        accepted the same way as ``master_label``.
    master_point : (x, y, z), default (0, 0, 0)
        Coordinates of the reference node R when the master role
        resolves to a multi-node set (nearest-in-set). **Ignored**
        when the role is a single decoupled node — that node's own
        coordinates are used.
    weighting : ``"uniform"`` | ``"area"``, default ``"uniform"``
        ``"uniform"`` ⇒ equal weights (``-w`` omitted, the fork
        element's default). ``"area"`` ⇒ apeGmsh computes each
        independent node's **tributary area** over the slave
        surface (each face's area split equally among its nodes —
        the same lumping model as ``g.loads`` surface-tributary
        resolution) and emits ``-w w1..wN``, so a force at R
        distributes like a uniform traction on the surface.
        Requires the slave label (or ``slave_entities``) to resolve
        to meshed surface faces; an independent node on no slave
        face fails loud.
    k : float | ``"auto"``, optional
        Translational penalty stiffness (``-k``). ``None`` ⇒ the fork
        default (``1e12``). ``"auto"`` scales it off a representative
        host element's stiffness diagonal
        (``K_t = k_alpha · max|K_host(i,i)|``) — requires ``host``.
        Note the **force distribution is exact for any penalty** (the
        RBE3 property); ``k`` only relaxes the *kinematic* fit of the
        reference node.
    k_alpha : float, optional
        Multiplier for ``k="auto"`` (``-kAlpha``; fork default ``1e3``).
        Only valid together with ``k="auto"``.
    host : int, optional
        Representative host element for ``k="auto"`` / ``bipenalty_wcap``
        (``-host``) as a **FEM element id** — the bridge translates it to
        the emitted OpenSees tag at emit time. RBE3 has no single host
        by construction; name ONE typical element among the independents'
        parents — it is read ONLY to scale the penalties.
    kr : float, optional
        Rotational penalty stiffness (``-kr``). ``None`` ⇒ fork-derived.
    enforce : ``"penalty"`` | ``"al"``, default ``"penalty"``
        ``"al"`` = augmented Lagrangian — recovers a near-exact weighted
        fit of R at moderate ``k`` (**implicit only**; cannot combine
        with the bipenalty knobs).
    bipenalty_dtcr : float, optional
        Explicit-dynamics critical-time-step target (``-bipenalty
        -dtcr``). The reference node is **massless** by construction, so
        an explicit run needs this (or it has a zero stable step).
        ``None`` ⇒ off.
    bipenalty_wcap : float, optional
        Bipenalty via the host frequency (``-bipenalty -wcap``):
        ``m_p = K_t/(β·ω_host)²`` with ``β`` = this value. Requires
        ``host``; mutually exclusive with ``bipenalty_dtcr``.
    absolute : bool, default False
        Keep the **absolute** tie (``-absolute``) — skip the default
        ``g0`` stress-free birth.
    name : str, optional
        Friendly name (also the stage-claim key for `s.distributing`).

    Returns
    -------
    DistributingCouplingDef

    Raises
    ------
    ValueError
        On an invalid knob (``enforce`` not in {penalty, al};
        non-positive ``k``/``kr``/``bipenalty_dtcr``/``bipenalty_wcap``;
        ``al`` + a bipenalty knob; ``k="auto"`` or ``bipenalty_wcap``
        without ``host``; ``k_alpha`` without ``k="auto"``; a dangling
        ``host`` no knob consumes; ``bipenalty_dtcr`` + ``bipenalty_wcap``;
        ``weighting`` not in {uniform, area}); a ``g.decouple_node``
        handle without ``label=``; a label that names both a
        decoupled node and a Part/PG; an ambiguous duplicate
        decoupled label.
    """
    if weighting not in ("uniform", "area"):
        raise ValueError(
            "distributing_coupling: weighting must be 'uniform' (equal "
            "weights) or 'area' (tributary-area -w weights computed over "
            f"the slave surface), got {weighting!r}."
        )
    master_label = _as_role_label(master_label, "master_label")
    slave_label = _as_role_label(slave_label, "slave_label")
    return self._add_def(DistributingCouplingDef(
        master_label=master_label, slave_label=slave_label,
        master_point=master_point, weighting=weighting,
        control=CouplingControl(
            k=k, k_alpha=k_alpha, host=host, kr=kr, enforce=enforce,
            bipenalty_dtcr=bipenalty_dtcr, bipenalty_wcap=bipenalty_wcap,
            absolute=absolute,
        ),
        name=name))

embedded

embedded(host_label, embedded_label, *, tolerance=1.0, host_entities=None, embedded_entities=None, stiffness='auto', stiffness_p=None, rotational=False, pressure=False, host_coupling='linear', name=None) -> EmbeddedDef

Embed lower-dimensional elements inside a host volume or surface.

Each node of the embedded part is constrained to the displacement field of the host element it falls inside via host shape functions. Used for rebar in concrete, stiffeners in shells, fibres in composite hosts, etc.

Supported host element types:

  • 3-D host: tet4 (etype 4), tet10 (11), hex8 (5), hex20 (17), prism6 (6), prism15 (18), pyramid5 (7), pyramid13 (14).
  • 2-D host: tri3 / CST (etype 2), tri6 / LST (9), quad4 (3), quad8 (16), quad9 (10).

Non-simplex and higher-order hosts are decomposed to linear sub-tris / sub-tets using corner nodes only (hex8 → 6 Kuhn tets; prism6 → 3 tets; pyramid5 → 2 tets; quad4 → 2 tris on the (0,2) diagonal; tri6 / tet10 / hex20 / quad8 / quad9 / prism15 / pyramid13 → corner-only). The embedded coupling is therefore linear regardless of the host's native interpolation order — see host_coupling and :class:EmbeddedDef for the full contract. A UserWarning fires once per (host type, entity) the first time a midside-bearing host is decomposed.

The resolver automatically drops embedded nodes that coincide with host element corners, since those are already rigidly attached through shared connectivity.

Parameters

host_label : str Part label whose host elements form the embedding field. Stored internally as master_label. embedded_label : str Part label whose nodes are embedded. Stored as slave_label. (Label validation is bypassed for EmbeddedDef — these labels may also be physical group names if no part registry is in use.) tolerance : float, default 1.0 Maximum dimensionless barycentric excess allowed when locating an embedded node inside a host sub-element. 0.0 means strictly inside; the default 1.0 preserves pre-Phase-2 permissive behaviour. See :class:EmbeddedDef for the fail-loud gate. stiffness : float or "auto", default "auto" Penalty stiffness K of the emitted ASDEmbeddedNodeElement. "auto" resolves at emit from the host material (K = α·E_host·L_char, α = 1e3). A numeric value is unit-dependent and must be calibrated against a known solution — the pre-slice-B default 1e18 (the OpenSees C++ default) stalls Newton in N/mm/MPa models while 1e101e12 converge; emit still warns when a record carries 1e18. (Unlike :meth:tie, embedded has no enforce="equation" escape hatch — the fork g.embed is the conditioned alternative.) stiffness_p : float, optional Separate rotational/pressure penalty (-KP); None ⇒ falls back to K. Same unit caveat. host_entities, embedded_entities : list of (dim, tag), optional Restrict the host / embedded sides to specific Gmsh entities. When omitted the whole label is used. host_coupling : {"linear"}, default "linear" Reserved keyword pinning the coupling kinematics. Only "linear" is currently accepted (coupling to 3 or 4 corner nodes via barycentric shape functions, matching ASDEmbeddedNodeElement). Reserved so that future higher-order options ("trilinear", "biquadratic") can be added without breaking old models. name : str, optional Friendly name.

Returns

EmbeddedDef

Notes

Emitted downstream as ASDEmbeddedNodeElement. The host_label / embedded_label argument names mirror Abaqus's *EMBEDDED ELEMENT vocabulary; internally the composite still stores them as master/slave for consistency with the rest of the constraint records.

Examples

Rebar curve embedded inside a concrete tet mesh::

g.constraints.embedded(
    host_label="concrete_block",
    embedded_label="rebar_curve",
    tolerance=2.0,        # mm
)

Same rebar embedded into a hex8 mesh (each rebar node is located inside one of the 6 Kuhn sub-tets of the enclosing hex and coupled to that sub-tet's 4 corners)::

g.constraints.embedded(
    host_label="concrete_block_hex",
    embedded_label="rebar_curve",
)
Source code in src/apeGmsh/core/ConstraintsComposite.py
def embedded(self, host_label, embedded_label, *, tolerance=1.0,
             host_entities=None, embedded_entities=None,
             stiffness="auto", stiffness_p=None,
             rotational=False, pressure=False,
             host_coupling="linear",
             name=None) -> EmbeddedDef:
    """Embed lower-dimensional elements inside a host volume or
    surface.

    Each node of the embedded part is constrained to the
    displacement field of the host element it falls inside via
    host shape functions. Used for **rebar in concrete**,
    stiffeners in shells, fibres in composite hosts, etc.

    Supported host element types:

    * **3-D host:** tet4 (etype 4), tet10 (11), hex8 (5),
      hex20 (17), prism6 (6), prism15 (18), pyramid5 (7),
      pyramid13 (14).
    * **2-D host:** tri3 / CST (etype 2), tri6 / LST (9),
      quad4 (3), quad8 (16), quad9 (10).

    Non-simplex and higher-order hosts are decomposed to
    linear sub-tris / sub-tets using corner nodes only (hex8
    → 6 Kuhn tets; prism6 → 3 tets; pyramid5 → 2 tets;
    quad4 → 2 tris on the (0,2) diagonal; tri6 / tet10 /
    hex20 / quad8 / quad9 / prism15 / pyramid13 →
    corner-only).  The embedded coupling is therefore
    **linear** regardless of the host's native interpolation
    order — see ``host_coupling`` and :class:`EmbeddedDef`
    for the full contract.  A ``UserWarning`` fires once per
    (host type, entity) the first time a midside-bearing host
    is decomposed.

    The resolver automatically drops embedded nodes that
    coincide with host element corners, since those are
    already rigidly attached through shared connectivity.

    Parameters
    ----------
    host_label : str
        Part label whose host elements form the embedding
        field.  Stored internally as ``master_label``.
    embedded_label : str
        Part label whose nodes are embedded. Stored as
        ``slave_label``. (Label validation is bypassed for
        ``EmbeddedDef`` — these labels may also be physical
        group names if no part registry is in use.)
    tolerance : float, default 1.0
        Maximum dimensionless barycentric excess allowed when
        locating an embedded node inside a host sub-element.
        ``0.0`` means strictly inside; the default ``1.0``
        preserves pre-Phase-2 permissive behaviour.  See
        :class:`EmbeddedDef` for the fail-loud gate.
    stiffness : float or ``"auto"``, default ``"auto"``
        Penalty stiffness ``K`` of the emitted
        ``ASDEmbeddedNodeElement``. ``"auto"`` resolves at emit
        from the host material (``K = α·E_host·L_char``, α = 1e3).
        **A numeric value is unit-dependent and must be calibrated
        against a known solution** — the pre-slice-B default
        ``1e18`` (the OpenSees C++ default) stalls Newton in
        N/mm/MPa models while ``1e10``–``1e12`` converge; emit
        still warns when a record carries 1e18. (Unlike
        :meth:`tie`, ``embedded`` has no ``enforce="equation"``
        escape hatch — the fork ``g.embed`` is the conditioned
        alternative.)
    stiffness_p : float, optional
        Separate rotational/pressure penalty (``-KP``); ``None`` ⇒
        falls back to ``K``. Same unit caveat.
    host_entities, embedded_entities : list of (dim, tag), optional
        Restrict the host / embedded sides to specific Gmsh
        entities.  When omitted the whole label is used.
    host_coupling : {"linear"}, default ``"linear"``
        Reserved keyword pinning the coupling kinematics.  Only
        ``"linear"`` is currently accepted (coupling to 3 or 4
        corner nodes via barycentric shape functions, matching
        ``ASDEmbeddedNodeElement``).  Reserved so that future
        higher-order options (``"trilinear"``, ``"biquadratic"``)
        can be added without breaking old models.
    name : str, optional
        Friendly name.

    Returns
    -------
    EmbeddedDef

    Notes
    -----
    Emitted downstream as ``ASDEmbeddedNodeElement``. The
    ``host_label`` / ``embedded_label`` argument names mirror
    Abaqus's ``*EMBEDDED ELEMENT`` vocabulary; internally the
    composite still stores them as ``master``/``slave`` for
    consistency with the rest of the constraint records.

    Examples
    --------
    Rebar curve embedded inside a concrete tet mesh::

        g.constraints.embedded(
            host_label="concrete_block",
            embedded_label="rebar_curve",
            tolerance=2.0,        # mm
        )

    Same rebar embedded into a hex8 mesh (each rebar node is
    located inside one of the 6 Kuhn sub-tets of the
    enclosing hex and coupled to that sub-tet's 4 corners)::

        g.constraints.embedded(
            host_label="concrete_block_hex",
            embedded_label="rebar_curve",
        )
    """
    return self._add_def(EmbeddedDef(
        master_label=host_label, slave_label=embedded_label,
        tolerance=tolerance, host_entities=host_entities,
        embedded_entities=embedded_entities, name=name,
        stiffness=stiffness, stiffness_p=stiffness_p,
        rotational=rotational, pressure=pressure,
        host_coupling=host_coupling))

node_to_surface

node_to_surface(master, slave, *, dofs=None, tolerance=1e-06, name=None)

6-DOF node to 3-DOF surface coupling via phantom nodes.

Creates a single constraint that aggregates all surface entities in slave. Shared-edge mesh nodes are deduplicated so each original slave node gets exactly one phantom.

Parameters

master : int, str, or (dim, tag) The 6-DOF reference node. slave : int, str, or (dim, tag) The surface(s) to couple. If it resolves to multiple surface entities, they are combined into a single constraint and slave nodes are deduplicated.

Returns

NodeToSurfaceDef A single def covering all resolved surface entities.

Source code in src/apeGmsh/core/ConstraintsComposite.py
def node_to_surface(self, master, slave, *,
                    dofs=None, tolerance=1e-6,
                    name=None):
    """6-DOF node to 3-DOF surface coupling via phantom nodes.

    Creates a single constraint that aggregates all surface
    entities in *slave*.  Shared-edge mesh nodes are deduplicated
    so each original slave node gets exactly one phantom.

    Parameters
    ----------
    master : int, str, or (dim, tag)
        The 6-DOF reference node.
    slave : int, str, or (dim, tag)
        The surface(s) to couple.  If it resolves to multiple
        surface entities, they are combined into a single
        constraint and slave nodes are deduplicated.

    Returns
    -------
    NodeToSurfaceDef
        A single def covering all resolved surface entities.
    """
    from ._helpers import resolve_to_tags
    m_tags = resolve_to_tags(master, dim=0, session=self._parent)
    s_tags = resolve_to_tags(slave,  dim=2, session=self._parent)
    if len(m_tags) != 1:
        raise ValueError(
            f"node_to_surface master {master!r} resolved to "
            f"{len(m_tags)} dim-0 entities {m_tags} — the master "
            f"must identify exactly one reference point.")
    if not s_tags:
        raise ValueError(
            f"node_to_surface slave {slave!r} resolved to no "
            f"dim-2 surface entities.")
    master_tag = m_tags[0]

    if name is None:
        m_name = str(master) if isinstance(master, str) else str(master_tag)
        s_name = str(slave) if isinstance(slave, str) else "surface"
        display_name = f"{m_name}{s_name}"
    else:
        display_name = name

    # Store ALL surface tags as a comma-separated string so the
    # resolver can union their slave nodes and deduplicate.
    slave_label = ",".join(str(int(t)) for t in s_tags)

    return self._add_def(NodeToSurfaceDef(
        master_label=str(master_tag),
        slave_label=slave_label,
        dofs=dofs, tolerance=tolerance,
        name=display_name))

node_to_surface_spring

node_to_surface_spring(master, slave, *, dofs=None, tolerance=1e-06, name=None)

Spring-based variant of :meth:node_to_surface.

Identical topology and call signature, but the master → phantom links are tagged for downstream emission as stiff elasticBeamColumn elements instead of kinematic rigidLink('beam', ...) constraints. Use this variant when the master carries free rotational DOFs (fork support on a solid end face) that receive direct moment loading — the constraint-based variant of node_to_surface can produce an ill-conditioned reduced stiffness matrix in that case because the master rotation DOFs get stiffness only through the kinematic constraint back-propagation, with nothing attaching directly to them.

See :class:~apeGmsh.solvers.Constraints.NodeToSurfaceSpringDef for the full rationale.

Emission in OpenSees::

# Each master → phantom link becomes a stiff beam element
next_eid = max_tet_eid + 1
for master, slaves in fem.nodes.constraints.stiff_beam_groups():
    for phantom in slaves:
        ops.element(
            'elasticBeamColumn', next_eid,
            master, phantom,
            A_big, E, I_big, I_big, J_big, transf_tag,
        )
        next_eid += 1

# equalDOFs are unchanged from the normal variant
for pair in fem.nodes.constraints.equal_dofs():
    ops.equalDOF(
        pair.master_node, pair.slave_node, *pair.dofs)
Parameters

Same as :meth:node_to_surface.

Returns

NodeToSurfaceSpringDef

Source code in src/apeGmsh/core/ConstraintsComposite.py
def node_to_surface_spring(self, master, slave, *,
                           dofs=None, tolerance=1e-6,
                           name=None):
    """Spring-based variant of :meth:`node_to_surface`.

    Identical topology and call signature, but the master → phantom
    links are tagged for downstream emission as stiff
    ``elasticBeamColumn`` elements instead of kinematic
    ``rigidLink('beam', ...)`` constraints. Use this variant when
    the master carries **free rotational DOFs** (fork support on a
    solid end face) that receive direct moment loading — the
    constraint-based variant of ``node_to_surface`` can produce an
    ill-conditioned reduced stiffness matrix in that case because
    the master rotation DOFs get stiffness only through the
    kinematic constraint back-propagation, with nothing attaching
    directly to them.

    See :class:`~apeGmsh.solvers.Constraints.NodeToSurfaceSpringDef`
    for the full rationale.

    Emission in OpenSees::

        # Each master → phantom link becomes a stiff beam element
        next_eid = max_tet_eid + 1
        for master, slaves in fem.nodes.constraints.stiff_beam_groups():
            for phantom in slaves:
                ops.element(
                    'elasticBeamColumn', next_eid,
                    master, phantom,
                    A_big, E, I_big, I_big, J_big, transf_tag,
                )
                next_eid += 1

        # equalDOFs are unchanged from the normal variant
        for pair in fem.nodes.constraints.equal_dofs():
            ops.equalDOF(
                pair.master_node, pair.slave_node, *pair.dofs)

    Parameters
    ----------
    Same as :meth:`node_to_surface`.

    Returns
    -------
    NodeToSurfaceSpringDef
    """
    from ._helpers import resolve_to_tags
    m_tags = resolve_to_tags(master, dim=0, session=self._parent)
    s_tags = resolve_to_tags(slave,  dim=2, session=self._parent)
    if len(m_tags) != 1:
        raise ValueError(
            f"node_to_surface_spring master {master!r} resolved "
            f"to {len(m_tags)} dim-0 entities {m_tags} — the "
            f"master must identify exactly one reference point.")
    if not s_tags:
        raise ValueError(
            f"node_to_surface_spring slave {slave!r} resolved to "
            f"no dim-2 surface entities.")
    master_tag = m_tags[0]

    if name is None:
        m_name = str(master) if isinstance(master, str) else str(master_tag)
        s_name = str(slave) if isinstance(slave, str) else "surface"
        display_name = f"{m_name} \u2192 {s_name} (spring)"
    else:
        display_name = name

    slave_label = ",".join(str(int(t)) for t in s_tags)

    return self._add_def(NodeToSurfaceSpringDef(
        master_label=str(master_tag),
        slave_label=slave_label,
        dofs=dofs, tolerance=tolerance,
        name=display_name))

tied_contact

tied_contact(master_label, slave_label, *, master_entities=None, slave_entities=None, dofs=None, tolerance=1.0, stiffness='auto', stiffness_p=None, rotational=False, pressure=False, enforce='penalty', control=None, name=None) -> TiedContactDef

Full surface-to-surface tie (slave conforms to master).

Every slave-surface node is tied to the master surface via shape-function interpolation. One-directional: an earlier bidirectional variant (also projecting master nodes onto slave faces) was removed because it produced cyclic / over-determined MPCs the constraint handler cannot satisfy. Pick the finer mesh as the master.

enforce= selects the coupling route exactly as in :meth:tie ("penalty" default → ASDEmbeddedNodeElement; "equation" → exact equationConstraint; "penalty_al"LadrunoEmbeddedNode).

Resolution emits :class:~apeGmsh.solvers.Constraints.SurfaceCouplingRecord objects on fem.elements.constraints.

Parameters

master_label : str Part label of the first surface. slave_label : str Part label of the second surface. master_entities, slave_entities : list of (dim, tag), optional Restrict each side to specific Gmsh entities. dofs : list[int], optional DOFs to tie. None = all translational. tolerance : float, default 1.0 Maximum projection distance. Unit-sensitive. stiffness : float or "auto", default "auto" Penalty stiffness K of each emitted ASDEmbeddedNodeElement (penalty routes only). "auto" resolves at emit from the host material — see :meth:tie for the formula and the numeric-value caveat (a fixed number is unit-dependent; the old 1e18 default stalls Newton in N/mm/MPa and still warns at emit). stiffness_p : float, optional Separate rotational/pressure penalty (-KP); None ⇒ falls back to K. enforce : {"penalty", "penalty_al", "equation"}, default "penalty" Coupling route (ADR 0068) — same semantics as :meth:tie; "equation" emits exact equationConstraint rows and rejects the penalty-only knobs. name : str, optional Friendly name.

Returns

TiedContactDef

Raises

KeyError If either label is not in g.parts.

See Also

tie : One-directional tie (slave-projected only). mortar : Deprecated alias for a fork mortar mesh-tie (contact(formulation="mortar", tie=True)).

Source code in src/apeGmsh/core/ConstraintsComposite.py
def tied_contact(self, master_label, slave_label, *,
                 master_entities=None, slave_entities=None,
                 dofs=None, tolerance=1.0,
                 stiffness="auto", stiffness_p=None,
                 rotational=False, pressure=False,
                 enforce="penalty", control=None,
                 name=None) -> TiedContactDef:
    """Full surface-to-surface tie (slave conforms to master).

    Every slave-surface node is tied to the master surface via
    shape-function interpolation. **One-directional**: an earlier
    bidirectional variant (also projecting master nodes onto slave
    faces) was removed because it produced cyclic / over-determined
    MPCs the constraint handler cannot satisfy. Pick the **finer**
    mesh as the master.

    ``enforce=`` selects the coupling route exactly as in :meth:`tie`
    (``"penalty"`` default → ``ASDEmbeddedNodeElement``; ``"equation"``
    → exact ``equationConstraint``; ``"penalty_al"`` →
    ``LadrunoEmbeddedNode``).

    Resolution emits
    :class:`~apeGmsh.solvers.Constraints.SurfaceCouplingRecord`
    objects on ``fem.elements.constraints``.

    Parameters
    ----------
    master_label : str
        Part label of the first surface.
    slave_label : str
        Part label of the second surface.
    master_entities, slave_entities : list of (dim, tag), optional
        Restrict each side to specific Gmsh entities.
    dofs : list[int], optional
        DOFs to tie. ``None`` = all translational.
    tolerance : float, default 1.0
        Maximum projection distance. **Unit-sensitive.**
    stiffness : float or ``"auto"``, default ``"auto"``
        Penalty stiffness ``K`` of each emitted
        ``ASDEmbeddedNodeElement`` (penalty routes only).
        ``"auto"`` resolves at emit from the host material — see
        :meth:`tie` for the formula and the numeric-value caveat
        (a fixed number is unit-dependent; the old ``1e18`` default
        stalls Newton in N/mm/MPa and still warns at emit).
    stiffness_p : float, optional
        Separate rotational/pressure penalty (``-KP``); ``None`` ⇒
        falls back to ``K``.
    enforce : {"penalty", "penalty_al", "equation"}, default "penalty"
        Coupling route (ADR 0068) — same semantics as :meth:`tie`;
        ``"equation"`` emits exact ``equationConstraint`` rows and
        rejects the penalty-only knobs.
    name : str, optional
        Friendly name.

    Returns
    -------
    TiedContactDef

    Raises
    ------
    KeyError
        If either label is not in ``g.parts``.

    See Also
    --------
    tie : One-directional tie (slave-projected only).
    mortar : Deprecated alias for a fork mortar mesh-tie
        (``contact(formulation="mortar", tie=True)``).
    """
    return self._add_def(TiedContactDef(
        master_label=master_label, slave_label=slave_label,
        master_entities=master_entities, slave_entities=slave_entities,
        dofs=dofs, tolerance=tolerance, name=name,
        stiffness=stiffness, stiffness_p=stiffness_p,
        rotational=rotational, pressure=pressure, enforce=enforce,
        control=control))

mortar

mortar(master_label, slave_label, *, eps_n='auto', outward, master_entities=None, slave_entities=None, name=None) -> ContactDef

Deprecated alias for a fork segment-to-segment mortar mesh-tie.

.. deprecated:: mortar() is a thin convenience alias for :meth:contact with formulation="mortar", tie=True; call that directly. It emits a :class:DeprecationWarning.

Delegates to the fork's ALM-penalty mortar mesh-tie (ADR 0073): a permanent segment-to-segment bond (the zero-gap limit — the full 3-vector residual driven to zero, no friction). Returns a :class:ContactDef resolving to fem.elements.contacts (the fork contactSurface + contact -mortar -tie pair + the LadrunoContact handler), not the old MortarDef / Lagrange-multiplier path. Fork-only at run time; deck emission works on any build.

This is a breaking change from the prior stub (which raised NotImplementedError): the return type is now ContactDef, the semantics are an ALM penalty contact-tie (not a Lagrange-multiplier operator), and the never-functional dofs / integration_order parameters are removed (a permanent penalty tie bonds the full 3-vector with a single penalty and has no DOF-subset or quadrature-order knob — passing them now raises TypeError).

Parameters

master_label, slave_label : str The two surface PG / part labels to bond. Both resolve to faceted surfaces (master -master, slave -slave-segments); pick the finer mesh as whichever side you trust more — the fork integrates the overlap either way. eps_n : float | "auto", default "auto" ALM normal penalty for the tie ("auto" sizes it from the solid). outward : (float, float, float) Required. The master surface normal toward the slave. A tie interface is coincident-flat, so without an explicit sign the fork's per-pair reference is in-plane and gate H2 silently drops every pair to zero force (the tie would bond nothing). See :class:ContactDef. master_entities, slave_entities : list of (dim, tag), optional Restrict each side to specific Gmsh entities. name : str, optional Friendly name (round-trips into the emitted deck comment).

Returns

ContactDef

See Also

contact : The canonical fork contact / mortar-tie generator. tied_contact : Collocation-based non-matching tie (no fork required).

Source code in src/apeGmsh/core/ConstraintsComposite.py
def mortar(self, master_label, slave_label, *,
           eps_n="auto", outward,
           master_entities=None, slave_entities=None,
           name=None) -> ContactDef:
    """Deprecated alias for a fork segment-to-segment mortar mesh-tie.

    .. deprecated::
       ``mortar()`` is a thin convenience alias for
       :meth:`contact` with ``formulation="mortar", tie=True``; call
       that directly. It emits a :class:`DeprecationWarning`.

    Delegates to the fork's ALM-penalty mortar **mesh-tie** (ADR 0073):
    a permanent segment-to-segment bond (the zero-gap limit — the full
    3-vector residual driven to zero, no friction). Returns a
    :class:`ContactDef` resolving to ``fem.elements.contacts`` (the fork
    ``contactSurface`` + ``contact -mortar -tie`` pair + the
    ``LadrunoContact`` handler), **not** the old ``MortarDef`` /
    Lagrange-multiplier path. Fork-only at run time; deck emission works on
    any build.

    This is a **breaking** change from the prior stub (which raised
    ``NotImplementedError``): the return type is now ``ContactDef``, the
    semantics are an ALM penalty contact-tie (not a Lagrange-multiplier
    operator), and the never-functional ``dofs`` / ``integration_order``
    parameters are removed (a permanent penalty tie bonds the full
    3-vector with a single penalty and has no DOF-subset or quadrature-order
    knob — passing them now raises ``TypeError``).

    Parameters
    ----------
    master_label, slave_label : str
        The two surface PG / part labels to bond. Both resolve to faceted
        surfaces (master ``-master``, slave ``-slave-segments``); pick the
        finer mesh as whichever side you trust more — the fork integrates
        the overlap either way.
    eps_n : float | "auto", default "auto"
        ALM normal penalty for the tie (``"auto"`` sizes it from the solid).
    outward : (float, float, float)
        **Required.** The master surface normal toward the slave. A tie
        interface is coincident-flat, so without an explicit sign the fork's
        per-pair reference is in-plane and gate H2 silently drops every pair
        to zero force (the tie would bond nothing). See :class:`ContactDef`.
    master_entities, slave_entities : list of (dim, tag), optional
        Restrict each side to specific Gmsh entities.
    name : str, optional
        Friendly name (round-trips into the emitted deck comment).

    Returns
    -------
    ContactDef

    See Also
    --------
    contact : The canonical fork contact / mortar-tie generator.
    tied_contact : Collocation-based non-matching tie (no fork required).
    """
    import warnings
    warnings.warn(
        "g.constraints.mortar() is a deprecated alias for "
        "g.constraints.contact(formulation='mortar', tie=True, ...); "
        "call contact() directly. mortar() now delegates to the fork "
        "ALM-penalty mortar mesh-tie (returns a ContactDef, not the old "
        "MortarDef Lagrange path) — see ADR 0073.",
        DeprecationWarning, stacklevel=2,
    )
    return self.contact(
        master_label, slave_label,
        formulation="mortar", tie=True,
        eps_n=eps_n, outward=outward,
        master_entities=master_entities, slave_entities=slave_entities,
        name=name,
    )

validate_pre_mesh

validate_pre_mesh() -> None

No-op: constraints validate targets eagerly at _add_def.

Present so :meth:Mesh.generate can invoke validate_pre_mesh on all three composites uniformly.

Source code in src/apeGmsh/core/ConstraintsComposite.py
def validate_pre_mesh(self) -> None:
    """No-op: constraints validate targets eagerly at ``_add_def``.

    Present so :meth:`Mesh.generate` can invoke ``validate_pre_mesh``
    on all three composites uniformly.
    """
    return None

summary

summary()

DataFrame of the declared constraint intent — one row per def.

Columns: kind, name, master, slave, params. params is a short stringified view of the kind-specific fields (dofs, tolerance, etc.).

Source code in src/apeGmsh/core/ConstraintsComposite.py
def summary(self):
    """DataFrame of the declared constraint intent — one row per def.

    Columns: ``kind, name, master, slave, params``.  ``params`` is a
    short stringified view of the kind-specific fields (``dofs``,
    ``tolerance``, etc.).
    """
    import pandas as pd
    from dataclasses import fields

    _COMMON = {"kind", "name", "master_label", "slave_label"}

    rows: list[dict] = []
    for d in self.constraint_defs:
        params = {
            f.name: getattr(d, f.name)
            for f in fields(d)
            if f.name not in _COMMON
        }
        params = {k: v for k, v in params.items() if v is not None}
        rows.append({
            "kind"  : d.kind,
            "name"  : d.name or "",
            "master": d.master_label,
            "slave" : d.slave_label,
            "params": ", ".join(f"{k}={v}" for k, v in params.items()),
        })

    cols = ["kind", "name", "master", "slave", "params"]
    if not rows:
        return pd.DataFrame(columns=cols)
    return pd.DataFrame(rows, columns=cols)

Base class

All Stage-1 definitions inherit from ConstraintDef — a thin dataclass carrying kind, master_label, slave_label, and an optional friendly name. Subclasses add their kind-specific parameters.

apeGmsh._kernel.defs.constraints.ConstraintDef dataclass

ConstraintDef(kind: str, master_label: str, slave_label: str, name: str | None = None)

Base class for all constraint definitions.

Tier 1 — Node-to-Node

Pairwise constraints between co-located nodes. The resolver matches master-side nodes against slave-side nodes within tolerance and emits one NodePairRecord per match.

apeGmsh._kernel.defs.constraints.EqualDOFDef dataclass

EqualDOFDef(kind: str, master_label: str, slave_label: str, name: str | None = None, dofs: list[int] | None = None, tolerance: float = 1e-06, master_entities: list[tuple[int, int]] | None = None, slave_entities: list[tuple[int, int]] | None = None)

Bases: ConstraintDef

Co-located nodes share selected DOFs.

After meshing, the resolver finds node pairs within tolerance on the interface between master and slave instances, and produces one :class:NodePairRecord per pair.

Parameters

dofs : list[int] or None DOF numbers to constrain (1-based: 1=ux, 2=uy, 3=uz, 4=rx, 5=ry, 6=rz). None = all DOFs. tolerance : float Spatial distance (in model units) within which two nodes are considered co-located. master_entities : list of (dim, tag), optional Limit the master search to specific geometric entities. slave_entities : list of (dim, tag), optional Limit the slave search to specific geometric entities.

apeGmsh._kernel.defs.constraints.RigidLinkDef dataclass

RigidLinkDef(kind: str, master_label: str, slave_label: str, name: str | None = None, link_type: str = 'beam', master_point: tuple[float, float, float] | None = None, slave_entities: list[tuple[int, int]] | None = None, tolerance: float = 1e-06)

Bases: ConstraintDef

Rigid bar connecting master and slave nodes.

rigid_beam -> full 6-DOF coupling (translations + rotations)::

u_s = u_m + θ_m × r       (translations)
θ_s = θ_m                  (rotations)

rigid_rod -> translations only, rotations independent::

u_s = u_m + θ_m × r
(θ_s free)
Parameters

link_type : "beam" or "rod" master_point : (x,y,z) or None If given, the master is the nearest node in the master set to this point. If None, the master is the node nearest the master set's centroid. slave_entities : list of (dim, tag), optional Geometric entities whose nodes become slaves. tolerance : float Reserved. Not currently enforced for master selection (the nearest node is taken unconditionally); kept for API stability and a future proximity-gated check.

apeGmsh._kernel.defs.constraints.PenaltyDef dataclass

PenaltyDef(kind: str, master_label: str, slave_label: str, name: str | None = None, stiffness: float = 10000000000.0, dofs: list[int] | None = None, tolerance: float = 1e-06, master_entities: list[tuple[int, int]] | None = None, slave_entities: list[tuple[int, int]] | None = None)

Bases: ConstraintDef

Soft spring between co-located node pairs.

Numerically approximates EqualDOF when K -> ∞. Useful when hard constraints cause ill-conditioning.

Parameters

stiffness : float Penalty spring stiffness (force/length units). dofs : list[int] or None DOFs to penalise. tolerance : float Node-matching tolerance.

Tier 2 — Node-to-Group

One master node drives many slave nodes through a kinematic transformation about a master point. Use these for floor diaphragms, lumped rigid bodies, or any cluster sharing a chosen DOF subset.

apeGmsh._kernel.defs.constraints.RigidDiaphragmDef dataclass

RigidDiaphragmDef(kind: str, master_label: str, slave_label: str, name: str | None = None, master_point: tuple[float, float, float] = (0.0, 0.0, 0.0), plane_normal: tuple[float, float, float] = (0.0, 0.0, 1.0), constrained_dofs: list[int] = (lambda: [1, 2, 6])(), plane_tolerance: float = 1.0)

Bases: ConstraintDef

In-plane rigid body constraint. All slave nodes at a given plane follow the master node for in-plane DOFs.

Classic use: floor slabs in multi-story buildings — all nodes at a floor elevation share in-plane translation + rotation about the out-of-plane axis.

Parameters

master_point : (x, y, z) Master node location (typically center of mass). plane_normal : (nx, ny, nz) Normal to the diaphragm plane. (0,0,1) = horizontal floor. constrained_dofs : list[int] DOFs constrained in-plane. For a horizontal floor with Z as vertical: [1, 2, 6] (ux, uy, rz). plane_tolerance : float Distance from the plane within which nodes are collected.

apeGmsh._kernel.defs.constraints.RigidBodyDef dataclass

RigidBodyDef(kind: str, master_label: str, slave_label: str, name: str | None = None, master_point: tuple[float, float, float] = (0.0, 0.0, 0.0), slave_entities: list[tuple[int, int]] | None = None, as_element: bool = False, mass: float | None = None, omega: tuple[float, float, float] | None = None)

Bases: ConstraintDef

Full rigid body constraint: all 6 DOFs of every slave node follow the master.

By default the body is emitted as a chain of rigidLink "beam" constraints (master → each slave). Set as_element=True to emit the fork element LadrunoRigidBody instead (class tag 33015, 3D only): the whole node set {master, *slaves} becomes one 6-DOF rigid body with a private internal centre-of-mass node and condensed mass — which the rigidLink chain cannot represent (no body mass, no CoM, no explicit-dynamics support). Fork-only: the element line emits on any build but needs the Ladruno fork to run.

Parameters

master_point : (x, y, z) Master node location. slave_entities : list of (dim, tag), optional Geometric entities whose nodes become slaves. as_element : bool, default False Emit element LadrunoRigidBody over {master, *slaves} (3D only) instead of the rigidLink chain. mass : float or None Total body mass for the as_element form (-mass); None condenses the mass from the slaves' own nodal mass. Ignored by the rigidLink form (raises if set without as_element). omega : (wx, wy, wz) or None Initial body-frame angular velocity for the as_element form (-omega, an explicit-dynamics initial condition — the body spins from t=0). None ⇒ no initial spin. Only valid with as_element=True.

apeGmsh._kernel.defs.constraints.KinematicCouplingDef dataclass

KinematicCouplingDef(kind: str, master_label: str, slave_label: str, name: str | None = None, master_point: tuple[float, float, float] = (0.0, 0.0, 0.0), slave_entities: list[tuple[int, int]] | None = None, dofs: list[int] | None = None, control: CouplingControl = CouplingControl())

Bases: ConstraintDef

RBE2 / kinematic coupling — a reference (master) node rigidly drives a set of slave nodes.

Emitted as the Ladruno-fork element LadrunoKinematicCoupling (class tag 33012): a penalty rigid-body driver with the correct moment-arm transport u_i = u_R + θ_R × d_i (so an offset reference is handled rigidly — unlike the old equalDOF expansion, which ignored the lever arm). Fork-only: the deck emits on any build, but running it needs the Ladruno fork; stock OpenSees fails loud at the element line.

Parameters

master_point : (x, y, z) Reference (master) node location — must carry the rotational DOFs (ndf 6 in 3D / 3 in 2D); the fork refuses a too-small reference. slave_entities : list of (dim, tag), optional Geometric entities whose nodes become slaves (may mix 3- and 6-DOF nodes — the element resolves the ragged layout). dofs : list[int] or None 1-based dependent components to tie on each slave (-dof). None (default) ties every DOF the slave has (the element's own default), which is the right behaviour for a mixed 3/6-DOF slave set; pass an explicit list to restrict (e.g. [1, 2, 3] for translations only).

Tier 2b — Mixed-DOF

A 6-DOF master node coupled to 3-DOF slave nodes (typically a beam end framing into a solid face). The resolver duplicates each slave to a 6-DOF phantom node so that rotational kinematics can propagate through a rigid arm before being equal-DOF-coupled to the original 3-DOF slave.

Two variants:

  • NodeToSurfaceDef emits the master → phantom link as a kinematic rigidLink('beam', …) constraint. Cheap and exact.
  • NodeToSurfaceSpringDef emits it as a stiff elasticBeamColumn element. Use this when the master has free rotational DOFs that receive direct moment loading — the constraint variant can produce an ill-conditioned reduced stiffness matrix in that case.

apeGmsh._kernel.defs.constraints.NodeToSurfaceDef dataclass

NodeToSurfaceDef(kind: str, master_label: str, slave_label: str, name: str | None = None, master_point: tuple[float, float, float] | None = None, dofs: list[int] | None = None, tolerance: float = 1e-06)

Bases: ConstraintDef

6-DOF node to 3-DOF surface coupling via phantom (duplicate) nodes.

Connects a 6-DOF master node (beam, frame, or any reference point) to a group of 3-DOF slave nodes on a surface (solid elements) through an intermediate layer of phantom nodes that carry full 6-DOF kinematics.

The resolver:

  1. Duplicates each slave node -> creates phantom node tags at the same coordinates (6-DOF intermediaries).
  2. Rigid links master -> each phantom node (rigid_beam), propagating rotational effects through the offset arm::

    u_phantom = u_master + θ_master × r

  3. EqualDOF phantom -> original slave, translations only [1, 2, 3] (rotations discarded since the solid has none).

This is the standard technique for mixed-dimensionality coupling (Abaqus *COUPLING, KINEMATIC on solids; OpenSees manual rigid-link + equalDOF pattern).

Unlike other constraint definitions that take string labels, this one accepts bare tags:

  • master_label: node tag (int, dim=0) — the 6-DOF node.
  • slave_label: surface entity tag (int, dim=2) — the Gmsh surface whose nodes become the 3-DOF slaves.
Parameters

dofs : list[int] or None Translational DOFs coupled to the solid. Default [1, 2, 3]. master_point : (x, y, z) or None Ignored. The master is taken directly from the master_label node tag (this def uses bare tags, see above); there is no proximity master-detection. Retained only for dataclass/API stability. tolerance : float Ignored for the same reason. Retained for API stability.

apeGmsh._kernel.defs.constraints.NodeToSurfaceSpringDef dataclass

NodeToSurfaceSpringDef(kind: str, master_label: str, slave_label: str, name: str | None = None, master_point: tuple[float, float, float] | None = None, dofs: list[int] | None = None, tolerance: float = 1e-06)

Bases: NodeToSurfaceDef

Spring-based variant of :class:NodeToSurfaceDef.

Same topology as NodeToSurfaceDef — a 6-DOF master node is coupled to the 3-DOF nodes of a surface through an intermediate layer of phantom nodes — but the master → phantom link is emitted downstream as a stiff elasticBeamColumn element instead of a kinematic rigidLink('beam', …) constraint.

Why this variant exists

The standard NodeToSurfaceDef uses rigidLink + equalDOF. That chain works perfectly for most cases — rigid load transfer, prescribed translations at a master, fully-fixed masters — but breaks down when all three of the following are true:

  • The master has free rotational DOFs (fork support, free bending rotations at a simply-supported end).
  • A moment is applied directly to those free rotation DOFs.
  • The slave side is a solid element with ndf=3 (tet4, hex8, …), so the rigid-link constraint back-propagates stiffness to the master rotations only through kinematic coupling — no element attaches directly to master.ry / master.rz.

Under those conditions the reduced stiffness matrix becomes ill-conditioned and OpenSees's solver fails with "numeric analysis returns 1 -- UmfpackGenLinSolver::solve".

The spring variant fixes it by giving the master's rotation DOFs direct element stiffness: each master → phantom link becomes a stiff elasticBeamColumn element whose 6-DOF stiffness matrix contributes terms on the master's rotation diagonal regardless of any constraint handler gymnastics. Conditioning stays good.

Trade-offs
  • Pro — robust for fork supports + moment loading.
  • Pro — element-level stiffness is directly assembled into K, so no penalty factor to tune.
  • Con — each master → phantom link is now an element, so the element count grows by n_slaves per coupling. For a typical face with ~30 slave nodes this is ~30 extra elasticBeamColumn elements per node_to_surface_spring call. Negligible in solve time.
  • Con — approximate-rigid rather than truly rigid: the stiff beams have finite stiffness, so there is a tiny compliance in the coupling. Choose the section properties so they are orders of magnitude stiffer than the downstream elements.
Parameters

Inherited from :class:NodeToSurfaceDef.

See Also

NodeToSurfaceDef : constraint-based variant.

Tier 3 — Node-to-Surface

A slave node is constrained to the displacement field of a master surface or volume through shape-function interpolation. Handles non-matching meshes, distributed loads, and embedded reinforcement.

Embedded — host mesh compatibility

g.constraints.embedded(host_label, embedded_label, ...) accepts any standard structural mesh on the host side. The collector decomposes non-simplex and higher-order hosts into linear sub-tris / sub-tets using corner nodes only, then dispatches to the existing C++ ASDEmbeddedNodeElement (which accepts 3- or 4-node retained sets).

Gmsh etype Code Host-side decomposition
tri3 (CST) 2 identity (1 tri per host)
tet4 4 identity (1 tet per host)
quad4 3 2 tris via (0,2) diagonal split
hex8 5 6 right-handed Kuhn tets (shared main diagonal)
prism6 6 3 tets
pyramid5 7 2 tets
tri6 (LST) 9 corners only → 1 tri (midsides discarded)
tet10 11 corners only → 1 tet
pyramid13 14 corners only → 2 tets
quad8 / quad9 16/10 corners only → 2 tris
hex20 17 corners only → 6 Kuhn tets
prism15 18 corners only → 3 tets

Sub-element rows are virtual — they do not correspond to elements in the gmsh mesh. They exist purely as a coupling-layer fabrication so the linear-shape-function coupling of ASDEmbeddedNodeElement works against any supported host topology.

The linear-coupling contract (host_coupling="linear")

The embedded coupling is always linear over 3 or 4 corner nodes, regardless of the host's native interpolation order. An LST plate's quadratic curvature, a hex8's bilinear twist mode, a quad9's biquadratic field — none are seen by the embedded node. The embed sees only the linear corner-to-corner stretch of whichever sub-tri / sub-tet contains it.

EmbeddedDef.host_coupling is a reserved keyword that pins this behaviour. Only "linear" is currently accepted. The keyword is reserved (not just documented) so a future "trilinear" / "biquadratic" option — which would require a new OpenSees element class supporting N-node retained sets — can land without breaking existing models.

Warning on midside-bearing hosts

The first time the collector decomposes a host that carries midside nodes (tri6, tet10, quad8, quad9, hex20, prism15, pyramid13), one UserWarning fires per (etype, entity) pointing at the linear-coupling consequence. Acknowledge by setting host_coupling="linear" explicitly on the embedded(...) call.

If you chose LST / quad8 / hex20 specifically for curvature fidelity, the embed will not give it to you — either accept the linear coupling or wait for the HostProjector work (deferred; see ADR 0036).

Per-hex coupling asymmetry

Two embedded nodes inside the same hex8 may couple to different 4-corner subsets depending on which of the 6 Kuhn sub-tets contains each one. This is geometrically correct under linear coupling but can surprise readers of the resolved records. The Kuhn decomposition is symmetric (orientation-independent across adjacent hexes), so there is no neighbour-hex-dependence in the choice.

Mixed-dim host fail-loud

A host part / physical group that combines 2D entities (shell, quad plate) and 3D entities (brick, tet volume) raises at collection time. The linear coupling cannot pick between sub-tris and sub-tets deterministically (kNN centroid search would dispatch based on opaque proximity, which is opaque physics). Split the host into two separate g.constraints.embedded(...) calls — one for the 2D part, one for the 3D part.

Off-host fail-loud

An embedded node that falls outside every host sub-element by more than EmbeddedDef.tolerance (default 1.0 from the factory; the class default is 0.0 for strictly-inside) raises naming the offending slave node and its barycentric excess. Either fix the geometry / mesh so the embed lies inside the host, or widen tolerance= explicitly if extrapolation is intentional.

See ADR 0036 for the full decision record (Kuhn-table orientation invariants, alternatives rejected, HostProjector RFC deferral).

Example — rebar in hex-meshed concrete

from apeGmsh import apeGmsh

with apeGmsh(model_name="rc_block") as g:
    # ... CAD import, parts, etc. ...

    # Hex-meshed concrete host, line-meshed rebar curve
    g.constraints.embedded(
        host_label="concrete_block_hex",
        embedded_label="rebar_curve",
        stiffness=1.0e8,        # STKO-parity penalty (ADR 0035)
        # host_coupling="linear" is the default; setting it
        # explicitly acknowledges the linear-coupling contract
        # if your host carries midside nodes.
    )

    g.mesh.generation.generate(dim=3)
    fem = g.mesh.queries.get_fem_data(dim=3)
    # Embedded records land on fem.elements.constraints as
    # InterpolationRecord; each rebar node couples to 4 of the
    # 8 corners of the hex that contains it (one of 6 Kuhn
    # sub-tets — see the per-hex asymmetry note above).

apeGmsh._kernel.defs.constraints.TieDef dataclass

TieDef(kind: str, master_label: str, slave_label: str, name: str | None = None, master_entities: list[tuple[int, int]] | None = None, slave_entities: list[tuple[int, int]] | None = None, dofs: list[int] | None = None, tolerance: float = 1.0, stiffness: 'float | str' = 'auto', stiffness_p: float | None = None, rotational: bool = False, pressure: bool = False, enforce: str = 'penalty', control: CouplingControl | None = None, method: str = 'collocation', outward: tuple[float, float, float] | None = None)

Bases: ConstraintDef

Surface tie via shape function interpolation.

Each slave node is projected onto the closest master element face. Its DOFs are constrained to the master face via::

u_slave = Σ  N_i(ξ,η) · u_master_i

where N_i are the shape functions of the master face element evaluated at the projected parametric coordinates.

This is what Abaqus *TIE does. It preserves displacement continuity even with non-matching meshes.

Parameters

master_entities : list of (dim, tag) Master surface entities. slave_entities : list of (dim, tag) Slave surface entities (nodes on these are projected). dofs : list[int] or None DOFs to tie. None = all translational DOFs [1,2,3]. tolerance : float Maximum projection distance. Slave nodes farther than this from the master surface are skipped.

apeGmsh._kernel.defs.constraints.DistributingCouplingDef dataclass

DistributingCouplingDef(kind: str, master_label: str, slave_label: str, name: str | None = None, master_point: tuple[float, float, float] = (0.0, 0.0, 0.0), slave_entities: list[tuple[int, int]] | None = None, weighting: str = 'uniform', control: CouplingControl = CouplingControl())

Bases: ConstraintDef

RBE3 / distributing coupling — a reference (dependent) node is the weighted-average rigid-body fit of a set of independent nodes, and a load applied at the reference is distributed to the set as a statically-equivalent force pattern, adding no stiffness to the independents (the set stays free to deform).

Emitted as the Ladruno-fork element LadrunoDistributingCoupling (class tag 33011). It is the inverse-role sibling of :class:KinematicCouplingDef (RBE2): there the single node is the rigid master; here it is the flexible dependent. Fork-only: the deck emits on any build, but running it needs the Ladruno fork.

Parameters

master_point : (x, y, z) Location of the reference (dependent) node R — must carry the rotational DOFs (ndf 6 in 3D / 3 in 2D) so a moment transmits. slave_entities : list of (dim, tag), optional Geometric entities whose nodes become the independent set (translations-only is fine — the fit injects no rotational stiffness into them). weighting : "uniform" | "area" "uniform" ⇒ equal weights (-w omitted ⇒ the fork element's equal-weight default). "area" ⇒ the resolver computes per-independent tributary areas over the slave surface faces (each face's area split equally among its nodes — the g.loads surface-tributary lumping model) and stores them on the record's weights-w w1..wN emitted in the sorted independent order.

apeGmsh._kernel.defs.constraints.EmbeddedDef dataclass

EmbeddedDef(kind: str, master_label: str, slave_label: str, name: str | None = None, host_entities: list[tuple[int, int]] | None = None, embedded_entities: list[tuple[int, int]] | None = None, tolerance: float = 0.0, stiffness: 'float | str' = 'auto', stiffness_p: float | None = None, rotational: bool = False, pressure: bool = False, host_coupling: str = 'linear')

Bases: ConstraintDef

Embedded element constraint: nodes of a lower-dimensional element (beam, truss) are constrained to the displacement field of a higher-dimensional host element (solid).

Used for reinforcement in concrete, stiffeners in shells, etc.

Parameters

host_entities : list of (dim, tag), optional Host volume/surface entities. Settable via g.constraints.embedded(..., host_entities=...); when omitted the whole host_label is used. embedded_entities : list of (dim, tag), optional Embedded line/surface entities. Settable via embedded(..., embedded_entities=...); when omitted the whole embedded_label is used. tolerance : float Maximum dimensionless barycentric excess allowed when locating an embedded node inside a host element. 0.0 (the default) means strictly inside; 0.05 allows ~5% extrapolation; inf accepts everything (the pre-Phase-2 behaviour). An embedded node whose excess exceeds this threshold raises ValueError from the resolver naming the offending slave node and its excess — fail-loud, since accepting an extrapolated node silently produces an ASDEmbeddedNodeElement with negative shape-function weights and the wrong physics. host_coupling : {"linear"} Reserved keyword that pins the coupling kinematics for this embed. Only "linear" is currently accepted: the embedded node is coupled to 3 or 4 corner nodes of a host tri/tet sub-element via linear barycentric shape functions, matching the kinematics of OpenSees ASDEmbeddedNodeElement.

For non-simplex / higher-order hosts (tri6, tet10, quad4,
quad8, quad9, hex8, hex20, prism6, prism15, pyramid5,
pyramid13) the
``ConstraintsComposite._collect_host_subelements`` collector
decomposes the host into linear sub-tris / sub-tets using
corner nodes only and ignores midside nodes.  Consequence:
the embedded coupling does NOT see the host's native
bilinear / trilinear / quadratic displacement field — only
a linear projection over the corner subset that brackets
the embedded point.

Per-hex asymmetry: two embedded nodes inside the same hex8
may couple to *different* 4-corner subsets depending on
which of the 6 Kuhn sub-tets contains each one.  This is
geometrically correct under linear coupling but can surprise
readers of the resolved records.

The keyword is reserved (not just documented) so that a
future ``"trilinear"`` / ``"biquadratic"`` option can be
added without changing the public API; pre-existing models
will keep producing identical numerical results because
``"linear"`` stays the default.

Tier 4 — Surface-to-Surface

Bidirectional surface couplings. Use these when neither side can be clearly picked as finer than the other and you want a symmetric treatment.

apeGmsh._kernel.defs.constraints.TiedContactDef dataclass

TiedContactDef(kind: str, master_label: str, slave_label: str, name: str | None = None, master_entities: list[tuple[int, int]] | None = None, slave_entities: list[tuple[int, int]] | None = None, dofs: list[int] | None = None, tolerance: float = 1.0, stiffness: 'float | str' = 'auto', stiffness_p: float | None = None, rotational: bool = False, pressure: bool = False, enforce: str = 'penalty', control: CouplingControl | None = None)

Bases: ConstraintDef

Full surface-to-surface tie. Every node on the slave surface is tied to the master surface via shape function interpolation.

One-directional (slave conforms to master): an earlier bidirectional variant was removed because projecting master nodes onto slave faces as well produced cyclic / over-determined MPCs the constraint handler cannot satisfy.

Parameters

master_entities : list of (dim, tag) slave_entities : list of (dim, tag) dofs : list[int] or None tolerance : float

Tier 5 — Fork contact

Every tier above is a permanent bond, active from the first step. g.constraints.contact(master, slave, ...) is the one that can open, close, slide and carry friction — a real contact interaction rather than a kinematic constraint, emitted through the Ladruno fork's contact subsystem. "nts" is node-to-segment penalty; "mortar" is segment-to-segment augmented Lagrange, the accuracy lane for non-matching interfaces.

g.constraints.contact_plane(slave, ...) is the same idea with no master mesh at all: the slave meets a fixed infinite rigid plane.

Both resolve additively onto fem.elements.contacts / fem.elements.contact_planes rather than the MP-constraint channels, both are serial-only, and both need a live gmsh session — declaring one on a from_h5 or composed session raises. The deck emits on any build but runs only on the fork. Contact has no recorder channel, so results come back through the live queries described in the constraints concept page.

apeGmsh._kernel.defs.constraints.ContactDef dataclass

ContactDef(kind: str, master_label: str, slave_label: str, name: str | None = None, master_entities: list[tuple[int, int]] | None = None, slave_entities: list[tuple[int, int]] | None = None, formulation: str = 'nts', kn: float | str | None = None, kt: float | None = None, mu: float | None = None, eps_n: float | str | None = None, eps_t: float | str | None = None, cohesion: float | None = None, tau_max: float | None = None, aug_tol: float | None = None, max_aug: int | None = None, ngp: int | None = None, tie: bool = False, thickness: float | None = None, outward: tuple | None = None, soft: float | bool | None = None, visc: float | None = None, consistent_tan: bool = False, geom_tan: bool = False, cell: float | None = None, edge_edge: bool = False, edge_kn: float | str | None = None, edge_band: float | None = None, edge_mu: float | None = None, edge_kt: float | None = None, edge_cohesion: float | None = None, edge_tau_max: float | None = None, edge_consistent_tan: bool = False, edge_soft: float | bool | None = None, edge_alm: bool = False, edge_aug_tol: float | None = None)

Bases: ConstraintDef

Face-to-face contact between two meshed surfaces, emitted as the fork's contactSurface + contact pair (node-to-segment or mortar/ALM).

The geometry-side def captured by g.constraints.contact(...). The master is a faceted surface; the slave is a node set (NTS) or a faceted surface (mortar). apeGmsh resolves the face connectivity + (optionally) an outward normal from the CAD geometry, then emits contactSurface defs + the contact verb + constraints('LadrunoContact').

Parameters

master_label, slave_label The two surface PG / part labels in contact. formulation "nts" (node-to-segment penalty, slave = node set) or "mortar" (segment-to-segment ALM, slave = faceted surface). kn, kt, mu NTS normal/tangential penalty + Coulomb friction. kn may be "auto" (sized from the solid). Mortar rejects kn/kt. eps_n, eps_t Mortar ALM normal/tangential penalty ("auto" allowed). NTS rejects. cohesion, tau_max Mortar friction-cone adhesion intercept + Tresca shear cap. aug_tol, max_aug, ngp Mortar Uzawa augmentation tolerance / max augmentations / slave-facet Gauss order. tie Permanent mesh-tie bond (mortar only; mutually exclusive with friction). thickness 2D mortar only — the plane-model out-of-plane thickness h (fork -thickness, default 1.0). The mortar lane's interval integrals produce force per unit thickness, so the fork applies h ONCE, at its 2D injection site, to eps_n / eps_t / visc / the friction clamps (cohesion / tau_max) and the tie stiffness. Three conventions live here and must not be conflated:

1. The **element** thickness (``ops.element.FourNodeQuad(thickness=…)``)
   is baked into the element's own stiffness; contact never re-reads
   or re-derives it.
2. ``thickness=h`` scales the **explicit** per-unit-thickness
   penalties listed above — apeGmsh only emits ``-thickness h``; the
   scaling itself is entirely fork-side.
3. ``eps_n="auto"`` is **NOT** h-scaled: it resolves from the owning
   element's ``getInitialStiff()``, which already absorbs the
   element's thickness, so re-scaling it by ``h`` would be an h²
   error (regression-gated fork-side). ``eps_t`` INHERITS that
   provenance when it is ``"auto"`` (or when friction defaults it
   from ``eps_n``), so the pair moves together: both h-scale under
   an explicit ``eps_n``, neither h-scales under an auto one.

The NTS lane has no ``-thickness`` at all (``kn`` is force per unit
length of gap, never a pressure) and the fork's parser refuses the
flag there; a 3D mortar deck's thickness lives in its elements and
the fork FATALs on ``-thickness`` at ``handle()``. Both are refused
here instead — the second at resolve time, where the model dimension
is known.

outward Optional single outward direction. None (default) → emit no -outward; the fork derives a correct PER-FACET normal from connectivity (the right choice for separated bodies and for curved / closed / solid-part masters). Pass an explicit (ox, oy, oz) ONLY for an initially-COINCIDENT (zero-gap) contact, where the fork's per-pair sign reference (slave − segment-centroid) is in-plane and ambiguous — there the explicit direction pins the sign (matches the fork's "use -outward for just-penetrated starts"). A single global outward is wrong on a non-flat master (it skips perpendicular facets and inverts opposed ones), so only set it for an effectively flat interface.

**2D (``ndm=2``) models** take a 2-vector ``(ox, oy)`` — it is
z-padded to ``(ox, oy, 0.0)`` on the record and the emitter drops the
third component back off, because the fork REJECTS the 3-component
form on a 2D surface. And 2D is where an explicit direction is
usually needed at all: the fork's 2D lanes orient from an
interface-level centroid vote that is genuinely ambiguous on a FLUSH
interface (the masonry joint, the footing on soil) and aborts there.

``outward="winding"`` (2D NTS only) declares the side through the
master chain's own winding instead of a vector — the fork's
``-outward winding``. Exact per segment, so it orients curved and
closed masters that no single direction can, and no direction is
auto-derived by apeGmsh. It needs a fork build carrying the mode;
older builds refuse the keyword at parse.

master_entities, slave_entities Restrict each side to specific Gmsh entities (default = whole label). soft Explicit-only Courant-stable SOFT penalty (-soft, ADR 0073). True ⇒ the fork default SOFSCL (0.10); a float ⇒ an explicit SOFSCL. Sizes the contact stiffness from the nodal mass + timestep under explicit dynamics so contact never throttles dt_cr (impact / pounding / recontact runs at the structural dt); inert under implicit (which uses the base penalty). NTS = SOFT=1, mortar = SOFT=2. Requires a base penalty (kn/eps_n); mutually exclusive with tie. A SOFSCL above the coupled-stability bound (mortar > 0.25, NTS > 1) warns. visc Viscous normal-stabilisation coefficient μ_c (-visc): a velocity-proportional normal damper (p_visc = μ_c·gap_rate) that bleeds chatter / snap-through energy in the pounding / rocking / uplift regime. 0 ⇒ off (inert in statics, v ≡ 0). Mutually exclusive with tie (a permanent bond has no contact-chatter to damp). consistent_tan Opt into the non-symmetric consistent friction tangent (-consistanttan) for true quadratic Newton on frictional contact. REQUIRES a non-symmetric solver (system FullGeneral / UmfPack / BandGeneral) — a symmetric solver silently drops the off-diagonal coupling and corrupts the solve. The default (symmetric) tangent is correct on any solver. geom_tan Opt the NTS segment lane into the consistent ∂n/∂u geometric normal tangent (-geomtan) for quadratic Newton on curved / large-sliding interfaces. Symmetric ⇒ solver-safe on any system. NTS-only (the fork refuses it on the mortar lane). cell Broad-phase cell-size scale (-cell): the spatial-hash bucket size as a fraction of the median segment diagonal (must be > 0; a huge value ⇒ 1 bucket ⇒ brute force). A performance-tuning knob; omitted ⇒ the fork default. Applies to both formulations. edge_edge Enable the perpendicular edge-edge contact fallback (-edgeedge, fork ADR-57 E2): the cos_t→0 pairs the face-mortar clip degenerates on get a dedicated segment-to-segment penalty. Mortar-only (the fork routes it off the mortar lane); off ⇒ byte-identical. The other edge_* knobs require edge_edge=True (the fork ignores -edge* without -edgeedge). edge_kn Edge-edge normal penalty (-edgeKn auto|<val>): "auto" ⇒ sized per master facet (like eps_n="auto"); a value ⇒ fixed; None (default) ⇒ the resolved mortar penalty. edge_band Gap activation band d_band for the edge-edge fallback (-edgeBand); None ⇒ sized from the facet edge length at run time. edge_mu, edge_kt, edge_cohesion, edge_tau_max Edge-edge Coulomb/Tresca friction (-edgeMu/-edgeKt/ -edgeCohesion/-edgeTauMax) — the unified cone min(μN+c, τmax). All None ⇒ frictionless edge contact. edge_consistent_tan Opt the edge-edge friction into the non-symmetric consistent Csl tangent (-edgeConsistentTan). Like consistent_tan, REQUIRES a non-symmetric solver (system FullGeneral / UmfPack / BandGeneral) when edge friction is active — a symmetric solver silently drops the off-diagonal coupling and corrupts the solve. edge_soft Explicit-only Courant-stable SOFT penalty on the edge-edge fallback (-edgeSoft [SOFSCL], fork ADR-57 E5): True ⇒ the fork default SOFSCL (0.10), a float ⇒ an explicit SOFSCL. Under explicit dynamics the edge penalty becomes k_soft = SOFSCL·4·m_eff/dt²; inert under implicit. A SOFSCL > 1 warns (ω·dt = 2√SOFSCL > 2). edge_alm Opt the edge-edge fallback into the one-scalar commit-cycle augmented Lagrangian (-edgeAlm, fork ADR-57 E6); off ⇒ the E2 penalty path. Implicit-only. edge_aug_tol Edge-edge ALM augmentation tolerance metadata (-edgeAugTol); the held-load proc passes its own tol.

apeGmsh._kernel.defs.constraints.ContactPlaneDef dataclass

ContactPlaneDef(kind: str, master_label: str = '', slave_label: str = '', name: str | None = None, slave_entities: list[tuple[int, int]] | None = None, normal: tuple | None = None, point: tuple | None = None, kn: float | None = None, visc: float | None = None, soft: float | bool | None = None)

Bases: ConstraintDef

Rigid analytical-plane contact (fork contactPlane; ADR 0073).

A meshed slave surface contacts a fixed, infinite rigid plane defined by an outward normal and a point on it, with a normal penalty kn. The plane is frictionless and fixed (no master mesh) — use it for a rigid floor / wall / foundation where the counter-body needn't be meshed. The slave nodes are emitted as a contactSurface -slave set; the plane and penalty go on the contactPlane verb (resolved by the same LadrunoContact handler). Fork-only at run time.

Parameters

slave_label : str The meshed surface PG / part label whose nodes contact the plane. normal : (float, float, float) | (float, float) The plane's outward unit normal (toward the slave / open side). In a 2D model it is the 2-vector (nx, ny), Z-PADDED here to (nx, ny, 0.0) — see the note below. point : (float, float, float) | (float, float) Any point on the plane; (px, py) in a 2D model, z-padded likewise. kn : float Normal penalty stiffness (required; the fork reads it as a plain value — there is no "auto" sizing on contactPlane). visc : float, optional Viscous normal-stabilisation coefficient μ_c (-visc). soft : float | bool, optional Explicit-only Courant-stable SOFT penalty (-soft): True ⇒ the fork default SOFSCL 0.10, a float ⇒ an explicit SOFSCL. slave_entities : list of (dim, tag), optional Restrict the slave to specific Gmsh entities. name : str, optional Friendly name (round-trips into the emitted deck comment).

Tier 6 — Interface springs

Every tier above is a bond: the slave follows the master in tension as in compression, for as long as the analysis runs. g.constraints.interface(master, slave, ...) is the one that can let go. It emits one zeroLength per coincident node pair between a 2D continuum boundary and a node-for-node coincident wire, with a unilateral normal law (compression only, separation free) and a strength-capped tangential law (elastic until the bond shear tau_b is reached, then slip). That is the soil- or rock-to-structure bond. A tunnel liner in converging ground takes load until the bond slips and no more; with a bilateral tie the ground keeps converging and the liner's demand grows with it, without a ceiling.

The two laws are declared, not built. NormalLaw and TangentialLaw are flat-scalar dataclasses carrying stiffness per unit area — apeGmsh translates each into a typed uniaxial material at emit, scaled by that pair's own tributary area. The Cerro Lindo shape, a steel arch against a tunnel face, is an ent normal (no tension whatsoever) with an epp tangential capped at the bond strength:

from apeGmsh import NormalLaw, TangentialLaw

g.constraints.interface(
    "face", "wire",                                    # rock rim, liner rim
    normal=NormalLaw(kind="ent", k_per_area=1.0e9),    # [F/L³]
    tangential=TangentialLaw(kind="epp", k_per_area=1.0e8, tau_b=2.5e5),
    thickness=0.5,                                     # out-of-plane, required
    name="RockLinerInterface",
)

You never compute the tributary areas that scaling needs. The resolver accumulates 0.5 × edge_length along the master polyline, multiplies by thickness, and asserts the total closes on the master face's length × thickness before a line is emitted; a pair with a zero share is an error, not a quiet no-op. thickness itself is required and has no default, because a guessed out-of-plane dimension would scale every force in the interface and say nothing.

Local-x of each pair is the outward normal of the master face, derived per pair from the master's own boundary edges, so a curved master's frame swings with the arc instead of collapsing onto one average direction. ZeroLength deformation is x̂·(u_j − u_i) with the master always as i, so separation reads as positive elongation and an ent normal carries exactly zero force there, while closure is compression. That is observable and worth observing once on a new model: pull the slave off the master and the pair's spring_force_0 must be zero. The mirror-image convention converges just as happily into a tension-only interface that is wrong everywhere, which is why the signs are applied by the emit-time translation and are never yours to pass.

The slave's ndf is a declaration, not an inference. Left at None (or 2) the slave is taken to match the 2D continuum and the spring joins the two real nodes. Pass slave_ndf=3 when the wire will become a beam: the engine refuses a zeroLength whose endpoints disagree on ndf, so each pair instead gets a 2-DOF phantom at the slave's coordinates, an equalDOF(retained=beam node, constrained=phantom, dofs=[1, 2]), and a spring running master → phantom — leaving the beam's rotation free, which is the hinge behaviour you want at a liner-to-ground contact. It has to be explicit because element classes are assigned at ops.element time, after resolution: when the resolver runs there is genuinely nothing in the model that says whether your wire becomes a truss or a beam. Declare it wrong and the bridge refuses at emit, naming the ndf it actually found.

Two-dimensional line masters only, for now. A 3D model raises NotImplementedError at the call, and an interior edge — material on both sides, so no outward direction exists — raises at resolve; both are loud, neither degrades into a guess. Partitioned (MPI) emit is supported: each pair's whole unit lands on the one rank owning the master node's backing continuum element, because the pair's nodes are co-located and node-tally ownership cannot decide between the ranks. And an interface given a name= can be claimed into a stage with s.interface(name="RockLinerInterface"), which installs it on ground the earlier stages already equilibrated — the liner-install pattern, and the reason the springs are born strain-free instead of pre-loaded by the convergence that happened before they existed.

apeGmsh._kernel.defs.constraints.InterfaceDef dataclass

InterfaceDef(kind: str, master_label: str, slave_label: str, name: str | None = None, master_entities: list[tuple[int, int]] | None = None, slave_entities: list[tuple[int, int]] | None = None, normal: object | None = None, tangential: object | None = None, thickness: float | None = None, tolerance: float = 1e-06, slave_ndf: int | None = None)

Bases: ConstraintDef

Oriented coincident-pair zeroLength interface (ADR 0093).

The geometry-side def captured by g.constraints.interface(...): one zeroLength per coincident (master, slave) node pair, with per-pair local axes taken from the master face geometry and per-pair tributary-scaled normal / tangential laws. The master is a 2D line boundary of a meshed continuum; the slave is a node-for-node coincident wire (3D surface masters raise — ADR 0093 D2).

Parameters

master_label, slave_label The master curve PG / part label (a free boundary of the continuum) and the coincident slave label. The two node sets must be disjoint and node-for-node coincident. normal, tangential The declarative per-area laws (:class:~apeGmsh._kernel.records._constraints.NormalLaw / :class:~apeGmsh._kernel.records._constraints.TangentialLaw, ADR 0093 D1). Stored on each record and translated to typed uniaxial materials, scaled by that pair's A_trib, only at emit — the verb carries no OpenSees types (INV-4). thickness Out-of-plane thickness, > 0 and required: A_trib = ell_trib * thickness (D3). The verb refuses to guess it (sign-off question 2, settled explicit-only). tolerance Coincidence radius for the node pairing. slave_ndf Which ndf the slave wire will be declared with — an explicit decision, never inferred. At resolve time apeGmsh cannot know whether the slave wire becomes an OpenSees truss (ndf 2) or a beam (ndf 3): element classes are assigned at ops.element declaration, i.e. after resolution. So:

* ``None`` (default) / ``2`` — the slave matches the 2D
  continuum's ndf, and the zeroLength connects the two real
  nodes directly. No phantom.
* ``3`` — a beam slave. ``ZeroLength::setDomain`` refuses
  ``dofNd1 != dofNd2``, so each pair gets a phantom bridge
  (ADR 0093 D4): a 2-dof phantom at the slave's coordinates,
  ``equalDOF(retained=beam node, constrained=phantom,
  dofs=[1,2])``, and the zeroLength running master continuum ->
  phantom. The beam's rotation DOF is never touched (the hinge
  semantics the campaign asks for).

Any other value raises. Validating this against the ndf
actually inferred from the emitted elements is ADR 0093 S5's
job (emit time is the first moment that ndf exists).

master_entities, slave_entities Restrict each side to specific Gmsh entities (default = the whole label). name Friendly name — carried onto every record, and the handle a future s.interface(name=...) stage claim will use (INV-6).

Records

Resolved records — what the FEM broker exposes after meshing.

apeGmsh._kernel.records._constraints

Stage 2 — Constraint Records (post-mesh, resolved).

These dataclasses carry the concrete mesh-level outputs of constraint resolution: node tags, shape-function weights, offset vectors, and phantom-node bookkeeping. Records are solver-agnostic — any adapter (OpenSees, Abaqus, Code_Aster, …) can consume them.

All records ultimately express the linear MPC equation::

u_slave = C · u_master

ConstraintRecord dataclass

ConstraintRecord(kind: str, name: str | None = None)

Base for all resolved constraint records.

Every record expresses (or can be expanded to) the general linear MPC equation: u_slave = C · u_master.

ADR 0038 §"Tag-reference rewrite checklist" — every concrete subclass below declares a tag_rewrite_spec class attribute (ClassVar) naming the tag-bearing + name-bearing fields the Phase 3B.2a compose rewriter must offset / namespace-prefix. The base class has no spec on its own — it is never instantiated bare.

NodePairRecord dataclass

NodePairRecord(kind: str, name: str | None = None, master_node: int = 0, slave_node: int = 0, dofs: list[int] = list(), offset: ndarray | None = None, penalty_stiffness: float | None = None, master_dofs: list[int] | None = None)

Bases: ConstraintRecord

One master node ↔ one slave node.

Covers: equal_dof, rigid_beam, rigid_rod, penalty.

Attributes

master_node : int Master node tag (from mesh). slave_node : int Slave node tag (from mesh). dofs : list[int] Constrained DOFs (1-based). offset : ndarray or None Rigid arm vector r = x_slave − x_master. Present for rigid link types; None for equal_dof. penalty_stiffness : float or None For penalty type only.

constraint_matrix
constraint_matrix(ndof: int = 6) -> ndarray

Build the constraint transformation matrix C such that u_slave[dofs] = C · u_master[all_dofs].

For equal_dof: C is a selection matrix (rows of identity). For rigid_beam: C includes the skew-symmetric offset matrix.

Parameters

ndof : int DOFs per node (default 6 for shell/beam).

Returns

ndarray of shape (len(dofs), ndof)

Source code in src/apeGmsh/_kernel/records/_constraints.py
def constraint_matrix(self, ndof: int = 6) -> ndarray:
    """
    Build the constraint transformation matrix C such that
    u_slave[dofs] = C · u_master[all_dofs].

    For equal_dof: C is a selection matrix (rows of identity).
    For rigid_beam: C includes the skew-symmetric offset matrix.

    Parameters
    ----------
    ndof : int
        DOFs per node (default 6 for shell/beam).

    Returns
    -------
    ndarray of shape (len(dofs), ndof)
    """
    n = len(self.dofs)
    C = np.zeros((n, ndof))

    if self.kind in (ConstraintKind.EQUAL_DOF, ConstraintKind.PENALTY):
        # u_slave_i = u_master_i
        for row, dof in enumerate(self.dofs):
            C[row, dof - 1] = 1.0

    elif self.kind in (ConstraintKind.RIGID_BEAM, ConstraintKind.RIGID_ROD):
        # u_s = u_m + θ_m × r
        #
        # In matrix form for translations (DOFs 1-3):
        #   [u_s]   [I  | -[r×]] [u_m ]
        #   [   ] = [   |      ] [    ]
        #   [θ_s]   [0  |   I  ] [θ_m ]  (beam only)
        #
        # Skew-symmetric matrix of r:
        #   [r×] = [ 0   -rz   ry]
        #          [ rz   0   -rx]
        #          [-ry   rx   0 ]
        r = self.offset if self.offset is not None else np.zeros(3)
        rx, ry, rz = r

        skew = np.array([
            [ 0,  -rz,  ry],
            [ rz,  0,  -rx],
            [-ry,  rx,   0],
        ])

        for row, dof in enumerate(self.dofs):
            idx = dof - 1
            if idx < 3:
                # Translation: u_s_i = u_m_i + (skew · θ_m)_i
                C[row, idx] = 1.0                   # I term
                C[row, 3:6] = -skew[idx, :]         # -[r×] · θ_m
            elif idx < 6 and self.kind == ConstraintKind.RIGID_BEAM:
                # Rotation (beam only): θ_s = θ_m
                C[row, idx] = 1.0

    return C

NodeGroupRecord dataclass

NodeGroupRecord(kind: str, name: str | None = None, master_node: int = 0, slave_nodes: list[int] = list(), dofs: list[int] = list(), offsets: ndarray | None = None, plane_normal: ndarray | None = None, control: 'CouplingControl | None' = None, as_element: bool = False, mass: float | None = None, omega: tuple[float, float, float] | None = None)

Bases: ConstraintRecord

One master node ↔ multiple slave nodes.

Covers: rigid_diaphragm, rigid_body, kinematic_coupling.

Attributes

master_node : int slave_nodes : list[int] dofs : list[int] DOFs constrained for all slaves. offsets : ndarray Array of shape (n_slaves, 3) — offset vector for each slave. plane_normal : ndarray or None For rigid_diaphragm: normal to the constraint plane.

expand_to_pairs
expand_to_pairs() -> list[NodePairRecord]

Expand this group constraint into individual :class:NodePairRecord objects — one per slave node.

This is the most common consumption path: most solvers implement group constraints as loops of pair constraints (e.g., OpenSees rigidDiaphragm or repeated equalDOF).

Source code in src/apeGmsh/_kernel/records/_constraints.py
def expand_to_pairs(self) -> list[NodePairRecord]:
    """
    Expand this group constraint into individual
    :class:`NodePairRecord` objects — one per slave node.

    This is the most common consumption path: most solvers
    implement group constraints as loops of pair constraints
    (e.g., OpenSees ``rigidDiaphragm`` or repeated ``equalDOF``).
    """
    pairs = []
    for i, sn in enumerate(self.slave_nodes):
        offset = self.offsets[i] if self.offsets is not None else None
        if self.kind == ConstraintKind.RIGID_DIAPHRAGM:
            pair_kind = ConstraintKind.RIGID_BEAM
        elif self.kind == ConstraintKind.RIGID_BODY:
            pair_kind = ConstraintKind.RIGID_BEAM
        else:
            pair_kind = ConstraintKind.KINEMATIC_COUPLING

        pairs.append(NodePairRecord(
            kind=pair_kind,
            name=self.name,
            master_node=self.master_node,
            slave_node=sn,
            dofs=list(self.dofs),
            offset=offset,
        ))
    return pairs

InterpolationRecord dataclass

InterpolationRecord(kind: str, name: str | None = None, slave_node: int = 0, master_nodes: list[int] = list(), weights: ndarray | None = None, dofs: list[int] = list(), projected_point: ndarray | None = None, parametric_coords: ndarray | None = None, excess: float | None = None, stiffness: 'float | str' = 1e+18, stiffness_p: float | None = None, rotational: bool = False, pressure: bool = False, enforce: str = 'penalty', control: 'CouplingControl | None' = None)

Bases: ConstraintRecord

One slave node interpolated from a master element face.

Covers: tie, distributing, embedded.

The constraint equation is::

u_slave = Σ  w_i · u_master_i

where w_i are the interpolation weights (shape function values at the projected parametric coordinates on the master face).

Attributes

slave_node : int master_nodes : list[int] Nodes of the master element face (ordered). weights : ndarray Shape function values N_i(ξ,η) — same length as master_nodes. Sum to 1.0 for partition of unity. dofs : list[int] projected_point : ndarray or None Physical coordinates of the projection onto the master face (useful for verification / visualisation). parametric_coords : ndarray or None (ξ, η) on the master face. excess : float or None Barycentric excess of the slave node relative to the host element — 0.0 when the slave is strictly inside, positive when outside (extrapolation; the magnitude is how far outside in barycentric coordinates). Populated by resolve_embedded; None for records produced by other code paths. Enables downstream tolerance gating and post-resolution introspection.

constraint_matrix
constraint_matrix(ndof: int = 3) -> ndarray

Build the constraint matrix C of shape (ndof, n_master_nodes * ndof).

u_slave[i] = Σ_j w_j · u_master_j[i] for each DOF i

Source code in src/apeGmsh/_kernel/records/_constraints.py
def constraint_matrix(self, ndof: int = 3) -> ndarray:
    """
    Build the constraint matrix C of shape
    (ndof, n_master_nodes * ndof).

    u_slave[i] = Σ_j  w_j · u_master_j[i]   for each DOF i
    """
    n_master = len(self.master_nodes)
    n_dof = len(self.dofs)
    C = np.zeros((n_dof, n_master * n_dof))
    w = self.weights if self.weights is not None else np.ones(n_master) / n_master
    for row, dof in enumerate(self.dofs):
        for j in range(n_master):
            C[row, j * n_dof + row] = w[j]
    return C

ReinforceTieRecord dataclass

ReinforceTieRecord(kind: str, name: str | None = None, rebar_node: int = 0, host_nodes: list[int] = list(), weights: ndarray | None = None, direction: ndarray | None = None, corot: bool = False, shape_b: ndarray | None = None, bond_scale: float | None = None, bond: str | None = None, perfect: float | None = None, kt: float | None = None, kt_alpha: float | None = None, enforce: str = 'penalty', bipenalty: bool = False, dtcr: float | None = None, excess: float | None = None, in_bounds: bool = True)

Bases: ConstraintRecord

One resolved LadrunoEmbeddedRebar tie (Ladruno fork).

Carries the inverse-map result for a single rebar node plus the pass-through tie parameters, so the bridge build step can emit element LadrunoEmbeddedRebar (via the R0 embedded_rebar_args builder, resolving bond by name → tag). Solver-agnostic — no OpenSees imports here.

Attributes

rebar_node The rebar (slave) mesh node tag. host_nodes The host element's node tags the weights couple to (8 for a hex8 host, 4 for tet4 — the -shape host node list). weights Shape-function weights Nᵢ(ξ) at the rebar point (sum to 1), parallel to host_nodes. direction Unit bar axis at this node (from the rebar segment) — the frozen reference axis (-dir). corot, shape_b Co-rotated bar-axis option (-corot, ADR 20 §10.5). corot=Trueshape_b carries the point-B shape weights NshapeB (parallel to host_nodes, the -shapeB host-element-tag-free path); the fork forms d̂_cur = normalize(Σ NshapeB·x − Σ Nshape·x) from current host node positions. corot=Falseshape_b is None (frozen -dir). bond_scale π·d_b·L_trib (None for the perfect-bond law). bond LadrunoBondSlip material name for the axial law, or None when perfect is set. perfect Perfect-bond axial penalty kAxial (or None for bond). kt, kt_alpha, enforce Transverse-penalty + enforcement pass-throughs. excess, in_bounds Inverse-map diagnostics (excess > tol with snap ⇒ extrapolated).

EmbedTieRecord dataclass

EmbedTieRecord(kind: str, name: str | None = None, node: int = 0, host_nodes: list[int] = list(), weights: ndarray | None = None, k: float | None = None, k_alpha: float | None = None, enforce: str = 'penalty', bipenalty: bool = False, dtcr: float | None = None, staged: bool = True, excess: float | None = None, in_bounds: bool = True)

Bases: ConstraintRecord

One resolved LadrunoEmbeddedNode tie (Ladruno fork).

The isotropic sibling of :class:ReinforceTieRecord: it ties a single constrained node into the host element it falls inside (via the same guarded inverse map), with no bar axis, bond law, or tributary length. The bridge build step emits element LadrunoEmbeddedNode via the embedded_node_args builder. Solver-agnostic — no OpenSees imports.

Attributes

node The constrained (slave) mesh node tag. host_nodes The host element's node tags the weights couple to (8 for hex8, 4 for tet4 — the -shape host node list). weights Shape-function weights Nᵢ(ξ) at the node (sum to 1), parallel to host_nodes. k, k_alpha, enforce Isotropic penalty + enforcement pass-throughs (-k / -kAlpha). bipenalty, dtcr Explicit bipenalty critical-time-step control. staged True (default) → g0 stress-free birth (no -absolute); False → emit -absolute (legacy absolute tie). excess, in_bounds Inverse-map diagnostics (excess > tol with snap ⇒ extrapolated).

ContactRecord dataclass

ContactRecord(kind: str, name: str | None = None, formulation: str = 'nts', master_faces: ndarray | None = None, master_nps: int = 0, slave_nodes: list[int] | None = None, slave_faces: ndarray | None = None, slave_nps: int = 0, outward: tuple | None = None, kn: float | str | None = None, kt: float | None = None, mu: float | None = None, eps_n: float | str | None = None, eps_t: float | str | None = None, cohesion: float | None = None, tau_max: float | None = None, aug_tol: float | None = None, max_aug: int | None = None, ngp: int | None = None, tie: bool = False, thickness: float | None = None, soft: float | bool | None = None, visc: float | None = None, consistent_tan: bool = False, geom_tan: bool = False, cell: float | None = None, edge_edge: bool = False, edge_kn: float | str | None = None, edge_band: float | None = None, edge_mu: float | None = None, edge_kt: float | None = None, edge_cohesion: float | None = None, edge_tau_max: float | None = None, edge_consistent_tan: bool = False, edge_soft: float | bool | None = None, edge_alm: bool = False, edge_aug_tol: float | None = None)

Bases: ConstraintRecord

One resolved fork contact interaction (contactSurface + contact).

A face-to-face contact between two meshed surfaces, emitted as the fork's contactSurface tag (-master|-slave|-slave-segments) … pair plus the contact tag master slave … verb (and the LadrunoContact handler). The master is always a faceted surface; the slave is a node set (NTS) or a faceted surface (mortar). Solver-agnostic — no OpenSees imports here.

Partition-aware since ADR 0092 S4 (previously serial-only). Under partitioned (MPI) emit the whole interaction lands inside exactly ONE rank's block — the owner, picked master-side (INV-1) — with every non-native interface node ghost-declared there as node + replayed fix (INV-2). The record still carries no per-rank tag-rewrite fields: nothing is ever split across ranks, so there is nothing to rewrite. soft= / edge_soft= stay refused under partitioning (INV-3), and staged partitioned contact is refused too (see BuiltModel._plan_partitioned_contacts).

Attributes

formulation "nts" (node-to-segment) or "mortar" (segment-to-segment ALM). master_faces, master_nps The master surface's flat face connectivity (n_faces, nps) and the per-facet node count nps (2=2D line segment, 3=tri, 4=quad). nps is the sole discriminator of the interaction's dimension (see :attr:ndm), and at nps == 2 the rows must be one run CHAINED head-to-tail — enforced in :meth:__post_init__. slave_nodes NTS slave node tags (None for mortar). slave_faces, slave_nps Mortar slave faceted connectivity + stride (None/0 for NTS). outward Unit outward normal (ox, oy, oz) toward the slave half-space, or None (let the fork auto-derive), or the string "winding" — the fork's declared-winding sentinel, which orients from the master chain's own head-to-tail winding instead of any direction (2D NTS only). A 2D direction is stored Z-PADDED here, (ox, oy, 0.0): the third component is genuinely zero, so every consumer (persistence, compose's rotation, emit) keeps one shape and the emitter drops the oz back off for the fork's 2-component form. kn, kt, mu NTS penalty (normal/tangential) + friction. eps_n, eps_t, cohesion, tau_max, aug_tol, max_aug, ngp, tie Mortar ALM penalty / friction-cone / augmentation controls + mesh-tie. thickness 2D mortar plane-model out-of-plane thickness h (fork -thickness); None ⇒ the fork default 1.0. Emitted verbatim — apeGmsh never scales anything with it. The fork applies h ONCE at its 2D injection site to eps_n / eps_t / visc / cohesion / tau_max and the tie stiffness, and deliberately does NOT scale an eps_n="auto" (that value already absorbs the element's own thickness via getInitialStiff(), so re-scaling it is an h² error); an "auto"/defaulted eps_t inherits that provenance and moves with it. Mortar-only and 2D-only, refused by name on every path in — ContactDef at declaration, resolve_contacts for a 3D model, contact_args at emit for a record that reached it without passing a def. soft, visc, consistent_tan, geom_tan Extension modifiers (ADR 0073): soft = explicit Courant-stable SOFT penalty (True ⇒ fork default SOFSCL 0.10, or a float SOFSCL); visc = viscous normal-stabilisation coefficient μ_c; consistent_tan = non-symmetric consistent friction tangent (needs an unsymmetric solver); geom_tan = NTS ∂n/∂u geometric normal tangent (NTS-only). cell Broad-phase cell-size scale (-cell): the spatial-hash bucket size as a fraction of the median segment diagonal (a positive performance-tuning knob; omitted ⇒ the fork default). Applies to both formulations. edge_edge, edge_kn, edge_band, edge_mu, edge_kt, edge_cohesion, edge_tau_max, edge_consistent_tan, edge_soft, edge_alm, edge_aug_tol Edge-edge fallback (ADR-57 E2–E7): edge_edge enables the perpendicular segment-to-segment fallback (mortar-only); edge_kn (float | "auto") its penalty; edge_band the gap activation band; edge_mu/edge_kt/edge_cohesion/edge_tau_max its Coulomb/Tresca friction; edge_consistent_tan the non-symmetric Csl tangent; edge_soft (True ⇒ fork default SOFSCL, or a float) the explicit Courant-stable SOFT penalty; edge_alm the commit-cycle ALM; edge_aug_tol the ALM tolerance.

ndm property
ndm: int

The interaction's spatial dimension, DERIVED from master_nps.

master_nps == 2 (a line segment) is 2D; 3/4 (tri/quad facets) is 3D. master_nps is the sole source of truth — this is a read-only convenience, deliberately not a stored field, because a second copy of the dimension is a second thing that can disagree with the connectivity it describes.

ContactPlaneRecord dataclass

ContactPlaneRecord(kind: str, name: str | None = None, slave_nodes: list[int] | None = None, normal: tuple | None = None, point: tuple | None = None, kn: float | None = None, visc: float | None = None, soft: float | bool | None = None)

Bases: ConstraintRecord

One resolved rigid analytical-plane contact (fork contactPlane).

A meshed slave surface contacts a fixed infinite rigid plane (normal + point) with normal penalty kn — frictionless, no master mesh. Emitted as a contactSurface -slave <nodes> set + the contactPlane verb (+ the LadrunoContact handler). Solver-agnostic — no OpenSees imports here. Partition-aware since ADR 0092 S4 (previously serial-only): the interaction emits inside exactly one owner rank's block — the plane has no master surface, so ownership is tallied from the SLAVE nodes — with non-native slave nodes ghost-declared there (INV-1/INV-2). soft= stays refused under partitioning (INV-3).

Parameters

slave_nodes The slave surface node tags (the contactSurface -slave set). normal, point The plane's outward normal + a point on it — always 3-vectors here. A 2D plane is stored Z-PADDED, (nx, ny, 0.0) / (px, py, 0.0) (the ContactRecord.outward decision): the third component is genuinely zero, so persistence, compose's rotation and emit all keep one shape, and the emitted line stays the zero-padded 9-argument form the fork accepts on a 2D and a 3D slave surface alike. kn Normal penalty stiffness. visc Viscous normal-stabilisation coefficient μ_c (-visc); None ⇒ off. soft Explicit SOFT penalty (-soft): True ⇒ fork default SOFSCL, a float ⇒ an explicit SOFSCL, None ⇒ off.

SurfaceCouplingRecord dataclass

SurfaceCouplingRecord(kind: str, name: str | None = None, slave_records: list[InterpolationRecord] = list(), mortar_operator: ndarray | None = None, master_nodes: list[int] = list(), slave_nodes: list[int] = list(), dofs: list[int] = list())

Bases: ConstraintRecord

Surface-to-surface coupling operator.

Covers: tied_contact, mortar.

The coupling is stored as a sparse set of interpolation records (one per slave node for tied_contact), or as the full mortar operator matrix B.

Attributes

slave_records : list[InterpolationRecord] Per-slave-node interpolation data (for tied_contact). mortar_operator : ndarray or None Dense coupling matrix B (for mortar method). Shape: (n_slave_dofs, n_master_dofs). master_nodes : list[int] All master nodes involved. slave_nodes : list[int] All slave nodes involved. dofs : list[int]

NodeToSurfaceRecord dataclass

NodeToSurfaceRecord(kind: str, name: str | None = None, master_node: int = 0, slave_nodes: list[int] = list(), phantom_nodes: list[int] = list(), phantom_coords: ndarray | None = None, rigid_link_records: list[NodePairRecord] = list(), equal_dof_records: list[NodePairRecord] = list(), dofs: list[int] = (lambda: [1, 2, 3])())

Bases: ConstraintRecord

Compound record for 6-DOF node to 3-DOF surface coupling via phantom nodes.

This record encapsulates the three-step coupling:

  1. Phantom nodes duplicated from the original slave positions.
  2. Rigid links from the 6-DOF master to each phantom node.
  3. EqualDOF from each phantom node to the original slave (translations only).

Solvers consume this by: - Creating the phantom nodes (6-DOF, same coords as slaves). - Emitting rigid_beam constraints master -> phantom. - Emitting equal_dof constraints phantom -> slave for DOFs [1,2,3].

Attributes

master_node : int The 6-DOF master node tag. slave_nodes : list[int] Original 3-DOF slave node tags (from the surface mesh). phantom_nodes : list[int] Generated 6-DOF phantom node tags (one per slave, same coordinates). Tag generation is handled by the resolver using an offset above the maximum existing node tag. phantom_coords : ndarray Coordinates of phantom nodes, shape (n_slaves, 3). Identical to the slave coordinates. rigid_link_records : list[NodePairRecord] Master -> phantom rigid beam records (with offset vectors). equal_dof_records : list[NodePairRecord] Phantom -> slave equalDOF records (translations only). dofs : list[int] Translational DOFs coupled to the surface (default [1,2,3]).

expand
expand() -> list[NodePairRecord]

Flatten into individual :class:NodePairRecord objects.

Returns the rigid link records followed by the equalDOF records — the natural emission order for solvers.

Source code in src/apeGmsh/_kernel/records/_constraints.py
def expand(self) -> list[NodePairRecord]:
    """
    Flatten into individual :class:`NodePairRecord` objects.

    Returns the rigid link records followed by the equalDOF
    records — the natural emission order for solvers.
    """
    return list(self.rigid_link_records) + list(self.equal_dof_records)

NormalLaw dataclass

NormalLaw(kind: str, k_per_area: float, tau_b_n: float | None = None, gap: float | None = None)

Declarative per-area normal-direction law (ADR 0093 D1).

A flat-scalar description of the interface's normal constitutive response — stored on :class:InterfaceRecord (h5-serializable), translated to a typed uniaxial material only at emit time in build.py, scaled per pair by A_trib. Kernel data — imports nothing from apeGmsh.opensees (INV-4). The sign convention is owned by the emit-time translation, never the caller (INV-1): the fields here are positive-magnitude physical quantities.

Attributes

kind "ent" — unilateral, compression-only (ENT(E = k_per_area * A_trib)). "epp_gap" — elastic-perfectly-plastic with a gap (ElasticPPGap(E = k_per_area * A_trib, Fy = -tau_b_n * A_trib, gap)); requires tau_b_n and gap. "elastic" — bilateral elastic (Elastic(E = k_per_area * A_trib)); the acceptance battery's bonded-limit law (ADR 0093 "Alternatives rejected"). k_per_area Stiffness per unit tributary area ([F/L**3]); required for every kind. tau_b_n Normal bond strength, a positive magnitude (epp_gap only); None for ent / elastic. gap Initial gap, <= 0 (epp_gap only); None for ent / elastic.

TangentialLaw dataclass

TangentialLaw(kind: str, k_per_area: float, tau_b: float | None = None)

Declarative per-area tangential-direction law (ADR 0093 D1).

The tangential sibling of :class:NormalLaw — see its docstring for the storage/translation/layering contract.

Attributes

kind "epp" — elastic-perfectly-plastic slip cap (ElasticPP(E = k_per_area * A_trib, epsyP = tau_b / k_per_area)); requires tau_b. A_trib cancels in the strain — the physical yield force tau_b * A_trib is the emergent product E * epsyP. "elastic" — bilateral elastic (Elastic(E = k_per_area * A_trib)); the acceptance battery's bonded-limit law. k_per_area Stiffness per unit tributary area ([F/L**3]); required for every kind. tau_b Tangential bond strength, a positive magnitude (epp only); None for elastic.

InterfaceRecord dataclass

InterfaceRecord(kind: str, name: str | None = None, master_node: int = 0, slave_node: int = 0, backing_element: int = 0, orient: tuple | None = None, a_trib: float = 0.0, normal_law: 'NormalLaw | None' = None, tangential_law: 'TangentialLaw | None' = None, phantom_node: int | None = None, phantom_coords: ndarray | None = None, phantom_ndf: int | None = None, equal_dof_records: list[NodePairRecord] = list())

Bases: ConstraintRecord

One resolved g.constraints.interface() coincident-pair spring (ADR 0093).

A single zeroLength between a master continuum node and a slave node (real, or a phantom bridging a mixed-ndf pair per D4), with per-pair geometry-derived orientation and tributary-scaled normal / tangential laws. An additive side-list record — like :class:ContactRecord, it emits elements/materials via its own emit_interfaces() pass (D5), bypassing the _DISPATCH MP-constraint pipeline. Solver-agnostic — no OpenSees imports here.

Attributes

master_node The real continuum node tag (INV-1: always iNode; local-x is the outward normal of the master face). slave_node The real slave node tag (INV-1: always jNode). For a mixed-ndf pair the zeroLength's actual second endpoint is phantom_node, not this field — slave_node is still carried for the nested equal_dof_records and for provenance. backing_element The tag of the highest-dimension (domain) continuum element backing master_node (INV-5) — the partition-ownership anchor. An element tag, offset by g.compose like the node tags — see the note above :attr:tag_rewrite_spec for why one offset covers both. orient The zeroLength -orient 6-tuple (x1, x2, x3, yp1, yp2, yp3) — local-x is the master face's outward normal (D2). A direction, not a tag; g.compose rotates it (never translates), the :class:ContactRecord-style _transform_contact_geometry extension (INV-2). a_trib Tributary area for this pair, ell_trib * thickness (D3). normal_law, tangential_law The declarative per-area laws (D1) — translated to typed materials, scaled by a_trib, only at emit. phantom_node The minted phantom node's tag for a mixed-ndf pair (D4); None for an equal-ndf pair (direct connection, no phantom). phantom_coords The phantom node's coordinates (identical to the pair's coincident coordinates); None when phantom_node is None. phantom_ndf The phantom node's ndf — the lower side's ndf (D4); None when phantom_node is None. Carried explicitly because the shared _emit_phantom_nodes helper hardcodes ndf=6, which does not apply here. equal_dof_records The nested equalDOF(retained=slave_node, constrained=phantom_node, dofs=[1,2]) record for a mixed-ndf pair (D4), as a one-element list of :class:NodePairRecord; empty for an equal-ndf pair.

Resolver

apeGmsh._kernel.resolvers._constraint_resolver._resolver.ConstraintResolver

ConstraintResolver(node_tags: ndarray, node_coords: ndarray, elem_tags: ndarray | None = None, connectivity: ndarray | None = None)

Converts constraint definitions into resolved records.

The resolver works with raw numpy arrays of node coordinates and connectivity — it does NOT depend on Gmsh or any solver. This makes it fully portable.

Parameters

node_tags : ndarray, shape (n_nodes,) Node tags (IDs) from the mesh. node_coords : ndarray, shape (n_nodes, 3) Nodal coordinates. elem_tags : ndarray, shape (n_elems,) Element tags. connectivity : ndarray, shape (n_elems, n_nodes_per_elem) Element connectivity (node tags). face_connectivity : list of ndarray, optional Element face connectivity for surface elements. If None, the resolver extracts faces from the volume connectivity.

Source code in src/apeGmsh/_kernel/resolvers/_constraint_resolver/_resolver.py
def __init__(
    self,
    node_tags: ndarray,
    node_coords: ndarray,
    elem_tags: ndarray | None = None,
    connectivity: ndarray | None = None,
) -> None:
    self.node_tags = np.asarray(node_tags, dtype=int)
    self.node_coords = np.asarray(node_coords, dtype=float)

    # Tag -> index mapping
    self._tag_to_idx: dict[int, int] = {
        int(t): i for i, t in enumerate(self.node_tags)
    }

    self.elem_tags = (
        np.asarray(elem_tags, dtype=int) if elem_tags is not None
        else None
    )
    self.connectivity = (
        np.asarray(connectivity, dtype=int) if connectivity is not None
        else None
    )

    # Running high-water mark for phantom node tag generation.
    # Each resolve_node_to_surface() call advances this so that
    # multiple calls never produce overlapping phantom tag ranges.
    self._next_phantom_tag: int = int(self.node_tags.max()) + 1

    # KD-tree for spatial queries (built lazily)
    self._tree = None

tree property

tree

Lazily build a KD-tree for nearest-neighbour queries.

resolve_equal_dof

resolve_equal_dof(defn: EqualDOFDef, master_nodes: set[int], slave_nodes: set[int]) -> list[NodePairRecord]

Resolve an EqualDOF definition into node pair records.

Parameters

defn : EqualDOFDef master_nodes : set[int] Node tags belonging to the master instance. slave_nodes : set[int] Node tags belonging to the slave instance.

Source code in src/apeGmsh/_kernel/resolvers/_constraint_resolver/_resolver.py
def resolve_equal_dof(
    self,
    defn: EqualDOFDef,
    master_nodes: set[int],
    slave_nodes: set[int],
) -> list[NodePairRecord]:
    """
    Resolve an EqualDOF definition into node pair records.

    Parameters
    ----------
    defn : EqualDOFDef
    master_nodes : set[int]
        Node tags belonging to the master instance.
    slave_nodes : set[int]
        Node tags belonging to the slave instance.
    """
    pairs = self._match_node_pairs(
        master_nodes, slave_nodes, defn.tolerance,
    )
    dofs = defn.dofs or [1, 2, 3, 4, 5, 6]
    return [
        NodePairRecord(
            kind=ConstraintKind.EQUAL_DOF,
            name=defn.name,
            master_node=mt,
            slave_node=st,
            dofs=list(dofs),
        )
        for mt, st in pairs
    ]

resolve_equal_dof_mixed

resolve_equal_dof_mixed(defn: 'EqualDOFMixedDef', master_nodes: set[int], slave_nodes: set[int]) -> list[NodePairRecord]

Resolve an EqualDOF_Mixed definition into node pair records.

Identical co-location matching to :meth:resolve_equal_dof, but each record carries BOTH the retained DOFs (:attr:NodePairRecord.master_dofs) and the constrained DOFs (:attr:NodePairRecord.dofs), paired by index, from defn.dof_pairs — emitted downstream as equalDOF_Mixed (RDOF_i / CDOF_i couples).

Source code in src/apeGmsh/_kernel/resolvers/_constraint_resolver/_resolver.py
def resolve_equal_dof_mixed(
    self,
    defn: "EqualDOFMixedDef",
    master_nodes: set[int],
    slave_nodes: set[int],
) -> list[NodePairRecord]:
    """
    Resolve an EqualDOF_Mixed definition into node pair records.

    Identical co-location matching to :meth:`resolve_equal_dof`, but
    each record carries BOTH the retained DOFs
    (:attr:`NodePairRecord.master_dofs`) and the constrained DOFs
    (:attr:`NodePairRecord.dofs`), paired by index, from
    ``defn.dof_pairs`` — emitted downstream as
    ``equalDOF_Mixed`` (``RDOF_i`` / ``CDOF_i`` couples).
    """
    pairs = self._match_node_pairs(
        master_nodes, slave_nodes, defn.tolerance,
    )
    rdofs = [int(r) for r, _ in defn.dof_pairs]
    cdofs = [int(c) for _, c in defn.dof_pairs]
    return [
        NodePairRecord(
            kind=ConstraintKind.EQUAL_DOF_MIXED,
            name=defn.name,
            master_node=mt,
            slave_node=st,
            dofs=list(cdofs),
            master_dofs=list(rdofs),
        )
        for mt, st in pairs
    ]
resolve_rigid_link(defn: RigidLinkDef, master_nodes: set[int], slave_nodes: set[int]) -> list[NodePairRecord]

Resolve a rigid link definition.

If master_point is specified, find the closest master node. Then link all slave nodes to that master via rigid offset.

Source code in src/apeGmsh/_kernel/resolvers/_constraint_resolver/_resolver.py
def resolve_rigid_link(
    self,
    defn: RigidLinkDef,
    master_nodes: set[int],
    slave_nodes: set[int],
) -> list[NodePairRecord]:
    """
    Resolve a rigid link definition.

    If ``master_point`` is specified, find the closest master node.
    Then link all slave nodes to that master via rigid offset.
    """
    # Find master node
    if defn.master_point is not None:
        master_tag, _ = self._closest_node_in_set(defn.master_point, master_nodes)
    else:
        if master_nodes:
            coords = np.array([self._coords_of(t) for t in master_nodes])
            centroid = coords.mean(axis=0)
            master_tag, _ = self._closest_node_in_set(centroid, master_nodes)
        else:
            centroid = self.node_coords.mean(axis=0)
            master_tag, _ = self._closest_node(centroid)

    master_xyz = self._coords_of(master_tag)
    kind = f"rigid_{defn.link_type}"

    if kind == ConstraintKind.RIGID_BEAM:
        dofs = [1, 2, 3, 4, 5, 6]
    else:
        dofs = [1, 2, 3]

    records = []
    for st in sorted(slave_nodes):
        if st == master_tag:
            continue
        slave_xyz = self._coords_of(st)
        offset = slave_xyz - master_xyz
        records.append(NodePairRecord(
            kind=kind,
            name=defn.name,
            master_node=master_tag,
            slave_node=st,
            dofs=list(dofs),
            offset=offset,
        ))
    return records

resolve_penalty

resolve_penalty(defn: PenaltyDef, master_nodes: set[int], slave_nodes: set[int]) -> list[NodePairRecord]

Resolve a penalty definition into node pair records.

Source code in src/apeGmsh/_kernel/resolvers/_constraint_resolver/_resolver.py
def resolve_penalty(
    self,
    defn: PenaltyDef,
    master_nodes: set[int],
    slave_nodes: set[int],
) -> list[NodePairRecord]:
    """Resolve a penalty definition into node pair records."""
    pairs = self._match_node_pairs(
        master_nodes, slave_nodes, defn.tolerance,
    )
    dofs = defn.dofs or [1, 2, 3, 4, 5, 6]
    return [
        NodePairRecord(
            kind=ConstraintKind.PENALTY,
            name=defn.name,
            master_node=mt,
            slave_node=st,
            dofs=list(dofs),
            penalty_stiffness=defn.stiffness,
        )
        for mt, st in pairs
    ]

resolve_rigid_diaphragm

resolve_rigid_diaphragm(defn: RigidDiaphragmDef, all_nodes: set[int]) -> NodeGroupRecord

Resolve a rigid diaphragm.

Collects all nodes within plane_tolerance of the diaphragm plane, then the closest to master_point becomes master.

Source code in src/apeGmsh/_kernel/resolvers/_constraint_resolver/_resolver.py
def resolve_rigid_diaphragm(
    self,
    defn: RigidDiaphragmDef,
    all_nodes: set[int],
) -> NodeGroupRecord:
    """
    Resolve a rigid diaphragm.

    Collects all nodes within ``plane_tolerance`` of the diaphragm
    plane, then the closest to ``master_point`` becomes master.
    """
    normal = np.asarray(defn.plane_normal, dtype=float)
    normal = normal / np.linalg.norm(normal)
    mp = np.asarray(defn.master_point, dtype=float)
    d = np.dot(normal, mp)

    # Collect nodes near the plane
    plane_nodes = []
    for tag in all_nodes:
        c = self._coords_of(tag)
        dist_to_plane = abs(np.dot(normal, c) - d)
        if dist_to_plane <= defn.plane_tolerance:
            plane_nodes.append(tag)

    if not plane_nodes:
        return NodeGroupRecord(
            kind=ConstraintKind.RIGID_DIAPHRAGM,
            name=defn.name,
            dofs=list(defn.constrained_dofs),
        )

    # Find master: closest to master_point
    master_tag, _ = self._closest_node(mp)
    if master_tag not in plane_nodes:
        # Pick the closest plane node instead
        dists = [np.linalg.norm(self._coords_of(t) - mp)
                 for t in plane_nodes]
        master_tag = plane_nodes[int(np.argmin(dists))]

    slave_tags = [t for t in plane_nodes if t != master_tag]
    master_xyz = self._coords_of(master_tag)
    offsets = np.array([
        self._coords_of(t) - master_xyz for t in slave_tags
    ]) if slave_tags else None

    return NodeGroupRecord(
        kind=ConstraintKind.RIGID_DIAPHRAGM,
        name=defn.name,
        master_node=master_tag,
        slave_nodes=slave_tags,
        dofs=list(defn.constrained_dofs),
        offsets=offsets,
        plane_normal=normal,
    )

resolve_kinematic_coupling

resolve_kinematic_coupling(defn: KinematicCouplingDef | RigidBodyDef, master_nodes: set[int], slave_nodes: set[int]) -> NodeGroupRecord

Resolve kinematic coupling or rigid body constraint.

Source code in src/apeGmsh/_kernel/resolvers/_constraint_resolver/_resolver.py
def resolve_kinematic_coupling(
    self,
    defn: KinematicCouplingDef | RigidBodyDef,
    master_nodes: set[int],
    slave_nodes: set[int],
) -> NodeGroupRecord:
    """
    Resolve kinematic coupling or rigid body constraint.
    """
    master_tag, _ = self._closest_node_in_set(defn.master_point, master_nodes)
    master_xyz = self._coords_of(master_tag)

    slaves = sorted(slave_nodes - {master_tag})
    offsets = np.array([
        self._coords_of(t) - master_xyz for t in slaves
    ]) if slaves else None

    as_element = False
    mass = None
    omega = None
    if isinstance(defn, RigidBodyDef):
        dofs = [1, 2, 3, 4, 5, 6]
        control = None
        as_element = defn.as_element
        mass = defn.mass
        omega = defn.omega
    else:
        # kinematic_coupling: dofs=None ⇒ "all the slave has" — record an
        # empty list so the LadrunoKinematicCoupling emit omits ``-dof``
        # (the fork element's own default, ragged-layout aware).
        dofs = list(defn.dofs) if defn.dofs else []
        # Carry the explicit penalty knobs onto the record; store None
        # when every knob is at its default (no flags to emit).
        ctrl = getattr(defn, "control", None)
        control = ctrl if (ctrl is not None and not ctrl.is_default) else None

    return NodeGroupRecord(
        kind=defn.kind,
        name=defn.name,
        master_node=master_tag,
        slave_nodes=slaves,
        dofs=dofs,
        offsets=offsets,
        control=control,
        as_element=as_element,
        mass=mass,
        omega=omega,
    )

resolve_tie

resolve_tie(defn: TieDef, master_face_conn: ndarray, slave_nodes: set[int]) -> list[InterpolationRecord]

Resolve a surface tie via closest-point projection.

For each slave node, find the closest master face, project onto it, and compute shape function weights.

Parameters

defn : TieDef master_face_conn : ndarray, shape (n_faces, n_nodes_per_face) Connectivity of master surface element faces (node tags). slave_nodes : set[int] Slave node tags to project.

Returns

list[InterpolationRecord]

Raises

ValueError When ZERO slave nodes project within defn.tolerance — a tie that resolves no records leaves the slave side completely unattached while the model still solves (converged-but-wrong). Shared choke point: both the build-phase and chain-phase tie/tied_contact paths resolve through here, so the guard covers all four routes.

Warns

UserWarning When only SOME slave nodes project (out-of-tolerance nodes are skipped). Legitimate when the slave surface extends past the master patch — otherwise the tolerance is too tight for the interface gap.

Source code in src/apeGmsh/_kernel/resolvers/_constraint_resolver/_resolver.py
def resolve_tie(
    self,
    defn: TieDef,
    master_face_conn: ndarray,
    slave_nodes: set[int],
) -> list[InterpolationRecord]:
    """
    Resolve a surface tie via closest-point projection.

    For each slave node, find the closest master face, project
    onto it, and compute shape function weights.

    Parameters
    ----------
    defn : TieDef
    master_face_conn : ndarray, shape (n_faces, n_nodes_per_face)
        Connectivity of master surface element faces (node tags).
    slave_nodes : set[int]
        Slave node tags to project.

    Returns
    -------
    list[InterpolationRecord]

    Raises
    ------
    ValueError
        When ZERO slave nodes project within ``defn.tolerance`` —
        a tie that resolves no records leaves the slave side
        completely unattached while the model still solves
        (converged-but-wrong).  Shared choke point: both the
        build-phase and chain-phase tie/tied_contact paths resolve
        through here, so the guard covers all four routes.

    Warns
    -----
    UserWarning
        When only SOME slave nodes project (out-of-tolerance nodes
        are skipped).  Legitimate when the slave surface extends
        past the master patch — otherwise the tolerance is too
        tight for the interface gap.
    """
    dofs = defn.dofs or [1, 2, 3]
    records = []

    # Pre-compute face centroids for quick nearest-face search
    n_faces = master_face_conn.shape[0]
    n_fpn = master_face_conn.shape[1]
    face_centroids = np.zeros((n_faces, 3))
    face_coords_list = []
    for fi in range(n_faces):
        nodes = master_face_conn[fi]
        coords = np.array([self._coords_of(int(n)) for n in nodes])
        face_coords_list.append(coords)
        face_centroids[fi] = coords.mean(axis=0)

    face_tree = _SpatialIndex(face_centroids)

    for st in sorted(slave_nodes):
        s_xyz = self._coords_of(st)

        # Find K nearest face centroids, try projection on each
        K = min(5, n_faces)
        _, face_indices = face_tree.query(s_xyz, k=K)
        if isinstance(face_indices, (int, np.integer)):
            face_indices = [face_indices]

        best_dist = float('inf')
        best_record = None

        for fi in face_indices:
            fi = int(fi)
            fc = face_coords_list[fi]
            fn = master_face_conn[fi]

            try:
                xi_eta, proj, dist = _project_point_to_face(s_xyz, fc)
            except Exception:
                continue

            if dist > defn.tolerance:
                continue

            if not _is_inside_parametric(xi_eta, n_fpn):
                continue

            if dist < best_dist:
                best_dist = dist
                shape_fn = SHAPE_FUNCTIONS[n_fpn]
                weights = shape_fn(xi_eta[0], xi_eta[1])

                best_record = InterpolationRecord(
                    kind=ConstraintKind.TIE,
                    name=defn.name,
                    slave_node=st,
                    master_nodes=[int(n) for n in fn],
                    weights=weights,
                    dofs=list(dofs),
                    projected_point=proj,
                    parametric_coords=xi_eta,
                    stiffness=defn.stiffness,
                    stiffness_p=defn.stiffness_p,
                    rotational=defn.rotational,
                    pressure=defn.pressure,
                    enforce=getattr(defn, "enforce", "penalty"),
                    control=(
                        _ctrl if (_ctrl := getattr(defn, "control", None))
                        is not None and not _ctrl.is_default else None),
                )

        if best_record is not None:
            records.append(best_record)

    if slave_nodes and not records:
        raise ValueError(
            f"tie {defn.name or ''!s}: resolved 0 records — none of "
            f"the {len(slave_nodes)} slave nodes of "
            f"{defn.slave_label!r} projects onto "
            f"{defn.master_label!r} within "
            f"tolerance={defn.tolerance} — the slave side would be "
            f"left completely unattached.  Check the tolerance "
            f"against the interface gap and that the labels name "
            f"the two touching surfaces."
        )
    if len(records) < len(slave_nodes):
        import warnings

        warnings.warn(
            f"tie {defn.name or ''!s}: only {len(records)} of "
            f"{len(slave_nodes)} slave nodes of "
            f"{defn.slave_label!r} projected onto "
            f"{defn.master_label!r} within "
            f"tolerance={defn.tolerance}; the rest are NOT tied. "
            f"Legitimate when the slave surface extends past the "
            f"master patch — otherwise raise the tolerance.",
            UserWarning,
            stacklevel=3,
        )
    return records

resolve_tie_mortar

resolve_tie_mortar(defn: TieDef, master_face_conn: ndarray, slave_face_conn: ndarray) -> list[InterpolationRecord]

Resolve a method="mortar" tie (ADR 0086).

Dual-basis integral mortar over the slave/master facet overlaps — one :class:InterpolationRecord per slave node, weights from P = D_dual⁻¹ M instead of collocated shape functions, so neither side's interpolation order is imposed on the other. defn.tolerance is the out-of-plane coincidence tolerance; defn.outward optionally orients the interface plane.

Fail-loud by design: every degenerate case (non-flat interface, ambiguous normal, non-convex facet, curved edge, overlapping master facets, coverage gap, partition-of-unity failure, tri6 slave facets) raises :class:~apeGmsh._kernel.resolvers._mortar.MortarTieError — a mortar tie never silently resolves to nothing.

Source code in src/apeGmsh/_kernel/resolvers/_constraint_resolver/_resolver.py
def resolve_tie_mortar(
    self,
    defn: TieDef,
    master_face_conn: ndarray,
    slave_face_conn: ndarray,
) -> list[InterpolationRecord]:
    """Resolve a ``method="mortar"`` tie (ADR 0086).

    Dual-basis integral mortar over the slave/master facet overlaps
    — one :class:`InterpolationRecord` per slave node, weights from
    ``P = D_dual⁻¹ M`` instead of collocated shape functions, so
    neither side's interpolation order is imposed on the other.
    ``defn.tolerance`` is the out-of-plane coincidence tolerance;
    ``defn.outward`` optionally orients the interface plane.

    Fail-loud by design: every degenerate case (non-flat interface,
    ambiguous normal, non-convex facet, curved edge, overlapping
    master facets, coverage gap, partition-of-unity failure, tri6
    slave facets) raises
    :class:`~apeGmsh._kernel.resolvers._mortar.MortarTieError` — a
    mortar tie never silently resolves to nothing.
    """
    from .._mortar import compute_dual_mortar_rows

    dofs = defn.dofs or [1, 2, 3]
    rows = compute_dual_mortar_rows(
        slave_face_conn,
        master_face_conn,
        self._coords_of,
        gap_tol=defn.tolerance,
        outward=defn.outward,
    )
    return [
        InterpolationRecord(
            kind=ConstraintKind.TIE,
            name=defn.name,
            slave_node=int(tag_s),
            master_nodes=list(m_tags),
            weights=weights,
            dofs=list(dofs),
            enforce=defn.enforce,
        )
        for tag_s, m_tags, weights in rows
    ]

resolve_distributing

resolve_distributing(defn: DistributingCouplingDef, master_nodes: set[int], slave_nodes: set[int], slave_face_conn: 'ndarray | None' = None) -> InterpolationRecord

Resolve an RBE3 distributing coupling to an InterpolationRecord.

The reference (dependent) node R is the master-side node closest to defn.master_point; the independents are the slave-side node set (minus R if it overlaps). The record carries R in slave_node and the independents in master_nodes — the field names read backwards for RBE3 (R is the dependent, the "master_nodes" are the independents it is fit from), but the geometry maps 1:1 onto the fork emit element LadrunoDistributingCoupling $tag $R $N $i1..iN.

weighting="uniform" leaves weights None ⇒ the emit omits -w and the fork element uses equal weights.

weighting="area" computes per-independent tributary areas over slave_face_conn (the slave surface's face connectivity, shape (n_faces, n_per_face)): each face's area is split equally among its nodes and accumulated — the same lumping model as g.loads surface-tributary resolution, so the RBE3 force distribution matches a uniform traction lumped onto the same surface. Weights are returned in the same sorted independent order the record emits (-w[i] pairs with i_i); the fork normalizes by W = Σw so only proportionality matters. Fails loud if an independent node lies on no slave face (a node-set / face-set mismatch would silently zero its share).

Source code in src/apeGmsh/_kernel/resolvers/_constraint_resolver/_resolver.py
def resolve_distributing(
    self,
    defn: DistributingCouplingDef,
    master_nodes: set[int],
    slave_nodes: set[int],
    slave_face_conn: "ndarray | None" = None,
) -> InterpolationRecord:
    """Resolve an RBE3 distributing coupling to an InterpolationRecord.

    The reference (dependent) node R is the master-side node closest
    to ``defn.master_point``; the **independents** are the slave-side
    node set (minus R if it overlaps). The record carries R in
    ``slave_node`` and the independents in ``master_nodes`` — the
    field names read backwards for RBE3 (R is the *dependent*, the
    "master_nodes" are the independents it is fit from), but the
    geometry maps 1:1 onto the fork emit
    ``element LadrunoDistributingCoupling $tag $R $N $i1..iN``.

    ``weighting="uniform"`` leaves weights ``None`` ⇒ the emit omits
    ``-w`` and the fork element uses equal weights.

    ``weighting="area"`` computes per-independent **tributary areas**
    over ``slave_face_conn`` (the slave surface's face connectivity,
    shape ``(n_faces, n_per_face)``): each face's area is split
    equally among its nodes and accumulated — the same lumping model
    as ``g.loads`` surface-tributary resolution, so the RBE3 force
    distribution matches a uniform traction lumped onto the same
    surface. Weights are returned in the **same sorted independent
    order** the record emits (``-w[i]`` pairs with ``i_i``); the fork
    normalizes by ``W = Σw`` so only proportionality matters. Fails
    loud if an independent node lies on no slave face (a node-set /
    face-set mismatch would silently zero its share).
    """
    ref_tag, _ = self._closest_node_in_set(defn.master_point, master_nodes)
    independents = sorted(slave_nodes - {ref_tag})
    if not independents:
        raise ValueError(
            "distributing_coupling: no independent nodes resolved — the "
            "slave set is empty or contains only the reference node. The "
            "reference (master) and independents (slaves) must be distinct "
            "node sets."
        )
    weights = None
    if getattr(defn, "weighting", "uniform") == "area":
        weights = self._tributary_areas(
            independents, slave_face_conn, name=defn.name,
        )
    ctrl = getattr(defn, "control", None)
    control = ctrl if (ctrl is not None and not ctrl.is_default) else None
    return InterpolationRecord(
        kind=defn.kind,
        name=defn.name,
        slave_node=ref_tag,
        master_nodes=independents,
        weights=weights,
        control=control,
    )

resolve_tied_contact

resolve_tied_contact(defn: TiedContactDef, master_face_conn: ndarray, slave_face_conn: ndarray, master_nodes: set[int], slave_nodes: set[int]) -> SurfaceCouplingRecord

Resolve a surface-to-surface tie — one-directional.

Slave-surface nodes are interpolated onto the master faces (the standard tied-contact / Abaqus *TIE convention: the slave conforms to the master, which is the reference).

The previous implementation also projected master nodes onto slave faces and concatenated both directions — a node could then be a slave in one direction and a master-face node in the other, producing cyclic / over-determined MPCs the constraint handler cannot satisfy. slave_face_conn is accepted for dispatch-signature stability but unused.

Source code in src/apeGmsh/_kernel/resolvers/_constraint_resolver/_resolver.py
def resolve_tied_contact(
    self,
    defn: TiedContactDef,
    master_face_conn: ndarray,
    slave_face_conn: ndarray,
    master_nodes: set[int],
    slave_nodes: set[int],
) -> SurfaceCouplingRecord:
    """Resolve a surface-to-surface tie — **one-directional**.

    Slave-surface nodes are interpolated onto the master faces
    (the standard tied-contact / Abaqus ``*TIE`` convention: the
    slave conforms to the master, which is the reference).

    The previous implementation also projected master nodes onto
    slave faces and concatenated both directions — a node could
    then be a slave in one direction and a master-face node in
    the other, producing cyclic / over-determined MPCs the
    constraint handler cannot satisfy.  ``slave_face_conn`` is
    accepted for dispatch-signature stability but unused.
    """
    dofs = defn.dofs or [1, 2, 3]

    # Slave nodes -> master faces (slave conforms to master).
    tie_fwd = TieDef(
        master_label=defn.master_label,
        slave_label=defn.slave_label,
        tolerance=defn.tolerance,
        dofs=dofs,
        stiffness=defn.stiffness,
        stiffness_p=defn.stiffness_p,
        rotational=defn.rotational,
        pressure=defn.pressure,
        enforce=getattr(defn, "enforce", "penalty"),
        control=getattr(defn, "control", None),
    )
    all_records = self.resolve_tie(
        tie_fwd, master_face_conn, slave_nodes,
    )

    return SurfaceCouplingRecord(
        kind=ConstraintKind.TIED_CONTACT,
        name=defn.name,
        slave_records=all_records,
        master_nodes=sorted(master_nodes),
        slave_nodes=sorted(slave_nodes),
        dofs=list(dofs),
    )

resolve_node_to_surface

resolve_node_to_surface(defn: NodeToSurfaceDef, master_tag: int, slave_nodes: set[int]) -> NodeToSurfaceRecord

Resolve a 6-DOF node to 3-DOF surface coupling.

Steps:

  1. Use the master node tag directly (already resolved from master_label as bare node tag).
  2. Generate phantom node tags — one per slave, starting at max(all_existing_tags) + 1.
  3. Build rigid-beam records: master -> each phantom.
  4. Build equalDOF records: each phantom -> original slave (translations only).
Parameters

defn : NodeToSurfaceDef master_tag : int The 6-DOF master node tag (dim=0). slave_nodes : set[int] Node tags belonging to the slave surface (dim=2, 3-DOF).

Returns

NodeToSurfaceRecord

Source code in src/apeGmsh/_kernel/resolvers/_constraint_resolver/_resolver.py
def resolve_node_to_surface(
    self,
    defn: NodeToSurfaceDef,
    master_tag: int,
    slave_nodes: set[int],
) -> NodeToSurfaceRecord:
    """
    Resolve a 6-DOF node to 3-DOF surface coupling.

    Steps:

    1. Use the master node tag directly (already resolved from
       ``master_label`` as bare node tag).
    2. Generate phantom node tags — one per slave, starting at
       ``max(all_existing_tags) + 1``.
    3. Build rigid-beam records: master -> each phantom.
    4. Build equalDOF records: each phantom -> original slave
       (translations only).

    Parameters
    ----------
    defn : NodeToSurfaceDef
    master_tag : int
        The 6-DOF master node tag (dim=0).
    slave_nodes : set[int]
        Node tags belonging to the slave surface (dim=2, 3-DOF).

    Returns
    -------
    NodeToSurfaceRecord
    """

    master_xyz = self._coords_of(master_tag)
    slave_list = sorted(slave_nodes - {master_tag})
    dofs = defn.dofs or [1, 2, 3]

    # -- 2. Generate phantom node tags (unique across calls) --
    start = self._next_phantom_tag
    phantom_tags = list(range(start, start + len(slave_list)))
    self._next_phantom_tag = start + len(slave_list)

    phantom_coords = np.array([
        self._coords_of(t) for t in slave_list
    ])

    # -- 3. Rigid beam: master -> phantom --
    # No dofs list: OpenSees `rigidLink('beam', ...)` picks DOFs
    # from the model's ndf at emit time. The caller's DOF space is
    # not known at resolve time and apeGmsh refuses to guess.
    rigid_records = []
    for phantom_tag, slave_tag in zip(phantom_tags, slave_list):
        slave_xyz = self._coords_of(slave_tag)
        offset = slave_xyz - master_xyz
        rigid_records.append(NodePairRecord(
            kind=ConstraintKind.RIGID_BEAM,
            name=defn.name,
            master_node=master_tag,
            slave_node=phantom_tag,
            offset=offset,
        ))

    # -- 4. EqualDOF: phantom -> slave (translations only) --
    edof_records = []
    for phantom_tag, slave_tag in zip(phantom_tags, slave_list):
        edof_records.append(NodePairRecord(
            kind=ConstraintKind.EQUAL_DOF,
            name=defn.name,
            master_node=phantom_tag,
            slave_node=slave_tag,
            dofs=list(dofs),
        ))

    return NodeToSurfaceRecord(
        kind=ConstraintKind.NODE_TO_SURFACE,
        name=defn.name,
        master_node=master_tag,
        slave_nodes=slave_list,
        phantom_nodes=phantom_tags,
        phantom_coords=phantom_coords,
        rigid_link_records=rigid_records,
        equal_dof_records=edof_records,
        dofs=list(dofs),
    )

resolve_embedded

resolve_embedded(defn, host_elems: ndarray, embedded_nodes: set[int] | list[int]) -> list[InterpolationRecord]

Resolve an embedded-element constraint.

Each embedded node is located inside a host element (tri3 in 2D or tet4 in 3D) via barycentric coordinates. The resulting shape-function weights couple the embedded node to the host element's corner nodes, matching the kinematics of ASDEmbeddedNodeElement in OpenSees.

Parameters

defn : EmbeddedDef Only defn.tolerance and defn.name are consulted. host_elems : ndarray, shape (n_elems, 3 | 4) Node-tag connectivity of the host elements. A row of 3 is treated as tri3; a row of 4 is treated as tet4. embedded_nodes : iterable of int Node tags to embed.

Returns

list[InterpolationRecord] One record per embedded node successfully located.

Source code in src/apeGmsh/_kernel/resolvers/_constraint_resolver/_resolver.py
def resolve_embedded(
    self,
    defn,
    host_elems: ndarray,
    embedded_nodes: set[int] | list[int],
) -> list[InterpolationRecord]:
    """
    Resolve an embedded-element constraint.

    Each embedded node is located inside a host element (tri3 in
    2D or tet4 in 3D) via barycentric coordinates. The resulting
    shape-function weights couple the embedded node to the host
    element's corner nodes, matching the kinematics of
    ``ASDEmbeddedNodeElement`` in OpenSees.

    Parameters
    ----------
    defn : EmbeddedDef
        Only ``defn.tolerance`` and ``defn.name`` are consulted.
    host_elems : ndarray, shape (n_elems, 3 | 4)
        Node-tag connectivity of the host elements. A row of 3
        is treated as tri3; a row of 4 is treated as tet4.
    embedded_nodes : iterable of int
        Node tags to embed.

    Returns
    -------
    list[InterpolationRecord]
        One record per embedded node successfully located.
    """
    host_elems = np.asarray(host_elems, dtype=int)
    if host_elems.ndim != 2 or host_elems.shape[0] == 0:
        return []

    npe = int(host_elems.shape[1])
    if npe not in (3, 4):
        raise ValueError(
            f"resolve_embedded: host elements must be tri3 (npe=3) "
            f"or tet4 (npe=4), got npe={npe}"
        )

    # Pre-compute corner coords per element and centroids for the
    # nearest-element search.
    n_elems = host_elems.shape[0]
    host_coords = np.zeros((n_elems, npe, 3), dtype=float)
    for ei in range(n_elems):
        for ni in range(npe):
            host_coords[ei, ni] = self._coords_of(int(host_elems[ei, ni]))
    centroids = host_coords.mean(axis=1)
    centroid_tree = _SpatialIndex(centroids)

    tol = float(defn.tolerance)
    # ``bary_tol`` is the inside-the-element early-break threshold
    # (resolver short-circuits once a host gives an inside hit).
    # The user-facing acceptance threshold is ``tol`` — applied
    # below as a fail-loud gate after the best-candidate search.
    bary_tol = 1e-6

    records: list[InterpolationRecord] = []
    K = min(16, n_elems)

    for en in sorted(int(t) for t in embedded_nodes):
        p = self._coords_of(en)
        _, cand = centroid_tree.query(p, k=K)
        if isinstance(cand, (int, np.integer)):
            cand = [int(cand)]
        else:
            cand = [int(c) for c in np.atleast_1d(cand)]

        best_record: InterpolationRecord | None = None
        best_excess = float("inf")

        for ei in cand:
            corners = host_coords[ei]
            if npe == 3:
                weights, excess, xi_eta = _barycentric_tri3(p, corners)
                parametric = xi_eta
            else:
                weights, excess, xi_etz = _barycentric_tet4(p, corners)
                parametric = xi_etz

            if excess is None:
                continue

            # "Inside" when all barycentric coords are non-negative
            # within bary_tol. Take the first hit; if none is fully
            # inside, keep the one with the smallest excess so the
            # caller can inspect via the log.
            if excess < best_excess:
                best_excess = excess
                best_record = InterpolationRecord(
                    kind=ConstraintKind.EMBEDDED,
                    name=defn.name,
                    slave_node=en,
                    master_nodes=[int(t) for t in host_elems[ei]],
                    weights=weights,
                    dofs=[1, 2, 3],
                    projected_point=p.copy(),
                    parametric_coords=parametric,
                    excess=float(excess),
                    stiffness=defn.stiffness,
                    stiffness_p=defn.stiffness_p,
                    rotational=defn.rotational,
                    pressure=defn.pressure,
                )
            if excess <= bary_tol:
                break

        if best_record is None:
            continue

        # Fail-loud gate on the user-facing barycentric excess
        # threshold.  ``best_excess`` accounts for float math via
        # ``bary_tol`` — an inside hit short-circuits the search
        # with excess <= 1e-6 ≈ 0.  An off-host node that survives
        # the search has ``best_excess > 0`` (extrapolation); we
        # accept only when ``best_excess <= defn.tolerance``, else
        # raise with a clear message naming the slave node so the
        # user can either fix the geometry / mesh or set a wider
        # tolerance explicitly.
        if best_excess > tol + bary_tol:
            raise ValueError(
                f"resolve_embedded: slave node {en} lies outside "
                f"every host element (barycentric excess "
                f"{best_excess:.3e}, tolerance {tol:.3e}).  Fix the "
                f"geometry/mesh so the embedded node falls inside "
                f"the host, OR set `EmbeddedDef.tolerance` to a "
                f"value >= {best_excess:.3e} if extrapolation is "
                f"intentional.  Constraint name: {defn.name!r}."
            )
        records.append(best_record)

    return records

resolve_node_to_surface_spring

resolve_node_to_surface_spring(defn: 'NodeToSurfaceSpringDef', master_tag: int, slave_nodes: set[int]) -> NodeToSurfaceRecord

Resolve a spring-variant 6-DOF → 3-DOF surface coupling.

Identical phantom-node generation and equalDOF records as :meth:resolve_node_to_surface. The only difference is that the master → phantom rigid-link records are tagged with kind='rigid_beam_stiff' so they are routed through stiff_beam_groups() at emission time (becoming stiff elasticBeamColumn elements) instead of rigid_link_groups() (which would emit rigidLink and hit the ill-conditioning described in :class:NodeToSurfaceSpringDef).

Source code in src/apeGmsh/_kernel/resolvers/_constraint_resolver/_resolver.py
def resolve_node_to_surface_spring(
    self,
    defn: "NodeToSurfaceSpringDef",
    master_tag: int,
    slave_nodes: set[int],
) -> NodeToSurfaceRecord:
    """
    Resolve a spring-variant 6-DOF → 3-DOF surface coupling.

    Identical phantom-node generation and equalDOF records as
    :meth:`resolve_node_to_surface`. The only difference is that
    the master → phantom rigid-link records are tagged with
    ``kind='rigid_beam_stiff'`` so they are routed through
    ``stiff_beam_groups()`` at emission time (becoming stiff
    ``elasticBeamColumn`` elements) instead of
    ``rigid_link_groups()`` (which would emit ``rigidLink`` and
    hit the ill-conditioning described in
    :class:`NodeToSurfaceSpringDef`).
    """

    master_xyz = self._coords_of(master_tag)
    slave_list = sorted(slave_nodes - {master_tag})
    dofs = defn.dofs or [1, 2, 3]

    start = self._next_phantom_tag
    phantom_tags = list(range(start, start + len(slave_list)))
    self._next_phantom_tag = start + len(slave_list)

    phantom_coords = np.array([
        self._coords_of(t) for t in slave_list
    ])

    # Stiff beams: master → phantom. Same structure as the
    # constraint-based variant but tagged with a distinct kind so
    # the mesh iterators can route them to the element emission
    # path.
    stiff_records = []
    for phantom_tag, slave_tag in zip(phantom_tags, slave_list):
        slave_xyz = self._coords_of(slave_tag)
        offset = slave_xyz - master_xyz
        stiff_records.append(NodePairRecord(
            kind=ConstraintKind.RIGID_BEAM_STIFF,
            name=defn.name,
            master_node=master_tag,
            slave_node=phantom_tag,
            offset=offset,
        ))

    edof_records = []
    for phantom_tag, slave_tag in zip(phantom_tags, slave_list):
        edof_records.append(NodePairRecord(
            kind=ConstraintKind.EQUAL_DOF,
            name=defn.name,
            master_node=phantom_tag,
            slave_node=slave_tag,
            dofs=list(dofs),
        ))

    return NodeToSurfaceRecord(
        kind=ConstraintKind.NODE_TO_SURFACE_SPRING,
        name=defn.name,
        master_node=master_tag,
        slave_nodes=slave_list,
        phantom_nodes=phantom_tags,
        phantom_coords=phantom_coords,
        rigid_link_records=stiff_records,
        equal_dof_records=edof_records,
        dofs=list(dofs),
    )

Module shim

The top-level apeGmsh.core.ConstraintsComposite module re-exports all public names from the _constraint_* modules for backwards compatibility. Module-level docstring contains the canonical taxonomy.

apeGmsh.core.ConstraintsComposite

ConstraintsComposite -- Define and resolve kinematic constraints.

Two-stage pipeline:

  1. Define (pre-mesh): factory methods store :class:ConstraintDef objects describing geometric intent.
  2. Resolve (post-mesh): :meth:resolve delegates to :class:ConstraintResolver (in solvers/Constraints.py) with caller-provided node/face maps. Dependency-injected -- this module never imports PartsRegistry.

Usage::

g.constraints.equal_dof("beam", "slab", tolerance=1e-3)
g.constraints.tie("beam", "slab", master_entities=[(2, 5)])

fem = g.mesh.queries.get_fem_data(dim=2)
nm  = g.parts.build_node_map(fem.nodes.ids, fem.nodes.coords)
fm  = g.parts.build_face_map(nm)
recs = g.constraints.resolve(
    fem.nodes.ids, fem.nodes.coords, node_map=nm, face_map=fm,
)