apeGmsh basics¶
A first-contact guide to working inside a apeGmsh session. This is the
document to read before guide_parts_assembly.md or guide_meshing.md:
it covers only the fundamentals — how a session starts, how geometry is
built, and how the OCC boolean operations (fragment, fuse,
cut/intersect) are used to get to a conformal, mesh-ready model.
The guide is grounded in the current source:
src/apeGmsh/_session.py— session lifecycle (begin/end)src/apeGmsh/_core.py— theapeGmshcomposite containersrc/apeGmsh/core/Model.py+_model_geometry.py+_model_boolean.py— geometry creation and boolean operations
All code snippets assume from apeGmsh import apeGmsh.
1. Why initialize? The session lifecycle¶
Gmsh is a C library with a single, process-wide state. Before any API
call you must hand control to that state with gmsh.initialize(), create
a named model with gmsh.model.add(...), and eventually release the
state with gmsh.finalize(). Forgetting any of the three is the single
most common source of Gmsh errors.
apeGmsh wraps this lifecycle behind two calls on a session object, and supports two equivalent usage patterns. Both produce the exact same result — pick whichever fits the situation better.
Pattern A — explicit begin() / end(). Useful in notebooks when
you want to keep the session alive across several cells, in interactive
debugging, or when the session lifetime has to be controlled by
something other than a lexical block (e.g. a class __init__ /
__del__, a Flask request, a test fixture).
from apeGmsh import apeGmsh
g = apeGmsh(model_name="cantilever", verbose=True)
g.begin() # gmsh.initialize() + gmsh.model.add("cantilever") + composite wiring
g.model.geometry.add_box(0, 0, 0, 1, 1, 10, label="beam")
g.mesh.generation.generate(dim=3)
# ... you can keep using g across many cells / function calls ...
g.end() # gmsh.finalize()
With this pattern you own the cleanup. If an exception is raised
between begin() and end(), gmsh.finalize() never runs, and the
next call to gmsh.initialize() anywhere in the process will inherit
a polluted state. In a notebook, the symptom is that a perfectly
correct cell suddenly errors because of a previous cell's crash. Wrap
sensitive sections in try / finally:
g = apeGmsh(model_name="cantilever")
g.begin()
try:
g.model.geometry.add_box(0, 0, 0, 1, 1, 10, label="beam")
g.mesh.generation.generate(dim=3)
finally:
g.end()
Pattern B — context manager (with). The preferred form in scripts
and one-shot notebook cells. Equivalent to Pattern A wrapped in the
try / finally above: __enter__ calls begin(), __exit__ calls
end() no matter how the block exits.
from apeGmsh import apeGmsh
with apeGmsh(model_name="cantilever", verbose=True) as g:
g.model.geometry.add_box(0, 0, 0, 1, 1, 10, label="beam")
g.mesh.generation.generate(dim=3)
# gmsh.finalize() runs here, even if the block raised
Both patterns go through the same begin() implementation, which does
three things in order:
- Calls
gmsh.initialize()andgmsh.model.add(self.name). - Instantiates every composite listed in
apeGmsh._COMPOSITES—model,mesh,physical,mesh_selection,constraints,loads,opensees, and so on — and attaches them as attributes ong. Those composites share a single_metadatadict (entity metadata likekind, cutting-plane normals, etc.) andg.labels(the label system backed by Gmsh physical groups). - Marks the session as active. Any composite method that touches gmsh
asserts
g._activefirst; this is what prevents the "I forgotbegin()" footgun.
end() finalises the Gmsh state and marks the session inactive. After
end() the composites still exist, but calling anything on them will
raise — the Gmsh state they referenced is gone.
Rule of thumb: reach for with unless you have a concrete reason not
to. The explicit form is there for interactive work and for cases
where the session has to outlive a single code block.
A session always uses the OpenCASCADE kernel (gmsh.model.occ). The
built-in kernel (gmsh.model.geo) is intentionally not wrapped: the
meshing, constraints, and FEM layers assume OCC semantics (persistent
entity identity across boolean operations, accurate bounding boxes,
STEP/IGES I/O). Mixing kernels would break the metadata and label tracking.
2. Creating geometry¶
Geometry is created through g.model, which exposes the OCC kernel
behind a thin, self-documenting wrapper. Every add_* method:
- calls the underlying
gmsh.model.occ.add*function, - calls
gmsh.model.occ.synchronize()unlesssync=False, - records entity metadata (
kind) ing.model._metadata, and iflabel=was provided, creates a label PG viag.labels, and - returns the integer tag of the new entity.
You therefore rarely touch gmsh.model.occ directly.
Primitives¶
Solids, surfaces, curves, and points are available:
with apeGmsh(model_name="demo") as g:
# Points (dim = 0)
p1 = g.model.geometry.add_point(0, 0, 0, mesh_size=0.1, label="origin")
# Curves (dim = 1)
p2 = g.model.geometry.add_point(1, 0, 0)
line = g.model.geometry.add_line(p1, p2, label="bottom_edge")
# Surfaces (dim = 2)
plate = g.model.geometry.add_rectangle(0, 0, 0, 1.0, 0.5, label="plate")
# Solids (dim = 3)
box = g.model.geometry.add_box(0, 0, 0, 1, 1, 10, label="beam")
cyl = g.model.geometry.add_cylinder(0.5, 0.5, 0, 0, 0, 10, radius=0.3, label="core")
sph = g.model.geometry.add_sphere(0.5, 0.5, 5, radius=0.2, label="notch")
Two things are worth noting:
-
Labels are the primary addressing mechanism in apeGmsh. The raw integer tags are unstable — boolean operations (fragment, fuse, cut) can split one entity into several new tags. Labels survive all of this automatically. Always label anything you plan to reference later: a boundary condition surface, a constraint partner, a material region. See the Labels subsection below.
-
sync=Trueis the default. Each call synchronises OCC before returning, which is slow for large models. When you are building a batch of entities in a tight loop, passsync=Falseto every call except the last one — the finalsync=Trueflushes the whole batch to the kernel at once.
Sketched geometry¶
For non-primitive shapes (an I-beam cross-section, a gusset plate, a fillet profile) the pattern is the standard Gmsh sketch workflow, just with apeGmsh wrappers:
# 1. Points
pts = [g.model.geometry.add_point(x, y, 0, sync=False) for x, y in coords]
# 2. Lines closing the contour
lns = [g.model.geometry.add_line(pts[i], pts[(i + 1) % len(pts)], sync=False)
for i in range(len(pts))]
# 3. Curve loop -> surface
loop = g.model.geometry.add_curve_loop(lns, sync=False)
face = g.model.geometry.add_plane_surface([loop], label="I_web")
# 4. Extrude to a solid if needed (use gmsh.model.occ.extrude + re-register)
Extrusion, revolution, and transforms are exposed via
g.model.transforms.extrude(...), g.model.transforms.translate(...), g.model.transforms.rotate(...),
and friends on _model_transforms.py. They follow the same
label + registry conventions as the primitives.
Labels — naming geometry for the rest of the pipeline¶
Every add_* call accepts an optional label= parameter. This single
string is the thread that connects geometry creation to meshing,
physical groups, constraints, loads, and solver export. Understanding
how it works is essential.
What happens when you write label="shaft":
- A Gmsh physical group is created behind the scenes with the
name
_label:shaft. This is invisible to the solver and tog.physical— it is purely geometry-time bookkeeping. - That physical group is the single source of truth for label
resolution.
g.labels.entities("shaft")queries it and returns the current entity tags, even if tags were renumbered by a boolean operation.
Labels survive boolean operations. Every fragment, fuse,
cut, and intersect call snapshots all physical groups before the
operation and remaps their entity membership through the OCC result
map afterward. You never need to re-resolve labels manually.
box = g.model.geometry.add_box(0, 0, 0, 1, 1, 3, label="shaft")
# box == 1 (an integer tag)
plane = g.model.geometry.add_axis_cutting_plane('z', offset=1.5)
top, bot = g.model.geometry.cut_by_plane(box, plane)
# box tag 1 is gone — replaced by top=[2] and bot=[3]
# But the label still works:
g.labels.entities("shaft") # -> [2, 3] — both fragments
Labels vs physical groups (two tiers):
Labels (g.labels) |
Physical groups (g.physical) |
|
|---|---|---|
| Purpose | Geometry bookkeeping | Solver-facing naming |
| Created by | label= on add_* methods |
Explicit user call |
| Gmsh name | _label:name (hidden prefix) |
name (no prefix) |
| Visible to solver | No | Yes |
| Survives booleans | Yes | Yes |
Labels are the working names you use during model construction. Physical groups are the names the solver sees. Promote a label to a physical group when you are ready:
# After geometry + booleans are done:
g.labels.promote_to_physical("shaft", pg_name="ColumnShaft")
# or create a PG directly:
g.physical.add_volume([tag1, tag2], name="ColumnShaft")
In multi-part assemblies, labels are prefixed with the instance
name. If a Part has a label "bottom" and you create two instances:
inst_A = g.parts.add(column, label="col_A")
inst_B = g.parts.add(column, label="col_B")
inst_A.labels.bottom # -> "col_A.bottom"
inst_B.labels.bottom # -> "col_B.bottom"
g.labels.entities(inst_A.labels.bottom) # -> tags for A's bottom surface
g.labels.entities(inst_B.labels.bottom) # -> tags for B's bottom surface
Labels travel through STEP round-trips via a JSON sidecar file
(*.step.apegmsh.json) that stores center-of-mass anchors for each
labeled entity. On reimport, entities are matched by geometric
proximity rather than tag numbers (which STEP does not preserve).
Querying with labels and PG names directly. Most query and resolver
methods accept labels and physical-group names anywhere they accept a
tag. g.model.queries.boundary(...) is a representative example: it
resolves a string as a label first (Tier 1, g.labels), then as a
physical-group name (Tier 2, g.physical):
g.model.geometry.add_box(0, 0, 0, 1, 1, 3, label="shaft")
# All three forms work:
faces = g.model.queries.boundary("shaft") # label
faces = g.model.queries.boundary("ColumnShaft") # PG name (after promotion)
faces = g.model.queries.boundary([(3, 1)]) # raw DimTag (discouraged)
This means you almost never need to materialise raw tags by hand.
Build geometry with label=, then pass the label string straight to
boundary, fragment, fuse, cut, intersect, constraint, load, and mass
APIs. The dispatch happens in _resolve_to_dimtags — the same
mechanism the boolean operations use.
3. OCC operations: fragment, fuse, cut, intersect¶
Once you have more than one body in the model you are almost always
going to need a boolean operation. apeGmsh exposes four of them on
g.model, all implemented in core/_model_boolean.py through a single
internal helper _bool_op that:
- resolves integer tags to
DimTags (so you can pass labels or bare tags interchangeably), - calls the matching
gmsh.model.occ.*function, - synchronises,
- cleans up the registry (consumed entities are removed; surviving ones are re-registered), and
- returns a
list[Tag]of the surviving entities at the target dimension.
fuse — union (A ∪ B)¶
g.model.boolean.fuse(objects, tools) merges bodies into one. Use this when
two parts are geometrically the same material and you don't care
about the interface between them — the classic example is gluing a
haunch onto a beam flange.
beam = g.model.geometry.add_box(0, 0, 0, 10, 0.3, 0.5)
haunch = g.model.geometry.add_box(0, 0, 0.5, 2.0, 0.3, 0.3)
merged = g.model.boolean.fuse(beam, haunch) # -> [one volume tag]
After fuse, the interface surface between the two boxes is gone.
You cannot recover it, so you cannot put a physical group, a BC, or a
tie constraint on it. If you need the interface, use fragment
instead.
cut — difference (A − B)¶
g.model.boolean.cut(objects, tools) removes the tool from the object. This
is how you drill bolt holes, carve notches, or subtract a void region
from a soil block.
block = g.model.geometry.add_box(0, 0, 0, 1, 1, 1)
hole = g.model.geometry.add_cylinder(0.5, 0.5, 0, 0, 0, 1, radius=0.1)
block2 = g.model.boolean.cut(block, hole) # block with a through-hole
intersect — intersection (A ∩ B)¶
g.model.boolean.intersect(objects, tools) keeps only the overlap region.
This is useful for clipping one shape to the bounding volume of
another — for example, clipping a soil layer to a site footprint.
fragment — split at intersections (keep everything)¶
g.model.boolean.fragment(objects, tools) is the most important one for FEM
work, and the one that trips people up first.
fragment computes all intersections between objects and tools
and then splits every body at those intersections without throwing
anything away. The result is a collection of sub-volumes (and
sub-surfaces, and sub-curves) that share coincident topology
wherever they touch — no gaps, no overlaps, no duplicated nodes.
This is the geometric precondition for a conformal mesh across a
multi-body model.
soil = g.model.geometry.add_box(-5, -5, -5, 10, 10, 5, label="soil")
footing = g.model.geometry.add_box(-1, -1, -1, 2, 2, 1, label="footing")
# Fragment the soil against the footing. Both bodies survive, but
# the soil now has a matching face where the footing sits.
pieces = g.model.boolean.fragment(soil, footing)
Because fragmenting can multiply entities, raw integer tags become
unreliable after the call. Labels survive automatically: every
boolean operation (fragment, fuse, cut, intersect) preserves
label and physical-group membership by remapping entity tags through
the result map. After a fragment you can keep using
g.labels.entities("soil") and it will return the correct (possibly
expanded) set of tags.
fragment also does a small post-processing step. If the operation
leaves free-floating surface fragments — for example a cutting plane
that extended past the solid — it removes them when
cleanup_free=True (the default). Set it to False if you
deliberately want to keep those surfaces for a boundary condition or
a selection set.
Choosing between fragment and fuse — the conformal question¶
This is the decision you need to make for every multi-body model:
| You want… | Use |
|---|---|
| Two bodies merged into one material region | fuse |
| Two bodies sharing a mesh-conformal interface, still separate | fragment |
| Two bodies remaining independent, tied with constraints | neither |
"Mesh-conformal" means the mesher produces a single set of nodes on
the shared face, so both bodies reference the same DOFs there. Without
fragment, each body would mesh its face independently and you would
end up with two unconnected node clouds that happen to be coincident
in space — a silent, catastrophic modelling error.
The rule of thumb in this project: if two parts touch and should
transmit force through the contact, fragment them. If they should
transmit force through an elastic or rigid link instead, leave them
alone and add a constraint in g.constraints.
4. Worked example: soil block + footing + column¶
End-to-end script showing initialization, geometry, and the three OCC operations working together. The goal is a soil block with a footing embedded in its top and a column rising out of the footing, all prepared for a conformal mesh.
4a. Context-manager form (scripts, one-shot cells)¶
from apeGmsh import apeGmsh
with apeGmsh(model_name="ssi_demo", verbose=True) as g:
# --- Bodies ---------------------------------------------------------
# Soil: 10 x 10 x 5 block with its top at z = 0
soil = g.model.geometry.add_box(-5, -5, -5, 10, 10, 5, label="soil")
# Footing: 2 x 2 x 1 block, top flush with soil surface
footing = g.model.geometry.add_box(-1, -1, -1, 2, 2, 1, label="footing")
# Column stub above the footing (two halves that we will fuse)
col_low = g.model.geometry.add_box(-0.15, -0.15, 0, 0.3, 0.3, 1.0)
col_high = g.model.geometry.add_box(-0.15, -0.15, 1.0, 0.3, 0.3, 2.0)
# --- Fuse: two column segments become one body ---------------------
column = g.model.boolean.fuse(col_low, col_high)
# The interface at z = 1.0 is erased. Good: the column is one
# homogeneous piece of concrete with no artificial seam.
# --- Fragment: make soil + footing + column conformal --------------
# Any order works; fragment is symmetric in objects/tools except
# for the order of surviving tags.
pieces = g.model.boolean.fragment(soil, [footing] + column)
# After this call:
# - soil has a matching face with the footing bottom
# - footing has a matching face with the column base
# - no entity overlaps, no gaps, no free surfaces
# --- Physical groups (for materials + BCs) -------------------------
# Labels still resolve because fragment updated the registry.
g.physical.from_label("soil", name="Soil", dim=3)
g.physical.from_label("footing", name="Footing", dim=3)
g.physical.from_label("column", name="Column", dim=3)
# --- Mesh ----------------------------------------------------------
g.mesh.sizing.set_size_global(0.5)
g.mesh.generation.generate(dim=3)
# At this point the three volumes share nodes across their
# fragment-generated interfaces; OpenSees will see a single
# connected assembly.
4b. Explicit begin() / end() form (notebooks, multi-cell work)¶
The same model, written so the session stays open across what would
typically be several notebook cells. This is the pattern to use when
you want to inspect g.model.queries.registry() between steps, call
g.plot.show() mid-build, or re-run just the meshing cell without
rebuilding the geometry.
from apeGmsh import apeGmsh
# --- Cell 1: open the session --------------------------------------------
g = apeGmsh(model_name="ssi_demo", verbose=True)
g.begin()
try:
# --- Cell 2: build the bodies ----------------------------------------
soil = g.model.geometry.add_box(-5, -5, -5, 10, 10, 5, label="soil")
footing = g.model.geometry.add_box(-1, -1, -1, 2, 2, 1, label="footing")
col_low = g.model.geometry.add_box(-0.15, -0.15, 0, 0.3, 0.3, 1.0)
col_high = g.model.geometry.add_box(-0.15, -0.15, 1.0, 0.3, 0.3, 2.0)
# --- Cell 3: fuse the column halves ---------------------------------
column = g.model.boolean.fuse(col_low, col_high)
# --- Cell 4: fragment the whole assembly ----------------------------
pieces = g.model.boolean.fragment(soil, [footing] + column)
# --- Cell 5: physical groups + mesh ---------------------------------
g.physical.from_label("soil", name="Soil", dim=3)
g.physical.from_label("footing", name="Footing", dim=3)
g.physical.from_label("column", name="Column", dim=3)
g.mesh.sizing.set_size_global(0.5)
g.mesh.generation.generate(dim=3)
finally:
# --- Final cell: release the Gmsh state ------------------------------
g.end()
The try / finally around the body is what makes this pattern safe in
a notebook: if any cell raises, the finally block still runs and
cleans up the Gmsh state, so the next session you open starts from a
clean slate. If you skip the try / finally, remember to run g.end()
manually before re-running the "open the session" cell — otherwise the
second gmsh.initialize() call will fight with the first.
The three OCC operations each play a distinct role here:
fuseon the two column segments is cosmetic — it removes an unnecessary internal face so the column is a single body.fragmenton the full trio is structural — it enforces conformal topology so the mesher can produce a single, connected FEM model.cutandintersectdid not appear, but they would come in if the soil block had a void (say, a buried tunnel:cut(soil, tunnel)) or if the footing had to be trimmed to a site polygon (intersect(footing, site)).
When not to fragment¶
Fragmenting a 5000-entity assembly is expensive. If your bodies are
either (a) truly disjoint in space, or (b) intended to be tied with
equalDOF / rigid links rather than sharing nodes, skip fragment
and handle the coupling in g.constraints instead. The meshing guide
(guide_meshing.md) covers that path.
5. What to read next¶
docs/guide_parts_assembly.md— how to build geometry in isolatedPartsessions and assemble them in a master session viag.parts.add(...)andg.parts.fragment_all().docs/guide_meshing.md— mesh sizing, physical groups,MeshSelectionSet, constraint resolution, and the FEM broker.docs/plan_v2_unified_architecture.md— the v2 architecture the composites are converging on.