Geometry & CAD¶
This page explains how geometry gets into an apeGmsh model — authored from primitives, imported from CAD, or generated by transforms — and why all three routes converge on the same thing: named OCC entities in the session's one kernel, ready for meshing.
There are really three doors. You can author geometry with the primitives
under g.model.geometry, you can import it from a CAD file (STEP or IGES),
or you can skip geometry entirely and load an already-meshed .msh file
straight into a FEMData snapshot. The first two land in the same place — live
geometry in the OCC kernel that you name, size, and mesh like anything else.
The third bypasses the geometry stage altogether, and we'll come back to it at
the end. All snippets below assume an open session g.
Authored geometry¶
Authoring is the door you already know from the
mental model: every add_* call takes a label=, and that
label is how the rest of the script refers to the entity. A box authored with
label="body" never needs its integer tag again — you promote the label to a
physical group, target it in loads and constraints, and the name survives every
edit that would have renumbered the tag.
The primitives cover points, lines, arcs, splines, rectangles, boxes, and the rest of the usual vocabulary. What they don't cover is shape complexity — tapered members, curved centerlines, revolved rings. That's what transforms are for.
Transforms: positioning and generating¶
Everything on g.model.transforms falls into one of two families, and keeping
them straight explains most of the API's behavior.
The rigid transforms — translate, rotate, scale, mirror, copy —
reposition or duplicate what already exists. A volume stays a volume. They all
modify entities in place except copy, which is the one that returns new
tags. That single asymmetry drives the fundamental pattern for repetitive
geometry: model the thing once, then copy-and-position each instance. From the
transforms source material, a column grid:
col = g.model.geometry.add_box(0, 0, 0, 0.4, 0.4, 3.5, label="col")
columns = [col]
for i in range(3):
for j in range(3):
if i == 0 and j == 0:
continue
[c] = g.model.transforms.copy(col)
g.model.transforms.translate(c, i * spacing, j * spacing, 0)
columns.append(c)
The same pattern turns a half-model into a full one — copy, then mirror the copies through a plane:
mirrored = g.model.transforms.copy(half_tags)
g.model.transforms.mirror(mirrored, 1, 0, 0, -L/2)
# Original half is at x < L/2, mirror is at x > L/2
If you mirror without copying first, the original moves — the most common
transform mistake, together with passing degrees where rotate and revolve
expect radians.
Because rigid transforms never change an entity's tag, everything attached to the entity travels with it: labels, physical-group membership, mesh sizes. You can label a column, translate it into position, and the label still resolves. This is what makes the copy-and-position workflow compatible with the name-driven style — name things early, move them freely.
The generative sweeps — extrude, revolve, sweep, thru_sections —
create geometry one dimension up: a curve becomes a surface, a surface becomes
a volume. They are the primary way to build 3-D structural volumes from 2-D
profiles, which is why they return the newly created (dim, tag) pairs rather
than self. The extrude return order is worth knowing, because it hands you
exactly the entities a structural model needs without searching:
# Extrude a floor outline into a 250 mm slab
slab_out = g.model.transforms.extrude(floor_surf, 0, 0, 0.25,
num_elements=[4])
top_face = slab_out[0] # (2, tag) — for applying roof loads
slab_vol = slab_out[1] # (3, tag) — for material assignment
revolve does the same around an axis (pipes, ring foundations, tanks),
sweep pushes a profile along an arbitrary wire (curved beams, ramps), and
thru_sections lofts through an ordered stack of section wires (tapered
columns, flared pylons). All four accept structured-layer controls
(num_elements, heights, recombine=True for hex elements), which is how a
generative operation doubles as a structured-meshing instruction.
One consequence of "generative" deserves a sentence: freshly swept entities
have no labels yet. sweep and thru_sections take a label= for the
highest-dimension result; for extrude and revolve you index into the
returned list and name what you need. Either way, the naming discipline is the
same as everywhere else — bake a name in before you move on.
Holes and voids¶
An opening is an ordinary OCC volume (or a dim-2 face) used as the tool of
g.model.boolean.cut — A minus B. That is a different operation from
g.model.geometry.cut_by_surface / cut_by_plane, which split a body and
keep both pieces. There is no Void solid type: mark a tool with as_void=True
(or author it with add_void_sweep / add_void_loft) and subtract it before
you mesh. Leftover void tools fail loud at generate().
g.model.geometry.add_box(0, 0, 0, 4, 2, 3, label="wall")
g.model.geometry.add_cylinder(2, 1, -0.1, 0, 0, 3.2, 0.25,
as_void=True, label="duct")
g.model.boolean.apply_voids("wall") # or cut("wall", "duct")
For a path that turns, build the trajectory (and optionally the profile) with
add_polyline and round selected vertices so OCC's pipe does not kink:
path = g.model.geometry.add_polyline(
[(0, 1, 1), (2, 1, 1), (2, 3, 1)],
fillet={1: 0.2},
)
profile = g.model.geometry.add_polyline(
[(0, 0.9, 0.9), (0, 1.1, 0.9), (0, 1.1, 1.1), (0, 0.9, 1.1)],
closed=True,
)
g.model.geometry.add_void_sweep(profile, path, label="elbow")
g.model.boolean.apply_voids("wall")
Two section polylines loft a tapered void (add_void_loft); give both sections
the same vertex fillet map so their sub-curve counts stay equal. Apply before
generate() — the pre-mesh check will name any tool you forgot to subtract.
Importing CAD: geometry, not a model¶
STEP and IGES are the exchange formats the CAD world speaks, and apeGmsh imports both through the OCC kernel with a single call:
load_iges has the same signature and return shape. By default only the
top-dimensional entities come back (highest_dim_only=True) — a solid model
returns its volumes, not the hundreds of face and edge tags underneath them.
Pass highest_dim_only=False when the next step is tagging a specific face,
though as we'll see below, discovering faces by query is usually the better
move.
The one idea to internalize about CAD import: STEP gives you geometry, not a
model. The format carries no physical groups, no mesh, no loads. After
load_step the imported bodies are ordinary OCC entities, exactly as if you
had authored them — naming, sizing, and meshing are still your job. STEP
import is not a shortcut to a solver-ready model; it is a shortcut to a
solver-ready geometry. (The one exception for names: DXF import maps layers
to physical groups via load_dxf, if your upstream tool can produce it.)
Two follow-ups matter for real files. First, a multi-body STEP assembly
arrives as independent solids — bodies that visually touch do not share
faces, and meshing them as-is produces non-conformal interfaces. Make the
assembly conformal (g.model.queries.make_conformal or
g.model.boolean.fragment) before meshing if you need shared nodes across the
interface. Second, files from the wild are rarely clean — which brings us to
healing.
Heal and diagnose¶
Legacy CAD exporters produce faces that don't quite meet, edges shorter than
the meshing tolerance, and shells that should have been solids. Left alone,
these surface later as cryptic meshing failures. apeGmsh's stance is look
before you leap: diagnose() inspects the imported geometry without touching
it, and healing is an explicit, opt-in step.
g.model.io.load_step("messy.step") # raw — advisory may fire
report = g.model.io.diagnose() # -> ImportHealth (non-mutating)
if report.is_suspect: # True iff slivers present
# re-import with the suggested scale-aware healing
imported = g.model.io.load_step("messy.step", heal="auto", dedupe=True)
ImportHealth reports what's in the model (entity counts per dimension, the
bounding-box diagonal) and what's suspicious (edges and faces far below the
model scale). is_suspect keys off slivers only — a surface-only import is
not flagged, because shell models import that way on purpose. On a raw import
apeGmsh runs this scan for you and emits a WarnGeomImportHealth advisory
when the result looks bad, so problems announce themselves at import time
rather than at mesh time.
Healing itself is heal="auto" on the load call, and the "auto" matters: a
fixed absolute tolerance is meaningless across unit systems (a 1e-3 mm gap
and a 1e-3 m gap differ by a thousand), so apeGmsh derives the tolerance
from the loaded geometry's bounding-box diagonal. A float pins an absolute
tolerance instead, and g.model.io.heal_shapes(...) exposes the full knob set
(sew faces, fix small edges, promote shells to solids) when the one-shot form
isn't enough. dedupe=True merges coincident entities that STEP assemblies
often repeat across bodies. The step-by-step version of this workflow is the
Import & heal a STEP file recipe.
Aggressive healing has a failure mode: a tolerance larger than the smallest genuine feature will merge features you wanted to keep. Verify visually after healing anything with a tolerance you chose yourself.
Naming what you imported¶
Imported entities arrive without labels — the CAD file had none to give. So how does an imported model join the name-driven workflow? By query: you describe entities geometrically and bake the result into a name. The bounding-box query is the workhorse from the import source material:
g.physical.add(3, bodies, name="Steel")
base_faces = [t for (d, t) in g.model.queries.entities_in_bounding_box(
-1e3, -1e3, -1e-3, 1e3, 1e3, 1e-3, dim=2)]
g.physical.add(2, base_faces, name="Fixed_Support")
The fluent .select() chain from the mental model does the
same job with spatial verbs (.on_plane, .in_box, .nearest_to) and a
terminal that bakes the name. Either way, the moment of naming is the seam
where imported geometry stops being foreign: after "Steel" and
"Fixed_Support" exist, the rest of the script — sizing, meshing, loads,
supports, the bridge — is identical for imported and authored geometry. The
plate-with-hole example runs this
CAD-to-solve path end to end.
The reverse direction exists too: g.model.io.save_step(...) writes the
current geometry back out, useful for returning a healed and
boolean-cleaned part to a CAD tool. Like the import, the export carries
geometry only — no mesh, no physical groups.
Already-meshed files skip geometry¶
The third door is .msh, Gmsh's native format, and it plays by different
rules: a .msh file already contains nodes, elements, and physical groups, so
there is nothing left to name or mesh. apeGmsh treats it as a direct path to
the FEMData snapshot:
No session, no geometry stage — the returned snapshot is completely decoupled
from Gmsh, ready for the solver bridge or for pickling into a pipeline. (When
you do want a live session around the imported mesh — to plot it, inspect
its groups, or re-mesh parts — g.model.io.load_msh merges the file into an
open session instead.) The dim argument selects which element dimension
lands in the snapshot, because FEMData holds elements of one dimension only;
a mixed shell-and-solid file is loaded once per dimension.
A useful summary of all three doors: authored and STEP geometry are mutable
— still subject to transforms, booleans, naming, and meshing inside the
session — while a .msh hands you a finished discretization whose only open
question is which solver reads it. If you can choose, choose geometry: it
keeps every downstream decision (mesh density, element type, group boundaries)
in your script, where it can be re-run and revised.
Next: Meshing.