Parts & assembly internals¶
This page explains the machinery behind the Part/Instance split — why it
exists, how names survive the trip through a STEP file, and how the registry
keeps its bookkeeping honest through OCC booleans — so you can predict what
g.parts will do and know where to change it.
Parts & assembly covers the same surface
from the user's side; this page is about what's underneath.
One live session, therefore a file¶
Everything here follows from a single constraint: Gmsh keeps one process-global OCC kernel. You can switch named models within a session, but every boolean, transform, and import mutates the same singleton, so a Part and an assembly cannot coexist in memory as independent geometries. Something has to be serialized, and apeGmsh serializes the Part: its canonical form is a STEP file on disk (plus a JSON sidecar for labels), not a Python object. The alternative — juggling multiple Gmsh models inside one session — would be faster, but a Part could then never outlive the session, and the whole point of a template is that you author it once and reuse it next week. The cost of the file-based route is that a Part must open and close a full Gmsh session of its own, and that the label pipeline has to survive a round trip through a format that has no idea what an apeGmsh label is. The rest of this page is the price being paid, deliberately.
The implementation lives in four modules under src/apeGmsh/core/:
Part.py (the isolated geometry session), _parts_registry.py
(PartsRegistry and the Instance record — the assembly side),
_parts_fragmentation.py (the fragment/fuse mixin), and _part_anchors.py
(sidecar IO and rebinding). There is no Assembly class — the apeGmsh
session is the assembly, and g.parts is what makes it behave like one.
The Part session¶
Part extends the same _SessionBase as apeGmsh but declares a
deliberately restricted composite set: model, labels, physical,
inspect, plot, and edit — no mesh, no solver composites. That
allow-list (Part._COMPOSITES) is the enforcement of the invariant that a
Part is geometry only; adding a mesh composite there would quietly break the
Part-as-CAD-artifact model, which is why contributor rule number one is
don't.
The lifecycle is built around auto-persist. If the user exits the with
block without calling save(), end() writes the geometry to a STEP file
in a fresh temp directory (apeGmsh_part_{name}_*/{name}.step) before
finalizing Gmsh — the ordering matters, because writing needs a live kernel,
and it must happen even when the user's build code raised (the export
attempt runs first, the user's exception still propagates). Auto-persist
failures are themselves caught and warned rather than raised: they must
never mask the build error underneath.
File deletion is governed by one bit, _owns_file. It is true only when the
library wrote the file into a temp directory it created; it is never true
after an explicit save(), no matter what happened before. Cleanup runs
through a weakref.finalize that closes over the temp-dir path — not over
self, which would pin the Part alive — so the tempfile disappears on
garbage collection or on an explicit cleanup(). Calling save() after an
earlier auto-persist reclaims the temp directory first and then hands
ownership of the new file to the caller. Re-entering a Part (begin() on an
object that owns a stale tempfile) cleans up before the new session starts.
The rule all of this serves: the library never deletes a file the user
named. For a CAD tool that is the worst-case bug class, and every new code
path that might remove a file must gate on _owns_file.
The sidecar¶
STEP cannot carry apeGmsh label strings — Gmsh writes through OCC's plain
STEP writer, which doesn't expose XDE naming, and third-party readers drop
it anyway — so labels travel in a JSON sidecar written next to the CAD file
({name}.step.apegmsh.json). Inside a Part, any geometry call with
label= auto-creates a Tier 1 label PG (the Part sets
_auto_pg_from_label = True precisely so labels exist to be exported). At
save time, collect_anchors walks those label PGs and records one anchor
per entity: the label name, the entity's dimension, its center of mass in
Part-local coordinates, and its bounding box. Only Tier 1 labels are
captured — solver-facing PGs are the user's business in the assembly and
don't travel through files. No named entities, no sidecar.
At import time the anchors are re-anchored geometrically. _import_cad
applies the same rotate-then-translate placement to each stored COM (and to
the eight corners of each stored bbox) that it applied to the geometry, then
matches transformed anchors against imported entities of the same dimension.
Matching is greedy and exclusive: every (anchor, candidate) pair within
tolerance is scored by COM distance with bbox distance as the tiebreaker,
the pairs are sorted best-first, and each entity can be claimed once. The
tolerance is scale-aware — a fraction (1e-4) of the imported geometry's
bounding-box diagonal — so the same code works in millimetres and metres.
The bbox tiebreaker is what disambiguates symmetric Parts, where two faces
can be equidistant from a stored COM.
One subtlety: imports default to highest_dim_only=True, but a sidecar can
carry anchors at any dimension (a face label on a solid). Rebinding
therefore matches against all entities in the model, re-enumerated per
dim, not just the dimtags importShapes returned — otherwise every
sub-dimension anchor would silently miss.
Failure policy is uniform across the whole pipeline: sidecar write failures, unreadable sidecars, unmatched anchors, and label-PG creation failures all warn and continue. A broken sidecar degrades to "labels don't round-trip", which is recoverable; a failed import or a failed CAD export is not, so nothing on the label path is allowed to raise.
The registry¶
Instance is a small record dataclass: label, source part name, file path,
an {dim: [tags]} entity map, the placement transforms, user properties,
a bounding box, and the list of prefixed label names created for it. Two
fields deserve attention. entities is mutable by design — fragmentation
rewrites it in place (next section), because user code holds references to
Instance objects and replacing the record would silently detach them.
labels is the _InstanceLabels helper: a slotted wrapper whose
__getattr__ prepends "{instance_label}." and checks the result against
label_names, so inst.labels.web returns the string "col.web", a typo
raises AttributeError listing what actually exists, and __dir__ feeds
the stripped names to IDE autocomplete. The user never types a raw prefixed
string — which also means a typo can never silently resolve to an empty
selection.
Every way of creating an instance — the part() context manager (a
before/after diff of gmsh.model.getEntities per dim), register() (by
explicit dimtags, or by adopting an existing label or physical group, with
an ownership check that rejects entities already claimed by another part),
from_model() (adopt everything untracked), add() (the canonical
Part-import path), and import_step() (third-party CAD, with optional
healing and deduplication) — funnels through one private method,
_register_instance. That funnel is where every instance gets its
inst.edit composite wired up, and where the chain-phase guard fires:
parts registration is build-phase only, because the chain-phase broker
carries its own immutable parts snapshot (ADR 0038). The parametric box
builders (add_plane_wave_box, the DRM boxes — ADR 0054, ADR 0066) build
directly in the live session with no STEP round-trip, but their instances
go through the same funnel.
add() and import_step() share _import_cad, which does the work in
order: import the file, deduplicate the returned dimtags (OCC reports
shared sub-entities once per parent), apply rotate then translate to the
highest-dim entities only — OCC propagates transforms through
sub-topology, and transforming every dim double-transforms the sub-shapes —
then rebind the sidecar, create one prefixed label PG per matched anchor,
and finally create the umbrella label (the instance's own name, covering
its top-dim entities). Prefixing is what makes two instances of one Part
disjoint by construction: the registry rejects duplicate instance labels on
every entry path, and every sidecar label lands as a fresh
{instance}.{name} PG. The registry never creates Tier 2 physical groups —
promotion is the user's explicit act, and any new feature that "helps" by
creating solver-facing PGs is bleeding internal naming into solver output.
delete() is the deliberately asymmetric operation: it drops the registry
record but leaves the geometry in the session, where it becomes untracked —
visible in the viewer and, importantly, a warning source at fragment time.
Fragmentation bookkeeping¶
The three boolean operations live in _PartsFragmentationMixin — the one
sanctioned mixin in the codebase, justified because fragment/fuse is a
closed three-method surface that shares every registry field. Each wraps
its OCC call in pg_preserved(), the snapshot-then-remap context that keeps
labels and physical groups alive through OCC's renumbering; what this
section adds is the registry-side remap that runs inside the same block.
OCC booleans return a result_map: for each input dimtag, the list of
output dimtags that replaced it. _remap_from_result turns that into a
per-dim old_tag → [new_tags] table — matching inputs only to outputs at
the same dimension, so a surface entry can't absorb a new volume — and
flattens every instance's entities[dim] through it in place. One old tag
can expand to several new tags when the fragment split it; tags no instance
owns are ignored. Fuse has a wrinkle: OCC often returns empty result maps
for fused inputs even though a merged entity exists, so with
absorbed_into_result=True an input with an empty map remaps to the
surviving same-dim result entities rather than being silently emptied.
fragment_all auto-detects every dimension present (a 2D shell standing on
a 3D solid joins the same OCC call as the volume, so it becomes conformal
instead of being dropped) and warns — with a dedicated
UnregisteredPartEntityWarning — about entities no instance tracks: they
participate in the boolean but cannot be remapped, so the user sees the
drift coming instead of discovering it later. fragment_pair is the local
variant: only the two named instances' entities enter the OCC call, mixed
dimensions included, and their records are remapped through the same
machinery. fuse_group removes the input instances from the registry
entirely and registers one new instance over the fused result (named after
the first input unless overridden) — the one operation where instances die.
Both fragment variants also reap stale per-entity metadata for inputs OCC
consumed, so a later pre-mesh validation doesn't trip over keys that no
longer exist.
After the mesh: bbox partition¶
The sidecar's anchors are spent once parts.add() returns, and the entity
maps are geometry-time bookkeeping — neither survives into solver land.
What carries instance identity across the mesh boundary is spatial:
build_node_map partitions mesh nodes by each instance's axis-aligned
bounding box, vectorized in numpy with a tolerance of 1e-6 times the box
span to absorb OCC float jitter without letting neighbours bleed into each
other. build_face_map then assigns a surface element to an instance iff
all its nodes belong to it. FEMData.from_gmsh snapshots the node map into
the broker, which is why fem.nodes.select(target="col_A") still works after
the Gmsh session is gone. The umbrella and prefixed labels take the other
route — they are ordinary Tier 1 names, promoted and persisted like any
label. Bbox containment is deliberately crude: it is exact for the
disjoint-parts case it serves, and anything finer belongs to labels, which
are exact by construction.
Next: Results internals.