Skip to content

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 transformstranslate, 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 sweepsextrude, 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:

imported = g.model.io.load_step("bracket.step")
bodies = imported[3]      # all imported volume tags

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:

from apeGmsh import MshLoader

fem = MshLoader.load("bridge.msh", dim=2)

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.