Constraints¶
This page explains how apeGmsh couples regions of a model that don't share
nodes — which constraint fits which coupling situation, when declarations turn
into node-level records, and why you never hand-write a single equalDOF for
the solver.
One equation, four situations¶
Every multi-point constraint in the library is ultimately the same statement:
some slave DOFs are a linear function of some master DOFs,
u_slave = C · u_master. What distinguishes the dozen factory methods on
g.constraints is how the coefficient matrix gets built, and that follows
directly from the geometry of the interface:
- the two sides share node positions → pair co-located nodes directly, or drive a node group rigidly from one master;
- the two sides meet at a surface but the meshes don't match → project slave nodes onto master faces and interpolate through shape functions;
- one part is buried inside another (rebar in concrete) → locate each embedded node inside a host element and couple it to the host's corners;
- the two sides may touch, slide, or separate → a genuine contact formulation, not a kinematic constraint at all.
Pick the situation and the method follows. Everything below walks the four in that order, but first the timing — because it's the same for all of them, and it's the part that makes constraints feel different from other FEM tools.
Declare, then resolve, then emit¶
Constraints follow the declare-then-resolve rhythm from the mental model. You declare a constraint before the mesh exists, against names — part labels or physical groups, never node tags:
# Stage 1 — declare (pre-mesh)
g.constraints.equal_dof("slab", "column_top", dofs=[1, 2, 3])
# Stage 2 — resolve (happens inside get_fem_data)
fem = g.mesh.queries.get_fem_data(dim=3)
# Resolved records are now on the broker
fem.nodes.constraints # node-pair and node-group records
fem.elements.constraints # surface interpolation records
The declaration is pure intent — no node exists yet when you make it, which is
exactly why it survives remeshing. At get_fem_data(...) the resolver walks
your declarations against the real mesh: it finds the co-located pairs, runs
the projections, computes the interpolation weights, and lands concrete records
on the snapshot — node-level constraints on fem.nodes.constraints,
surface-coupling records on fem.elements.constraints.
Then the third stage happens without you: the typed apeSees(fem) bridge reads
those records and emits the solver commands automatically — equalDOF,
rigidLink, rigidDiaphragm, the ASDEmbeddedNodeElement penalty elements,
phantom nodes and all. You declare the tie; the bridge writes the deck. The one
refinement worth knowing at concept level: in a staged analysis, a constraint
whose nodes only come alive in a later stage can be given a name= at
declaration and claimed inside that stage's block
(s.tie(name=...), s.embedded(name=...)) so it emits there instead of
globally.
One convention runs through all of it: DOFs are 1-based indices
(1=ux, 2=uy, 3=uz, 4=rx, 5=ry, 6=rz), and every geometric tolerance is in
model units — 1e-6 is right for a metre model and uselessly tight for a
millimetre one. The single exception to the index convention is
g.constraints.bc(...), the fix-to-ground boundary condition, which takes an
OpenSees-style restraint mask (dofs=[1, 1, 0] means "fix x and y") because
it becomes ops.fix downstream. It lives on g.constraints because fixity is
permanent, not load-pattern-scoped; the recipe is in
Supports & BCs.
Same mesh: co-located pairs and rigid clusters¶
When two parts genuinely share node positions at their boundary — a conformal
interface, typically the product of g.parts.fragment_all() or careful
construction — the constraint is just bookkeeping. equal_dof finds every
master/slave node pair within tolerance and ties the selected DOFs:
g.constraints.equal_dof(
"slab", "column_top",
dofs=[1, 2, 3], # couple translations only
tolerance=1e-6,
)
Omit dofs and all available DOFs are tied. Two siblings cover the less-common
shapes of the same situation: equal_dof_mixed ties differently numbered
DOFs across a pair (master uz driving slave rz, say, at a solid-to-shell
joint), and penalty replaces the algebraic constraint with a stiff spring
when the solver's constraint handler struggles.
rigid_link adds kinematics: each slave follows the master through a rigid
offset arm, so a rotation at the master produces translations at the slaves.
link_type="beam" carries all six DOFs; "rod" couples translations only.
Three group-level constraints build on the same idea. rigid_diaphragm is the
classic floor constraint — every slab node within plane_tolerance of the
diaphragm plane shares in-plane motion with a master node:
g.constraints.rigid_diaphragm(
"slab", "slab_master",
master_point=(2.5, 2.5, 3.0),
plane_normal=(0, 0, 1),
plane_tolerance=0.05,
)
rigid_body welds a whole region to a master (all six DOFs), and
kinematic_coupling is the RBE2 of the family: a reference node rigidly drives
a node set with a per-set DOF selection, carrying the correct moment-arm
transport so an offset reference couples rigidly. Its flexible counterpart is
distributing_coupling — RBE3 — which distributes a force at a reference point
over a node set as a statically equivalent pattern while the set stays
deformable, with weighting="area" for a traction-like spread. Reach for RBE2
when the region must move rigidly (a loading platen); RBE3 when you want to
introduce a load without stiffening anything. Both emit dedicated Ladruno
fork elements: the deck is written on any build, but running it needs the fork.
A labelled g.decouple_node (or its handle) is a valid reference for either —
pass it as master_label so essential (ops.fix) or natural (p.load) BCs
land on the ndf=6 work point without a post-mesh retarget.
Non-matching meshes: ties¶
Fragmenting everything conformal is not always possible or desirable — a shell
floor on a solid column, two independently meshed blocks, a refined region
against a coarse one. tie handles displacement continuity across a
non-matching interface: each slave node is projected onto the closest master
face and its DOFs are interpolated from that face's corner DOFs through the
face's shape functions, u_slave = Σ Nᵢ(ξ, η) · u_masterᵢ — the same idea as
Abaqus *TIE.
The tolerance is the maximum projection distance — generous enough to bridge
any geometric gap, tight enough not to grab the wrong face. As a rule, make the
finer mesh the master: more shape functions to project onto. When a part
has many faces, scope the search with master_entities= / slave_entities=.
How the tie is enforced is a separate choice, exposed as enforce=. The
default "penalty" emits ASDEmbeddedNodeElement penalty elements — robust
and handler-independent, with a stiffness that defaults to "auto" (sized
from the host material at emit, so it lands a few orders above the element
stiffness in whatever unit system you model in). "equation" emits exact
multi-point equations (translations only) enforced by a constraint handler,
and "penalty_al" uses the fork's augmented-Lagrange penalty element. Start
with the default; switch to "equation" when you need the interface exact
and you are running on a Ladruno fork build — the live equation route is
fork-gated, though deck emission is not
(Backend capabilities).
How the tie's weights are computed is a third, orthogonal choice —
method=. The default "collocation" is the projection described above:
each slave node pinned to the master field at one point. That is the right
answer until the two sides differ in element order — hex20 faces tied onto
hex8 faces — where pinning quadratic nodes to a bilinear field over-constrains
the finer side. method="mortar" instead integrates the bond over the facet
overlaps with a dual (biorthogonal) slave basis, so neither side's
interpolation is imposed on the other, and the result is master/slave
symmetric in a way collocation is not. It requires enforce="equation", a
flat coincident interface, and dim-2 element groups on both sides (extract
with dim=None); every degenerate case is a hard error rather than a silent
zero-record tie. It works on composed assemblies, which is where
order-mismatched models necessarily live. The practical rules are in
Tie non-matching meshes.
tied_contact is the surface-to-surface version of the same projection —
every slave-surface node tied to the master surface. It is one-directional
(slave conforms to master; pick the finer mesh as master), and takes the same
enforce= routes as tie.
The step-by-step recipe is in Tie non-matching meshes, and a complete worked model in the tie example.
Buried parts: embedded and beam-to-solid¶
Two situations look like ties but aren't surface-to-surface. The first is embedding: a lower-dimensional part living inside a host — rebar curves in a concrete volume, stiffeners in a shell.
Each embedded node is located inside a host element and constrained to follow
the host's displacement field through its shape functions. Non-simplex and
higher-order hosts are decomposed to linear sub-tets/sub-tris under the hood,
and embedded nodes that already coincide with a host corner are dropped —
they're attached through shared connectivity and constraining them again would
be redundant. Emission is ASDEmbeddedNodeElement, automatic like everything
else.
The second is the DOF-mismatch bridge: connecting a 6-DOF frame node to a
3-DOF solid face. node_to_surface builds a compound constraint — phantom
6-DOF nodes duplicated at the slave positions, rigid links from the master to
each phantom, equalDOF from each phantom down to the real solid node:
Here the master is a geometric point entity, not a part label — the one
place in the family where you target an entity rather than a name. The
node_to_surface_spring variant replaces the rigid links with stiff beam
elements; use it when the master carries free rotational DOFs under direct
moment loading, where the purely kinematic version leaves those rotations
without a stiffness path and the matrix conditioning suffers.
Contact¶
Everything above is kinematic — a permanent bond, active from the first step.
g.constraints.contact(...) is the genuinely different animal: a face-to-face
contact interaction that can open, close, slide, and carry friction, emitted
through the Ladruno fork's contact subsystem.
Two formulations: "nts" (node-to-segment penalty, with Coulomb friction) and
"mortar" (segment-to-segment augmented-Lagrange — the accuracy lane for
non-matching interfaces). Passing tie=True to the mortar formulation freezes
the pair into a permanent contact-tie bond. For a plain permanent bond,
prefer tie(method="mortar", enforce="equation") instead: the contact-tie is
enforced by penalty/augmentation (the gap is driven toward zero, never exactly
zero), it mandates the LadrunoContact handler — which cannot coexist with
any enforce="equation" tie in the same analysis — and it accepts linear
facets only. tie=True earns its keep when the interface must also behave
as contact later in the run. Contact declarations resolve additively onto
fem.elements.contacts rather than the MP-constraint channels; they need a
live gmsh session (declaring one on a from_h5/composed session raises), and
like the fork coupling elements, the deck emits on any build but runs only on
the fork.
One historical note, since older material mentions it: g.constraints.mortar()
still exists but is a deprecated alias that delegates to
contact(formulation="mortar", tie=True) and warns. Call contact() directly.
One more lane has no master mesh at all. g.constraints.contact_plane puts the
slave surface against a fixed infinite rigid plane given by a normal and a
point, frictionless, with a kn you must supply — there is no "auto" here.
It is the cheapest real contact in the library, and the only one that does not
demand ndf == ndm, so a shell can sit on a floor.
Contact in a plane model¶
Everything above holds in 2D, but the shape of a contact surface changes and so
does the way you orient it. The fork decides a surface's dimension by reading
its nodes' coordinates rather than the interpreter's ndm, and apeGmsh writes
exactly ndm coordinates per node, so a plane model reaches the 2D lanes
without being told. What changes is what you name: a 2D contact surface is the
meshed boundary curve — a dim-1 physical group — and not the dim-2 body.
Naming the body is refused rather than quietly turned into a contact between
solid elements.
Underneath, the fork's 2D surface is a flat list of node pairs chained head-to-tail, so three segments need six tags rather than four. The four-tag shorthand is legal to the parser and declares two disjoint segments with a hole between them — a deck that converges, balances its reactions, and transmits the load through the wrong distribution. apeGmsh builds that chain from the physical group's own edge connectivity, on both sides, so the holed form cannot be produced. That is the main reason to route a plane contact through the library rather than write the deck by hand.
Orientation is where 2D genuinely differs. In 3D the kernel derives a correct
normal per facet, which is why apeGmsh never guesses a global one there. The 2D
lanes instead take a single interface-level sign from a centroid vote, and that
vote is ambiguous the moment the two surfaces are coincident — which in 2D is
the ordinary case rather than the exception: the masonry joint, the footing
seated on soil, every zero-gap interface. A flush declaration is refused by
name, and there are two ways to answer it. outward=(ox, oy) names a direction
toward the slave's allowed half-space and works on both formulations.
outward="winding" instead declares the side through the master chain's own
travel — the slave lies to the left of it — which is the only thing that can
orient a curved or closed master, since no single direction can. Winding is
available on the NTS formulation only: the mortar lane runs no chain-integrity
scan for it to rest on, so a flush mortar interface always takes the vector and
a curved mortar master cannot be declared at all. What neither side will do is
guess — a master surface facing away from its slave is refused too, because
the fork's vote picks a sign and never asks whether this was the face you meant.
The remaining 2D-only parameter is thickness, worth stating precisely because
a plane model carries an out-of-plane thickness in three unrelated places. The
element's own thickness is baked into element stiffness and contact never
re-reads it. The mortar thickness=h scales the explicit penalties and the
tie stiffness once, at the fork's injection site; it is mortar-only, refused on
NTS and in 3D. And eps_n="auto" is deliberately left alone — it already
absorbs the element thickness through the element's initial stiffness, so
scaling it again would be a squared error.
Three properties of the model decide whether a plane contact transmits anything
at all, and none of them is visible in the declaration. A zero initial gap does
not arm the NTS lane, so seed a small overlap — the rigid plane arms from zero
and needs none. 2D contact carries the normal direction only, so a body whose
only other support is the interface keeps a free transverse mode, and when that
mode is excited Newton drifts while the solver reports a misleading warning
about the interface geometry. And the facets of a curved or closed master must
be sized from the expected penetration, or from the loop's own extent, rather
than from the elastic mesh around them: drive them from the same size parameter
and refining the model makes the contact worse, while a coarse closed ring
converges, balances, and transmits exactly zero. Parallel 2D contact does not
exist — it is out of scope in the fork, and refused by name here the moment the
model is partitioned. Kernel behaviour beyond all this — the vertex policy, the
radial end-cap, the units table — belongs to the fork's own
LadrunoContact2D_guide.md.
Unilateral interface springs¶
Between a bond and full contact sits a third thing, and the model that paid for
it is a tunnel liner in squeezing rock. g.constraints.interface(...) puts one
spring per coincident node pair across a 2D continuum boundary — unilateral in
the normal direction, capped at a bond strength in the tangential one. It needs
no contact search and no exclusive handler, because the pairing is plain
co-location, the same matching equal_dof does. What separates it from
equal_dof, tie and embedded is that it is not a bond at all: the normal
law carries compression and nothing else, and the tangential law stops carrying
shear once τ_b × A_trib is reached.
That distinction is mechanical, not cosmetic. With a bilateral bond the model
cannot reproduce demand saturation — the ground converges, the tie transmits
whatever force that convergence implies, and the liner's demand grows without
bound, while the field record shows metre-class convergence coexisting with
damaged-but-standing arches. Capping the bond puts a ceiling on what the ground
can hand the structure, and letting the interface open means it carries nothing
where the two sides have parted. The surface, including the sign convention you
should check once per model, is in
the constraints API; the two
things to get right at declaration are the out-of-plane thickness (required,
never guessed) and slave_ndf= when the coincident wire will be meshed as a
beam.
Reading back what you declared¶
After resolution the snapshot can tell you what actually happened —
fem.inspect.constraint_summary() prints the record counts by kind, and
fem.nodes.constraints.summary() / fem.elements.constraints.summary() give
DataFrame views. If a tie found zero pairs, the answer is almost always the
tolerance: too tight finds nothing, too loose couples nodes that shouldn't
be coupled. Check the summary before blaming the solver.
Next: Loads & masses.