Adaptive mesh refinement (AMR)
This page documents the internals of the AMR implementation. If you landed here looking for how to use AMR, see the AMR topic guide for the concepts (hanging nodes, balancing, error estimation) and the AMR reference for the public API — the three pages are complementary.
P4est
Ferrite's P4est implementation is based on these papers:
where the basic data structures are implemented from the first paper combined with the algorithms to materialize a grid from the second paper in the serial case.
Important concepts
One of the most important concepts, which everything is based on, are space filling curves (SFC). In particular, Z-order (also named Morton order, Morton space-filling curves) are used in p4est. The basic idea is that each Octant (in 3D) or quadrant (in 2D) can be encoded by 2 quantities
- the level
l - the lower left (front) coordinates
xyz
Based on them a unique identifier, the morton index, can be computed. The mapping from (l, xyz) to the morton index is bijective, meaning we can flip the approach and construct each octant/quadrant solely from the morton index and a given level l.
The current implementation of an octant looks like this:
struct OctantBWG{dim, N, T <: Integer} <: AbstractCell{RefHypercube{dim}} #Refinement level l::T #x,y,z \in {0,...,2^b} where (0 ≤ l ≤ b) xyz::NTuple{dim, T}endwhenever coordinates are considered we follow the z order logic, meaning x before y before z. Note that the acronym BWG stands for the initials of the surname of the authors of the p4est paper. The coordinates of an octant are described in the octree coordinate system which goes from $[0,2^b]^{dim}$. The parameter $b$ describes the maximum level of refinement and is set a priori. Another important aspect of the octree coordinate system is, that it is a discrete integer coordinate system, which has the advantage over float point based coordinate systems when matching coordinates that comparisons are guaranteed to be exact by construction. The size of an octant at the finest possible level b is always 1, sometimes these octants are called atoms.
The octree is implemented as:
struct OctreeBWG{dim, N, T <: Integer} <: AbstractCell{RefHypercube{dim}} leaves::Vector{OctantBWG{dim, N, T}} #maximum refinement level b::T nodes::NTuple{N, Int}endSo, only the leaves of the tree are stored and not any intermediate refinement level. The field b is the maximum refinement level and is crucial. This parameter determines the size of the octree coordinate system. The octree coordinate system is the coordinate system in which the coordinates xyz of any octant::OctantBWG are described.
Examples
Let's say the maximum octree level is $b=3$, then the coordinate system is in 2D $[0,2^3]^2 = [0, 8]^2$. So, our root is on level 0 of size 8 and has the lower left coordinates (0,0)
# different constructors available, first one OctantBWG(dim,level,mortonid,maximumlevel)# other possibility by giving directly level and a tuple of coordinates OctantBWG(level,(x,y))julia > dim = 2; level = 0; maximumlevel = 3julia > oct = Ferrite.AMR.OctantBWG(dim, level, 1, maximumlevel)OctantBWG{2, 4, Int64}l = 0xy = 0, 0The size of octants at a specific level can be computed by a simple operation
julia > Ferrite.AMR._compute_size(#=b=# 3, #=l=# 0)8This computation is based on the relation $\text{size}=2^{b-l}$. Now, to fully understand the octree coordinate system we go a level down, i.e. we cut the space in $x$ and $y$ in half. This means, that the octants are now of size $2^{3-1}=4$. Construct all level 1 octants based on mortonid:
# note the arguments are dim,level,mortonid,maximumleveljulia > dim = 2; level = 1; maximumlevel = 3julia > oct = Ferrite.AMR.OctantBWG(dim, level, 1, maximumlevel)OctantBWG{2, 4, Int64}l = 1xy = 0, 0julia > oct = Ferrite.AMR.OctantBWG(dim, level, 2, maximumlevel)OctantBWG{2, 4, Int64}l = 1xy = 4, 0julia > oct = Ferrite.AMR.OctantBWG(dim, level, 3, maximumlevel)OctantBWG{2, 4, Int64}l = 1xy = 0, 4julia > oct = Ferrite.AMR.OctantBWG(dim, level, 4, maximumlevel)OctantBWG{2, 4, Int64}l = 1xy = 4, 4So, the morton index is on one specific level just an x before y before z "cell" or "element" identifier
x-----------x-----------x
| | |
| | |
| 3 | 4 |
| | |
| | |
x-----------x-----------x
| | |
| | |
| 1 | 2 |
| | |
| | |
x-----------x-----------xThe operation to compute octants/quadrants is cheap, since it is just bitshifting. An important aspect of the morton index is that it's only consecutive on one level in this specific implementation. Note that other implementations exist that incorporate the level integer within the morton identifier and thereby have a unique identifier across levels. If you have a tree like this below:
x-----------x-----------x
| | |
| | |
| 9 | 10 |
| | |
| | |
x-----x--x--x-----------x
| |6 |7 | |
| 3 x--x--x |
| |4 |5 | |
x-----x--x--x 8 |
| | | |
| 1 | 2 | |
x-----x-----x-----------xyou would maybe think this is the morton index, but strictly speaking it is not. What we see above is just the leafindex, i.e. the index where you find this leaf in the leaves array of OctreeBWG. Let's try to construct the lower right based on the morton index on level 1
julia> o = Ferrite.AMR.OctantBWG(2,1,8,3)ERROR: AssertionError: m ≤ (one(T1) + one(T1)) ^ (dim * l)Stacktrace: [1] OctantBWG(dim::Int64, l::Int64, m::Int64, b::Int64) @ Ferrite.AMR Ferrite.jl/src/Adaptivity/octree.jl:47 [2] top-level scope @ REPL[1]:1The assertion expresses that it is not possible to construct a morton index 8 octant, since the upper bound of the morton index is 4 on level 1. The morton index of the lower right cell is 2 on level 1.
julia > o = Ferrite.AMR.OctantBWG(2, 1, 2, 3)OctantBWG{2, 4, Int64}l = 1xy = 4, 0Octant operations
There are multiple useful functions to compute information about an octant e.g. parent, children, etc.
Ferrite.AMR.isancestor — Function
isancestor(o1, o2, b) -> BoolIs o1 a strict ancestor of o2, i.e. is o2 a descendant of o1? Walks o2's parent chain up to the root, which is itself a valid ancestor (parent of the root is the root, hence the level-0 stop instead of a levels-remaining counter).
Ferrite.AMR.morton — Function
From Burstedde et al. [15];
The octant coordinates are stored as integers of a fixed number b of bits, where the highest (leftmost) bit represents the first vertical level of the octree (counting the root as level zero), the second highest bit the second level of the octree, and so on.
A morton index can thus be constructed by interleaving the integer bits (2D): $m(\text{Oct}) := (y_b,x_b,y_{b-1},x_{b-1},...y_0,x_0)_2$ further we assume the following
Due to the two-complement representation of integers in practically all current hardware, where the highest digit denotes the negated appropriate power of two, bitwise operations as used, for example, in Algorithm 1 yield the correct result even for negative coordinates.
also from Burstedde et al. [15]
Ferrite.AMR.children — Function
children(octant::OctantBWG{dim, N, T}, b::Integer) -> NTuple{N, OctantBWG}Compute the N = 2^dim children of octant, returned in z-order (x before y before z).
The first child's vertices of octant are utilized. Its 2^dim vertices coincide exactly with the anchors (lower-left corners) of all children, so each child is simply the level-l+1 octant placed at the corresponding vertex of family.
In 2D, with parent anchor ⊙ at (x,y) and edge length H = 2h:
(x,y+H) +───────┬───────+ (x+H,y+H) │ c3 │ c4 │ family = first child (c1), anchored at (x,y),(x,y+h) ├───────┼───────┤ edge length h. Its four vertices │ c1 │ c2 │ v1=(x, y ) v2=(x+h, y ) (x,y) ⊙───────┴───────+ (x+H,y) v3=(x, y+h) v4=(x+h, y+h) (x+h,y) are exactly the anchors of c1..c4.Ferrite.AMR.vertices — Function
vertices(octant::OctantBWG{dim}, b::Integer)Computes all vertices of a given octant. Each vertex is encoded within the octree coordinates i.e. by integers.
Ferrite.AMR.edges — Function
edges(octant::OctantBWG{dim}, b::Integer)Computes all edges of a given octant. Each edge is encoded within the octree coordinates i.e. by integers. Further, each edge consists of two three-dimensional integer coordinates.
Ferrite.AMR.faces — Function
faces(octant::OctantBWG{dim}, b::Integer)Computes all faces of a given octant. Each face is encoded within the octree coordinates i.e. by integers. Further, each face consists of either two two-dimensional integer coordinates or four three-dimensional integer coordinates.
Intraoctree operations
Intraoctree operations stay within one octree and compute octants that are attached in some way to a pivot octant o. These operations are useful to collect unique entities within a single octree or to compute possible neighbors of o. Burstedde et al. [15] Algorithm 5, 6, and 7 describe the following intraoctree operations:
Ferrite.AMR.corner_neighbor — Function
corner_neighbor(octant::OctantBWG, c::Integer, b::Integer)Computes the corner neighbor octant which is only connected by the corner c to octant
Ferrite.AMR.edge_neighbor — Function
edge_neighbor(octant::OctantBWG, e::Integer, b::Integer)Computes the edge neighbor octant which is only connected by the edge e to octant.
Ferrite.AMR.facet_neighbor — Function
facet_neighbor(octant::OctantBWG{dim, N, T}, f::T, b::T = DEFAULT_MAXLEVEL[dim]) -> OctantBWG{dim, N, T}Intraoctree face neighbor for a given faceindex f (in p4est, i.e. z order convention) and specified maximum refinement level b. Implements Algorithm 5 of Burstedde et al. [15].
x-------x-------x| | || 3 | 4 || | |x-------x-------x| | |o 1 * 2 || | |x-------x-------xConsider octant 1 at xyz=(0,0), a maximum refinement level of 1 and faceindex 2 (marked as *). Then, the computed face neighbor will be octant 2 with xyz=(1,0). Note that the function is not sensitive in terms of leaving the octree boundaries. For the above example, a query for face index 1 (marked as o) will return an octant outside of the octree with xyz=(-1,0).
Ferrite.AMR.possibleneighbors — Function
possibleneighbors(o::OctantBWG{2}, l, b)Returns a tuple of possible neighbors, where the first four are corner neighbors that are exclusively connected via a corner. The other four possible neighbors are face neighbors. Always returns the full NTuple (type-stable); callers that want only in-tree neighbours filter with inside.
possibleneighbors(o::OctantBWG{3}, l, b)Returns a tuple of possible neighbors, where the first eight are corner neighbors that are exclusively connected via a corner. After the first eight corner neighbors, the 6 possible face neighbors follow and after them, the edge neighbors. Always returns the full NTuple (type-stable); callers that want only in-tree neighbours filter with inside.
Interoctree operations
Interoctree operations, in contrast to intraoctree operations, compute octant transformations across different octrees. Thereby, one needs to account for topological connections between the octrees as well as possible rotations of the octrees. Burstedde et al. [15] Algorithm 8, 10, and 12 explain the algorithms that are implemented in the following functions:
Ferrite.AMR.transform_corner — Function
transform_corner(forest, k, c, oct, inside::Bool)
transform_corner(forest, v::VertexIndex, oct, inside::Bool)Algorithm 12 but with flipped logic in Burstedde et al. [15] to transform corner into different octree coordinate system Implements flipped logic in the sense of pushing the Octant oct through vertex v and stays within octree coordinate system k.
c is the corner of tree k (in BWG corner numbering) at the shared vertex; oct is placed at that corner of k, inside the root (inside = true) or diagonally outside of it (inside = false). A corner octant is fully determined by the corner index and the level, so no connectivity lookup is needed — in particular the corner must not be re-derived from vertex_vertex_neighbor[k, ...][1], which is ambiguous (and wrong) as soon as more than two trees meet at the vertex.
Ferrite.AMR.transform_edge — Function
transform_edge(forest, k, e, k′, e′, oct, inside::Bool)
transform_edge(forest, k′, e′, oct, inside::Bool)
transform_edge(forest, e′::EdgeIndex, oct, inside::Bool)Algorithm 10 in Burstedde et al. [15] to transform an edge into a different octree coordinate system, but with reversed logic. See transform_edge_remote with logic from paper. Transform the octant oct, which sits at edge e of pivot tree k (in k's coordinates), into the coordinate system of the neighbouring tree k′ at its edge e′. The along-edge coordinate is taken from the pivot's edge axis and mirrored iff trees k and k′ traverse the shared macro edge in opposite directions.
Both the pivot pair (k, e) and the target pair (k′, e′) must be passed explicitly: a macro edge can be shared by more than two trees, so neither the pivot nor the relative orientation can be re-derived from edge_edge_neighbor[..][1] lookups (those pick an arbitrary incident tree). The two shorter forms assume exactly two trees at the macro edge and take edge_edge_neighbor[k′, e′][1] as the pivot.
Ferrite.AMR.transform_facet — Function
transform_facet(forest::ForestBWG, k', f', o::OctantBWG) -> OctantBWG
transform_facet(forest::ForestBWG, f'::FacetIndex, o::OctantBWG) -> OctantBWGInteroctree coordinate transformation of an given octant o that lies outside of the pivot octree k, namely in neighbor octree k'. However, the coordinate of o is given in octree coordinates of k. Thus, this algorithm implements the transformation of the octree coordinates of o into the octree coordinates of k'. Useful in order to check whether or not a possible neighbor exists in a neighboring octree. Implements Algorithm 8 of Burstedde et al. [15].
x-------x-------x| | || 3 | 4 || | |x-------x-------x| | || 1 * 2 || | |x-------x-------xConsider 4 octrees with a single leaf each and a maximum refinement level of 1 This function transforms octant 1 into the coordinate system of octant 2 by specifying k=1 and f=2. While from the perspective of octree coordinates k=2 octant 1 is at xyz=(-2,0), the returned and transformed octant is located at xyz=(0,0)
Note that, compared to the algorithms proposed in the paper, we flipped the input and output logic a bit. However, the original proposed versions are implemented as well in:
Ferrite.AMR.transform_corner_remote — Function
transform_corner_remote(forest, k, c, oct, inside::Bool)
transform_corner_remote(forest, v::VertexIndex, oct, inside::Bool)Algorithm 12 in Burstedde et al. [15] to transform corner into different octree coordinate system. Follows exactly the version of the paper by taking oct and looking from the neighbor octree coordinate system (neighboring to k,v) at oct.
Ferrite.AMR.transform_edge_remote — Function
transform_edge_remote(forest, k, e, oct, inside::Bool)
transform_edge_remote(forest, e::EdgeIndex, oct, inside::Bool)Algorithm 10 in Burstedde et al. [15] to transform edge into different octree coordinate system. This function looks at the octant from the octree coordinate system of the neighbor that can be found at (k,e)
Ferrite.AMR.transform_facet_remote — Function
transform_facet_remote(forest::ForestBWG, k::T1, f::T1, o::OctantBWG{dim, N, T2}) -> OctantBWG{dim, N, T2}
transform_facet_remote(forest::ForestBWG, f::FacetIndex, o::OctantBWG{dim, N, T2}) -> OctantBWG{dim, N, T2}Interoctree coordinate transformation of an given octant o to the face-neighboring of octree k by virtually pushing os coordinate system through ks face f. Implements Algorithm 8 of Burstedde et al. [15].
x-------x-------x| | || 3 | 4 || | |x-------x-------x| | || 1 * 2 || | |x-------x-------xConsider 4 octrees with a single leaf each and a maximum refinement level of 1 This function transforms octant 1 into the coordinate system of octant 2 by specifying k=2 and f=1. While in the own octree coordinate system octant 1 is at xyz=(0,0), the returned and transformed octant is located at xyz=(-2,0)
although they are currently only used in the test suite.
Refinement and coarsening
Refinement replaces a leaf by its 2^dim children; coarsening replaces a 2^dim-sibling family by its parent. Both operate on each tree's Morton-sorted leaves vector and preserve that order, which the rest of the pipeline (balancing, the point iterator) relies on.
The production entry point is refine!(forest, cellids): an adaptive FE step marks cells with an error estimator and passes their global ids here. It is implemented to scale linearly in the number of leaves — the marked ids are mapped to per-tree local indices and each tree's leaf list is rebuilt in a single pass, rather than refining cells one at a time (every in-place insert! would memmove the array tail, giving O(n^2)). refine_all! is the uniform-refinement convenience wrapper and is linear for the same reason. The single-octant primitives underlying all of these are refine_octant! and coarsen_octant!.
Ferrite.AMR.refine_octant! — Function
refine_octant!(octree::OctreeBWG, pivot_octant::OctantBWG)Internal, octree-level refinement primitive; the user-facing entry point is refine!(forest, cellids).
Replace the leaf pivot_octant in octree.leaves by its 2^dim children, spliced in z-order into the parent's slot so the Morton order of leaves is preserved. A no-op if pivot_octant is already at the tree's maximum level octree.b.
pivot_octant is located with a searchsortedfirst binary search, which requires octree.leaves to be Morton-sorted (the Base.isless total order) — the standard invariant for a BWG octree. A single call is O(n) because of the in-place insert!; to refine many leaves at once prefer refine_all! or the refine!(forest, cellids) vector method, which rebuild the leaf list in one linear pass instead of n shifts.
Ferrite.AMR.coarsen_octant! — Function
coarsen_octant!(octree::OctreeBWG, o::OctantBWG)Internal, octree-level coarsening primitive; the user-facing entry point is coarsen!(forest, cellids).
Replace the 2^dim-sibling family that o belongs to with their common parent in the tree's Morton-sorted leaves. o is snapped back to the family's first sibling (via its child_id/morton), the parent is written in its slot, and the remaining 2^dim - 1 siblings are deleted — the inverse of refine_octant!. Assumes the whole family is present and at the same level (e.g. after balanceforest!).
Ferrite.AMR._coarsen_all! — Function
_coarsen_all!(forest::ForestBWG)Internal convenience for tests and development — not part of the public API.
Coarsen every 2^dim-sibling family in forest by one level — the inverse of refine_all!. Each leaf that is a first sibling (child_id == 1) is replaced by its parent via coarsen!.
This assumes every first sibling has its complete same-level family present, which holds for a uniformly refined forest but not for an arbitrary adaptively refined one. Calling it on a forest with incomplete families violates coarsen!'s precondition and corrupts the leaf list. For selective derefinement, coarsen individual complete families with coarsen! instead.
Balancing
Before a forest can be materialised into a grid it must satisfy the 2:1 balance condition: no two leaves sharing a face, edge or corner may differ by more than one refinement level. This is what guarantees that hanging nodes only ever appear at edge midpoints / face centers (see Hanging nodes below). balanceforest! enforces it, balancing each tree internally and propagating across tree boundaries for the leaves that touch them.
Ferrite.AMR._balance_leaf! — Function
_balance_leaf!(forest, k, tree, o, perm_face, perm_face_inv, perm_corner, perm_corner_inv, rootfaces, rootedges, rootvertices, facet_neighborhood)Per-leaf kernel of balanceforest! handling the inter-tree part of the 2:1 balance.
Operates on a single "pivot" leaf o of tree k. In-tree balancing is already taken care of by balancetree; this function only propagates balance across tree boundaries. It walks the possibleneighbors of o, keeps those lying outside the current tree (reachable only through a corner/face/edge connection to another tree), decodes the neighbour type from the possibleneighbors index s_i, maps the pivot's local index into the neighbour tree via the permutation tables, and calls balance_face/balance_corner/balance_edge to refine the neighbour tree where the balance condition requires it.
Ferrite.AMR._touches_tree_boundary — Function
_touches_tree_boundary(o::OctantBWG{dim}, b) -> Booltrue iff octant o has a face on its tree's boundary (some axis anchor at 0 or at the root extent 2^b). Only such leaves can have out-of-tree neighbours, so balanceforest! uses this to skip the interior leaves (the majority) when balancing across tree interfaces.
Ferrite.AMR.inside — Function
inside(oct::OctantBWG{dim}, b) -> BoolWhether oct lies within its tree's root domain [0, 2^b)^dim (see _maximum_size). A false means the octant has crossed a tree boundary — the signal that an inter-tree transform (transform_facet/transform_corner) is needed to express it in the neighbouring tree's coordinate system.
Ferrite.AMR._maximum_size — Function
_maximum_size(b::Integer) -> IntInteger extent 2^b of the root octant along each axis (computed as 1 << b): the octree coordinate domain of a tree is [0, 2^b)^dim. Used by inside to detect when an octant leaves its tree.
From a forest to a NonConformingGrid
The operations above manipulate the forest of octrees (refine, coarsen, balance, neighbour lookups). To actually solve a finite element problem we must turn that forest into a concrete grid — this is creategrid, which produces a NonConformingGrid: an ordinary grid plus the hanging-node constraints (conformity_info) that make a conforming finite element field possible.
conformity_info currently stores hanging vertices and their master vertices — exactly the information a linear (Q1) discretization needs, and no more. For general discretizations the non-conforming interface itself must be exposed: hanging edges and faces need to be detected and stored as entities, so that a field of any order can constrain all of its dofs on such an entity (with weights obtained by evaluating the coarse side's basis). Expect the layout of this field to change when support for higher-order discretizations lands.
Two ideas carry the whole construction:
- Integer / topological identity, no global node map. Every node is identified integer / topologically — a corner of the integer octree lattice of one tree — never by a floating-point physical position, so shared nodes are recognised exactly, with no tolerances. Physical coordinates are interpolated only once per node. There is no coordinate→id map of any kind, because the traversal below discovers every mesh entity exactly once: identity is established by construction, so "assign an id" degenerates to a counter increment. Node ids are assigned by the iterator callbacks and scattered into the element-node matrix
E(Isaac et al. [16] §6,Lnodes), and only tree-boundary nodes additionally enter small per-tree sorted tables used to reconcile identity across tree boundaries (see phase 3 of the pipeline below). This is the data layout that generalizes to distributed forests: each process numbers the nodes it owns, and only interface node ids are exchanged. - 2:1 balance is what keeps non-conformity tractable. On a balanced forest the two sides of a non-conforming interface differ by exactly one level, which buys the construction two things. First, hanging nodes appear only at predictable integer coordinates — the midpoint of a coarse edge or the center of a coarse face — so the traversal detects and numbers them without any search (with larger level jumps they could sit at quarter points and deeper). Second, the masters of a hanging node are nodes of the coarse entity and therefore regular themselves: every conformity constraint is resolved within one level and never chains through other hanging nodes. The algorithms of Isaac et al. [16] assume a balanced forest throughout.
Vocabulary: points, closure, support, part
The traversal machinery speaks the vocabulary of Isaac et al. [16] §2. Four terms carry everything, and all of them are purely integer/topological — no physical coordinate and no floating-point comparison appears anywhere:
| Term | Meaning | Paper | Code |
|---|---|---|---|
| point | One topological entity of the mesh — vertex, edge, face or volume — encoded as a (possibly degenerate) axis-aligned box: an anchor corner, a level, and per axis a flag whether the box extends along it. Two points are equal iff their encodings are equal. | §2.1 | IteratePoint |
| closure | A box including its boundary faces, edges and corners. "Octant o touches point c" always means c ⊂ closure(o). | §2.1 | _child_touches_point |
support of c | The octants at c's level whose closure contains c — up to 2^(dim - dim(c)) boxes around it (fewer on the domain boundary): 1 for a volume, 2 across a face, 4 around a 3D edge, 2^dim around a corner. These are the only octants that can decide what happens at c. | eq 2.11 | sc.supp in _iterate_interior! |
part of c | What splitting the point once decomposes its interior into: the 3^dim(c) points one level finer. | eq 2.7 | _foreach_partc |
struct IteratePoint{dim} anchor::NTuple{dim, Int} # minimum integer (octree) corner of the box level::Int # so the box has edge length _compute_size(b, level) axes::NTuple{dim, Bool} # the directions the box extends alongendThe number of extending axes is the dimension of the point, point_dim(c) = count(c.axes) (the paper's dim(c)). An octant is simply its own volume point (Remark 2.2 of Isaac et al. [16]).
One drawing per point dimension, each with its support (cf. Table 2.2 of Isaac et al. [16]); all boxes are at the same level ℓ, i.e. of size h = 2^(b-ℓ):
2D ────────────────────────────────────────────────────────────────────────────
volume point, dim(c) = 2 face point, dim(c) = 1 corner point, dim(c) = 0
axes = (true, true) axes = (false, true) axes = (false, false)
┏━━━━━━━┓ ┃ │
┃ ┃ s1 ┃ s2 s3 │ s4
┃ c ┃ ┃ c ──────●──────
┃ ┃ ┃ s1 │ c s2
┗━━━━━━━┛ ┃ │
supp(c) = {the octant supp(c) = {s1, s2}, supp(c) = {s1, s2, s3, s4},
itself}: c IS the the 2 octants whose the 2^dim octants whose
octant's box closure contains the closure contains the
face corner
3D ────────────────────────────────────────────────────────────────────────────
volume: as in 2D, supp = {itself} face (dim 2): 2 supports, as in 2D
corner (dim 0): 2³ = 8 supports edge (dim 1): 4 supports — looking
down the edge axis it is exactly
the 2D corner pictureSplitting a 2D volume point c once illustrates part(c) — the 3² = 9 interior sub-points, each one level finer:
┌─────────┬─────────┐
│ │ │ 4 volume points ▢
│ ▢ f ▢ │ 4 face points f
│ │ │ 1 corner point ● (the center of c's box)
├────f────●────f────┤
│ │ │ the boundary of c's box is NOT in part(c)!
│ ▢ f ▢ │
│ │ │
└─────────┴─────────┘The center point ● ties the two figures together: it is a corner point (dim(c) = 0) one level finer than c, and its support (eq 2.11) are exactly the four volume sub-points ▢ around it — the corner-point column of the table above, one level down. Likewise each face point f has the two adjacent volume sub-points as its support.
The boundary remark is the fact to internalize: the boundary features of c's box are not in part(c) — they were already produced when c's own parent point was split. Every entity of the leaf mesh therefore lies in the interior of exactly one ancestor box, at exactly one level, so a recursion that descends through part reaches every mesh entity exactly once — no deduplication, no lookup, identity by construction. The only exception is the root's own boundary, which has no parent split to produce it; its 3^dim closure points are seeded explicitly (_foreach_root_closure, Alg 5.3 line 4).
The descent and the stop rule
The heart of the materializer is the recursive traversal iterate_points (Iterate, Alg 5.3, serial), which drives _iterate_interior! (Iterate_interior, Alg 5.2) from every root-closure seed. Each recursion step carries a point c together with its support octants and, per support octant, the index range of the actual leaves below it in the tree's Morton-sorted leaves vector (the paper's S arrays). At every step the recursion asks one question — is some support octant itself a leaf? (Alg 5.2 line 7):
- No — everything around
cis refined further, soc's current description is too coarse to be a mesh entity. Descend: splitcintopart(c)(_foreach_partc), give each sub-point its support from the children ofc's supports (a combinatorial constant served by precomputed mask tables,_part_mask), and slice the leaf ranges withsplit_bounds— descendants of an octant are contiguous in Morton order, so this is index arithmetic, not search. - Yes — a leaf has no children, so the mesh has no finer structure touching
cfrom that side:c, as described, is an entity of the final mesh. The point is finalized: the recursion stops, builds the leaf supportleaf_supp(c)— each support octant that is a leaf enters as-is; for the refined ones, their children adjacent tocenter (one level down suffices under 2:1 balance;B_∩^j, Alg 5.2 line 14,_child_touches_point) — and fires the callbackvisit(c, leaf_supp)for it, exactly once, ever. Corner points cannot be split and always finalize (Alg 5.2 lines 15–18; the leaf per support subtree is found by_descend_to_corner).
The visited set is exactly PΩ of §5.1: every leaf volume and every face/edge/corner between leaves. Two consequences deserve emphasis:
- Exactly-once with complete support. Each visited entity fires one callback, and that callback sees all leaves touching the entity — the traversal never delivers a partial neighbourhood.
- Hanging points are never visited (Fig 5). At a face between a coarse leaf and finer neighbours the recursion stops at the coarse level — it cannot descend past a leaf — so the finer vertices in the face's interior lie beyond the recursion frontier and never become points. Their information is not lost: it arrives at the finalized coarse face, whose leaf support then mixes two levels, and that mixed support is the complete hanging-node configuration (see Hanging nodes below).
the stop rule at a non-conforming face (2D) — the face point c is the interface
between the coarse leaf and its refined neighbour, i.e. the segment from ● to ●:
┌─────────────────●────────┐
│ │ │ the recursion stops at c: its LEFT support
│ │ fine │ (the coarse leaf, level ℓ) is itself a leaf,
│ │ (ℓ+1) │ so c is finalized with
│ coarse │ │ leaf_supp(c) = {coarse, fine, fine}
│ leaf ○────────┤
│ (level ℓ) │ │ ○, the midpoint of c: a corner of the two
│ │ fine │ fine leaves but not a vertex of the coarse
│ │ (ℓ+1) │ leaf, and never visited as a point of its
│ │ │ own — the face callback creates it as the
└─────────────────●────────┘ hanging node, with the two ● as mastersThree keywords of iterate_points give the §5.4 callback specialisations, the analogue of passing NULL callbacks to p4est_iterate: mindim (don't recurse into / fire for points below this dimension), maxdim (don't fire the callback above this dimension; the volume recursion still runs, being the spine of the traversal), and skip_conforming (skip the callback for faces/edges whose supports are all equal-level leaves — conforming interfaces — for callbacks that only act on non-conforming ones; corners always fire). The whole descent is allocation-free: all per-depth state lives in one preallocated IterScratch, reused across the trees of the forest.
Ferrite.AMR.IteratePoint — Type
IteratePoint{dim}A point in the sense of Isaac et al. [16] §2.1: an octant volume or one of its lower-dimensional features (face, 3D edge, corner), encoded topologically by the box anchor (minimum integer corner), level (box extent h = _compute_size(b, level)) and axes (the directions the box extends along). point_dim(c) = count(axes) is the paper's dim(c); equality is field-wise — no physical coordinates, no rounding.
Ferrite.AMR.iterate_points — Function
iterate_points(visit, tree::OctreeBWG, sc::IterScratch; mindim = 0, maxdim = dim, skip_conforming = false)Isaac et al. [16] Algorithm 5.3 (Iterate), serial: drive _iterate_interior! from the closure of each tree root. visit(c::IteratePoint, leaf_supp::LeafSupport) is called once for every point c ∈ PΩ (5.1) — every non-hanging volume / face / edge / corner — with leaf_supp the leaves surrounding it plus their leaf indices (§6.4: "Iterate provides the index"). Use point_dim(c) to dispatch per dimension (volume = dim, face = dim-1, edge = 1, corner = 0), or pass mindim/maxdim for the §5.4 specialization (e.g. mindim = dim - 1 to visit only volumes + faces, or maxdim = dim - 1 to skip the volume callback like a NULL volume callback in p4est_iterate). With skip_conforming = true, face/edge points whose supports are all leaves of equal level (conforming interfaces) are skipped as well — the specialization for callbacks that only act on non-conforming interfaces, like the hanging-node detection; corner points always fire. The descent is allocation-free: callers traversing many trees (creategrid, facetskeleton) allocate one IterScratch and pass it to every tree of equal maximum level b (the buffers are depth-indexed, so they only depend on b); leaf_supp wraps reused buffers — copy what you retain.
Looping the trees of a forest is the serial Alg 5.3. Within a tree this visits PΩ exactly; at shared tree boundaries a feature is currently visited once per incident tree (its per-tree leaf_supp covers only that tree's leaves) — creategrid reconciles the per-tree visits through its boundary tables. Cross-tree coordinated descent (single-visit boundary leaf_supp via the orientation transforms, fully mirroring p4est_iterate) is the documented next step, consistent with _iterate_interface_hanging!'s inter-tree face descent.
Ferrite.AMR._iterate_interior! — Function
_iterate_interior!(visit, c::IteratePoint, depth, sc::IterScratch, leaves, b, mindim, maxdim, skipconf)Isaac et al. [16] Algorithm 5.2 (Iterate_interior), serial and allocation-free. On entry sc.supp[depth] holds c's support octants (eq 2.11) and sc.S[depth][i] the leaves index range under each. Exactly as in Alg 5.2, the recursion stops when some support octant is itself a leaf (line 7; for a corner point always, lines 16-18) — so hanging points are never visited (PΩ, eq 5.1) — and calls visit(c, leaf_supp) with the LeafSupport built via _child_touches_point (line 14) / _descend_to_corner (line 18); otherwise it descends part(c), slicing each range with split_bounds. leaf_supp wraps reused buffers — copy if retained. mindim/maxdim specialize the callback (§5.4): dims below mindim are not recursed into, and visit fires only for point_dim(c) ∈ mindim:maxdim. skipconf additionally suppresses the visit at conforming (all-supports-leaf) interfaces, see the comment inline.
Ferrite.AMR.IterScratch — Type
IterScratch{N, M, OT}
IterScratch(tree::OctreeBWG)Preallocated per-depth working memory of the recursive descent — the sc argument of iterate_points — so the traversal allocates nothing (Isaac et al. [16] §5.4). In a DFS only one root-to-node path is live, so buffers are indexed by recursion depth and reused across siblings; see the field comments for what each buffer holds. M = N + 1 is the split_bounds tuple length. Sizes depend only on the maximum level b, so one scratch is shared across all trees of a forest (creategrid does exactly that).
Ferrite.AMR._foreach_partc — Function
_foreach_partc(f, c::IteratePoint, b)Call f(e, combo) for each e ∈ part(c), the partition of c (eq 2.7 of Isaac et al. [16]): the 3^dim(c) one-level-finer points strictly interior to dom(c) — per extending axis the lower half, the degenerate mid plane, or the upper half (boundary features belong to the parent point's partition, which is what makes the descent visit every entity exactly once). combo is the base-3 slot encoding (one digit per extending axis, ascending), the key into the precomputed _part_mask tables.
Ferrite.AMR._foreach_root_closure — Function
_foreach_root_closure(f, ::Val{dim}, b)Call f(c) for each point in the closure of the tree root (Alg 5.3 line 4 of Isaac et al. [16], single tree): the root volume and all its boundary faces/edges/corners — the 3^dim seeds of the recursive descent. The root's boundary features have no parent split to produce them (unlike interior features, which arise as part of an ancestor), so they get their own descent seeds here. Along each axis the feature is pinned to the low face (coord 0), spans the full root, or is pinned to the high face.
Ferrite.AMR._child_touches_point — Function
_child_touches_point(ch::OctantBWG, c::IteratePoint, b) -> BoolWhether point c (a feature of ch's parent) lies in the closure of child octant ch — the child-boundary-intersection set B_∩^j (eq 4.5 / Alg 5.2 line 14 of Isaac et al. [16]): true iff ch's box straddles c's coordinate along every degenerate axis of c.
Ferrite.AMR._descend_to_corner — Function
_descend_to_corner(c::IteratePoint, s::OctantBWG, lo, hi, leaves, b) -> (leaf, index)Find the leaf under support octant s (leaves[lo:hi]) whose closure contains the 0-point c — the atom supp(c) search (Alg 5.2 line 18 / Prop 2.8 of Isaac et al. [16]) — returning it with its index into leaves (the paper's element index j, §6.4). c is a corner of s, so the descent follows the fixed ci-most path, narrowed with split_bounds; the Morton-first/-last slots resolve in O(1).
Ferrite.AMR.split_bounds — Function
split_bounds(leaves, lo, hi, a::OctantBWG, b) -> NTuple{2^dim + 1, Int}Algorithm 3.3 of Isaac et al. [16]. Given the contiguous, Morton-sorted leaf sub-range leaves[lo:hi] (all strict descendants of a), return boundary indices k such that child i of a occupies leaves[k[i]:k[i+1]-1]. Non-allocating: returns a stack NTuple — no 𝐤 vector and no SubArray views, so the recursive descent that calls it at every internal octant stays allocation-free. Short ranges (the vast majority: the recursion halves the range per level) are split by a single linear ancestor_id sweep; long ranges by 2^dim - 1 binary searches (the child key ancestor_id is monotone along the range).
Shared descent helper of the point iterator (_iterate_interior!, _descend_to_corner) and the interface descent (_iter_interface!).
The traversal types at a glance
Four types cooperate, with sharply separated roles — one is a message, one is memory, one is a window, one is the consumer:
| Type | Role | Lifetime |
|---|---|---|
IteratePoint | The message: describes the visited entity. | Created and discarded during the descent; never stored. |
IterScratch | The memory: per-depth buffers of the recursion, so the traversal allocates nothing. Carries no meaning between traversals. | One per forest, reused for every tree. |
LeafSupport | The window: the leaves touching the visited entity plus each leaf's index in tree.leaves — the element index j of §6.4 ("Iterate provides the index"), which lets a callback address per-element data directly. Wraps the scratch's buffers. | Valid only during the callback call — copy what you retain. |
LnodesVisitor | The consumer: creategrid's callback (Lnodes_callback, Alg 6.2) as a callable struct, so it can carry the output arrays it fills. | One per tree; its fields reference forest-wide outputs. |
All four meet at the callback boundary of one per-tree traversal:
iterate_points(visitor::LnodesVisitor, tree, sc::IterScratch; …)
│
▼ _iterate_interior! descends; for every finalized point c:
visitor(c::IteratePoint, ls::LeafSupport) # ls: a window into sc's reused buffersWhat the visitor does with each visit — and which forest-wide output arrays its fields reference — is the subject of the pipeline below.
The creategrid pipeline
creategrid drives the iterator and assembles the grid in a few phases. The call graph:
creategrid(forest)
│
├─ for each tree: iterate_points(visitor, tree, sc; # IBWG2015 Alg 5.2/5.3
│ mindim = 0, maxdim = dim-1, # = Alg 6.2 Lnodes
│ skip_conforming = true)
│ ├─ corner callback → _visit_corner! # create node id, scatter into E[slot, element]
│ ├─ face callback → _visit_face! # hanging face midpoint (2D) / center (3D)
│ └─ edge callback → _visit_edge3d! # hanging edge midpoints (3D)
│
├─ _iterate_interface_hanging! # inter-tree hanging constraints (reads E)
│ └─ _iter_interface! (per shared tree face) → _emit_interface_face!
│
├─ _merge_intertree_nodes! # alias ids shared across tree boundaries (boundary tables)
├─ _global_numbering # Alg 6.1: final dense ids in one sweep over E
├─ _build_cells # E columns → Quadrilateral / Hexahedron cells
└─ reconstruct_facetsets / reconstruct_cellsets # carry named boundaries / subdomains onto the refined grid- Numbering and hanging detection happen inside the single per-tree
iterate_pointspass (theLnodes_callbackof IBWG2015 Alg 6.2,LnodesVisitor). The corner callback creates the node at the visited point — a running provisional id, its physical coordinate, and a boundary-table entry if it lies on the tree boundary — and scatters the id into the element-node matrixEof every supporting leaf ("complete the entries inEpthat refer tog", §6.4).Eis a2^dim × ncellsinteger matrix holding the node id of every element corner in z-order — connectivity and node numbering in one array, and the single structure every later phase reads from. The face/edge callbacks detect non-conformity from the level mismatch in their support, create the hanging vertex the same way (hanging vertices are genuine fine-leaf corners) and record its constraint as(element, slot)references intoE, resolved after the traversal. - Inter-tree hanging is collected by a cross-tree two-sided face descent (
_iter_interface!) seeded at every shared tree face — the same idea as the intra-tree callbacks, but matching the two sides across a tree boundary viatransform_facet(handling rotations). - Cross-tree identity. The traversal is strictly per-tree, so a node on a shared tree boundary is visited once per incident tree and briefly holds one provisional id per tree. To merge the duplicates one lookup structure is unavoidable — "tree
k, which id did you give the node at coordinatex?" — and it is deliberately confined to the tree surface: whenever the corner callback creates a node whose coordinate lies on the root boundary (a component is0or2^b), it also appends(key, id)to that tree's boundary table, with the coordinate bit-packed into a singleUInt64key (_packcoord) so comparisons are one machine word. Each table is sorted once after its tree's traversal;_bnd_lookupthen answers queries by binary search._merge_intertree_nodes!walks the shared root vertices/faces/edges, maps coordinates into the neighbour tree's frame viatransform_facet/transform_corner/transform_edge(handling tree rotations), and recordsalias[duplicate] = owner(the lower tree index owns). A lookup miss is routine and meaningful: a hanging node exists as a vertex only on the refined side of an interface, so the coarse neighbour has no entry for it. Interior nodes — the overwhelming majority — never enter any table:O(surface)data, the only node-lookup structure of the materializer. - Global numbering, cells and constraints.
_global_numberingis the serialGlobal_numbering(Alg 6.1): one linear sweep overEin (element, element-node) order assigns final dense ids by first encounter — with the ownership ruleowner(c) = min leaf supp(c)(eq 6.2) this is the paper's partition-independent numbering._build_cellsthen reads the cells straight offE, the constraint records are resolved againstE, andreconstruct_facetsetstransfers the boundary sets andreconstruct_cellsetsthe cell sets (every leaf inherits its tree's set membership).
Ferrite.AMR.LeafSupport — Type
LeafSupport{OT}The local leaf support set leaf_supp(c) handed to the iterate_points callback: the leaves whose closure touches the visited point c, each paired with its index into the tree's Morton-sorted leaves (the element index j of Isaac et al. [16] §6.4). Iterating yields the octants; ls.idxs[i] belongs to ls.octs[i]; an index of 0 marks a non-leaf entry — impossible on a 2:1-balanced forest, and turned into an error by creategrid. Both vectors are reused buffers of the traversal — copy them if you retain them past the callback.
Ferrite.AMR.LnodesVisitor — Type
LnodesVisitor{dim, N, T, TI}Per-tree numbering context of the creategrid traversal — the Lnodes_callback of Isaac et al. [16] Alg 6.2, as a callable struct (a concrete top-level type instead of a closure, so the captures don't box). One instance per tree: E, the provisional node data (nodecoords_prov, cnt) and the constraint records (cons2/cons4) are shared across trees; bnd, the geometry and the element offset are the tree's own.
Callback dispatch by point_dim(c): corners create + scatter node ids (_visit_corner!); non-conforming faces and (3D) edges create the hanging vertex and record its constraint (_visit_face!, _visit_edge3d!); volumes need no work (cell connectivity IS the filled E).
Ferrite.AMR._visit_corner! — Function
_visit_corner!(v::LnodesVisitor, c, ls)Corner callback (dim(c) == 0): c is a non-hanging mesh vertex — hanging points are never visited by the iterator — so every supporting leaf has c as a corner. Create its node and scatter the id into each leaf's E slot (the leaf indices come with the support set).
Ferrite.AMR._mixed_support — Function
_mixed_support(c::IteratePoint, ls::LeafSupport) -> (coarse, fine)Non-conformity test of a finalized face/edge point (Isaac et al. [16] Fig 5): a leaf support at level(c) (the coarse side) coexisting with finer children. Returns the index of the first coarse leaf in ls (its corners are the constraint masters) and whether finer leaves exist — the interface hangs iff finer leaves coexist with the coarse one. A finalized face/edge always has at least one support that is itself a leaf at level(c) (that is what stopped the recursion in _iterate_interior!), so with all supports equal-level leaves the interface is conforming and there is nothing to do.
Ferrite.AMR._global_numbering — Function
_global_numbering(E, alias, nodecoords_prov) -> (final_of_prov, nodecoords)Serial Global_numbering (Isaac et al. [16] Alg 6.1): sweep the element-node matrix in (element, element-node) lexicographic order — the linear (column-major) order of E — and assign each canonical node its final dense id at first encounter. With the ownership rule owner(c) = min leaf supp(c) (eq 6.2), the first element referencing a node is its owner, so this reproduces the paper's partition-independent numbering without any per-node search. Also gathers the final node coordinates (the canonical provisional node's) in final-id order.
A zero entry in E means some element vertex was never assigned a node — impossible on a 2:1-balanced forest — and raises an error.
Ferrite.AMR._merge_intertree_nodes! — Function
_merge_intertree_nodes!(forest::ForestBWG{dim}, bnd, alias)Identify nodes shared across tree boundaries (creategrid's cross-tree pass). For each tree k, walk its root-vertex, root-face and (3D) root-edge neighbours; a node on a shared boundary is matched to its image in the lower-index neighbour k′ via transform_facet/ transform_corner/transform_edge (handling tree rotations), and aliased onto that owner. Only the lower-index tree owns a shared node (k > k′), giving a single canonical id per geometric node across all incident trees.
Node ids are looked up in bnd, the per-tree boundary node tables: bnd[k] holds (packed coord, provisional id) for every node of tree k lying on its root boundary, sorted by key (filled by the numbering traversal, see creategrid). This is the only node-lookup structure of the whole materializer — O(surface) per tree instead of a global coordinate hash map — and the walk itself visits only boundary leaves. The canonicalization is recorded in alias::Vector{Int} (indexed by provisional id, identity-initialized): alias[p] is the provisional id that p is merged onto, so the per-node canonical lookup in creategrid is an array index.
Ferrite.AMR._bnd_lookup — Function
_bnd_lookup(bnd, coord::NTuple{dim, <:Integer}, b) -> IntO(log) lookup in a tree's sorted boundary node table bnd ((packed coord, provisional id) pairs, see _merge_intertree_nodes!): the provisional id of the node at integer octree coord coord, or 0 if coord is out of the tree's [0, 2^b] range or is not a node of that tree. Both happen routinely during the cross-tree merge: the transform_facet/transform_edge images can land outside the neighbour's domain, and a hanging node exists as a leaf vertex on the refined side of an interface only.
Ferrite.AMR._build_cells — Function
_build_cells(::Type{CT}, E, node_map, final_of_prov, ::Val{NV}) -> Vector{CT}Materialize the cell vector from the element-node matrix E (provisional ids, z-order slots): column gid is cell gid's connectivity, remapped through final_of_prov to the final node ids and reordered to Ferrite's vertex order via node_map, wrapped in cell type CT (Quadrilateral/Hexahedron). A top-level function barrier so the cell construction compiles concretely (building cells in the type-unstable creategrid body boxes every cell).
Ferrite.AMR.reconstruct_facetsets — Function
reconstruct_facetsets(forest::ForestBWG{dim}) -> Dict{String, OrderedSet{FacetIndex}}Transfer the macro-mesh facet sets onto the materialized (refined) grid. For each original FacetIndex (tree, face), emit a FacetIndex for every leaf of that tree lying on the root face, converting between p4est and Ferrite face ordering (𝒱₂_perm/𝒱₃_perm). This keeps named boundaries (e.g. Dirichlet/Neumann sets) valid after refinement.
Staying inside one tree there is no rotation, so a leaf is on the root face iff its anchor lies on that face's axis-aligned plane (leaf.xyz[axis] == 0 for a low face, == 2^b - leafsize for a high face), and the contributing local face index is exactly the root face index. This is an O(#leaves) plane test, replacing a former O(#leaves · 2dim) contains_facet scan over each leaf's faces.
Ferrite.AMR.reconstruct_cellsets — Function
reconstruct_cellsets(forest::ForestBWG) -> Dict{String, OrderedSet{Int}}Transfer the macro-mesh cell sets onto the materialized (refined) grid: every leaf inherits the set membership of its tree (macro cell), so each macro cell id in a set is replaced by the cell ids of that tree's leaves (contiguous by _element_offsets). This keeps named subdomains (e.g. material regions) valid after refinement.
Physical coordinates
Node identity is purely integer; physical positions enter only here. Each macro element (tree) is an isoparametric $Q_1$ cell, so an octree coordinate is mapped to physical space by interpolating the tree's corner nodes with the bi-/trilinear Lagrange shape functions.
Ferrite.AMR._treecorners — Function
_treecorners(forest::ForestBWG{dim}, k::Integer) -> NTuple{2^dim, Vec{dim}}Physical coordinates of macro-tree k's 2^dim corner nodes, in Ferrite's vertex order for the tree's cell. These are the interpolation support points for _interp_treepoint; indexing forest.nodes through forest.cells[k].nodes directly keeps the result concrete and allocation-free.
Ferrite.AMR._interp_treepoint — Function
_interp_treepoint(corners::NTuple{N, Vec{dim}}, b, vertex::NTuple{dim}) -> Vec{dim}Map an integer octree coordinate vertex of a tree to physical space — the isoparametric $Q_1$ geometry map of the macro element (tree). Two steps:
- affine-scale the octree coordinate (in
[0, 2^b]^dim, see_maximum_size) to the reference cube $\xi \in [-1,1]^{dim}$ via $\xi = \texttt{vertex} \cdot 2/2^b - 1$; - interpolate the tree's physical
cornerswith the bi-/trilinear Lagrange shape functions, $x = \sum_{j=1}^{N} N_j(\xi)\, \texttt{corners}[j]$.
corners are the tree's 2^dim physical corner nodes (see _treecorners), passed in explicitly so the per-tree corners are computed once and reused for every node of the tree. This is the single bridge from the integer/topological octree world into physical coordinates.
Hanging nodes
A hanging node is a node that exists on the fine side of a non-conforming interface but is not a vertex on the coarse side. On a 2:1-balanced forest these are exactly the center of a coarse face (bordering a refined neighbour) and the midpoint of a coarse edge (bordering a finer leaf) — balance caps the level jump at one, so no ¼-points exist:
3D face fc = (c1,c2,c3,c4) in z-order — ● corner (master), ◆ face center, ○ edge midpoint:
c3 ●━━━━━━━○━━━━━━━● c4 constraints:
┃ m34 ┃ ◆ hnodes[c ] = {c1,c2,c3,c4} (face callback)
┃ ┃ ○ hnodes[m12] = {c1,c2} (edge callbacks)
m13○ ◆c ○m24 ○ hnodes[m34] = {c3,c4}
┃ (center) ┃ ○ hnodes[m13] = {c1,c3}
┃ m12 ┃ ○ hnodes[m24] = {c2,c4}
c1 ●━━━━━━━○━━━━━━━● c2In 2D a face is an edge, so there is just the midpoint with its two masters. Because hanging points are exactly the points the iterator never visits (the stop rule halts at the coarse leaf; see The descent and the stop rule above), they are created by the feature that owns them — detected via the mixed-level leaf support (_mixed_support): a non-conforming face point creates its center, and a non-conforming edge point creates its midpoint (_visit_face!, _visit_edge3d!). Each hanging vertex belongs to exactly one such coarse feature (two octant edges cannot share their midpoints), and each feature is visited exactly once — even an edge shared by several non-conforming faces — so every hanging vertex is created exactly once, with its constraint recorded as (element, slot) references into E (a master corner may not be numbered yet when the feature is visited; the references are resolved after the traversal).
Ferrite.AMR._visit_face! — Function
_visit_face!(v::LnodesVisitor, c, ls)Face callback (dim(c) == dim-1). Conforming faces need no work. On a non-conforming face — a coarse leaf on one side, its refined neighbour's children on the other — the face's interior vertices hang: in 2D the face midpoint (constrained by the 2 endpoints), in 3D the face center (constrained by the 4 face corners). The 3D face's edge midpoints are not emitted here: each hanging edge is its own iterator point, visited exactly once even when shared by several non-conforming faces, and handled by _visit_edge3d!.
Ferrite.AMR._visit_edge3d! — Function
_visit_edge3d!(v::LnodesVisitor, c, ls)Edge callback (3D, dim(c) == 1): the midpoint of a non-conforming coarse edge hangs, constrained by the edge's endpoints. On a 2:1-balanced forest every hanging vertex is either such an edge midpoint or a face center (_visit_face!) — level jumps are capped at one, so no ¼-points exist — and each is created by exactly one callback: the edge midpoint belongs to exactly one coarse edge (two distinct octant edges cannot share their midpoints), even when that edge borders several non-conforming faces.
Ferrite.AMR._iter_interface! — Function
_iter_interface!(
cons2, cons4, E, offR, forest, kL, lvsL, octL, loL, hiL, fL,
kR, lvsR, octR, loR, hiR, fR, bL, bR
)Synchronized two-sided descent of a shared tree face, emitting inter-tree hanging-node constraints. octL ∈ tree kL (leaves lvsL[loL:hiL], native frame) and octR ∈ tree kR are images of each other across the shared face — fL/fR are the local face indices toward it — and descend in lock-step at equal levels.
- both sides leaves of equal size → conforming, nothing emitted;
- one side a leaf, the other refined → the leaf is the coarse side and the hanging nodes lie on the refined side's face in the fine tree's frame (genuine fine-leaf vertices), emitted via
_emit_interface_face!as(element, slot)references intoE(offRis treekR's element offset). Only thekL-coarse case emits here; thekR-coarse case is emitted when the descent is run from(kR, fR), so each interface is handled once per direction; - both refined → recurse, matching child
ion thekLside to its image on thekRside viatransform_facet(the validated cross-tree orientation pattern — no new logic).
The integer/topological analogue, across trees, of the intra-tree face/edge callbacks of the numbering traversal (see creategrid).
Ferrite.AMR._emit_interface_face! — Function
_emit_interface_face!(cons2, cons4, E, off, lvs, lo, hi, octR, fR, b)The kL side of an interface octant pair is a leaf while the kR side octR (leaves lvs[lo:hi], element offset off) is refined: the interior points of the shared face hang. Every one of them is a vertex of octR's face children — leaves under the 2:1 balance, already numbered by tree kR's own traversal — so the constrained ids are read straight off E and the constraints recorded exactly like the intra-tree emitters: the face midpoint (2D) / face center (3D) constrained by the face corners, and in 3D each face-edge midpoint constrained by its edge's endpoints, masters in ascending coordinate order. (On a >1 level jump the ids are read from the corner leaves via _subtree_corner_ref's descent.)
Ferrite.AMR._iterate_interface_hanging! — Function
_iterate_interface_hanging!(cons2, cons4, E, offsets, forest::ForestBWG)Collect all inter-tree hanging-node constraints of forest into cons2/cons4. For each shared tree face (found via the facet–facet neighbourhood), seed _iter_interface! with the two tree roots and let it descend both sides in lock-step. The forest-level counterpart of the intra-tree face/edge callbacks; together they capture every hanging node of the materialized grid. Single-tree forests have no shared faces, so this is a no-op there.
Ferrite.AMR.center — Function
center(pivot_face) -> NTuple{dim, Int}Integer centroid of a set of octree coordinates (the corners of a face or edge): the elementwise sum divided by the number of points. For a coarse face bordering a refined neighbour this is exactly the hanging node — the face center (4 corners) or edge midpoint (2 corners) — in the same integer/topological frame (no physical coordinates).
Ferrite.AMR.contains_facet — Function
contains_facet(mface, sface) -> BoolWhether the sub-facet sface is geometrically contained in the master facet mface, both given as octree corner coordinates. In 2D the facets are axis-aligned segments and containment is an interval test along the shared axis; in 3D mface is a face and sface is accepted iff its center lies inside mface's bounding box. Used to decide whether a leaf face lies on a coarse/root face (refinement interfaces, boundary-set transfer).
The facet skeleton
Facet-jump error estimators and DG-style couplings need the true facet interfaces of the refined forest — including the coarse↔fine (hanging) and across-tree ones, which an ExclusiveTopology of the materialized grid cannot provide (it only knows the macro/root mesh). facetskeleton materializes them as pairs of FacetIndex into creategrid's cell numbering: the intra-tree interfaces come from a face-only iterate_points traversal (FacetSkeletonVisitor), the inter-tree ones from a two-sided lock-step descent of each shared tree face (_iter_interface_facets!), mirroring _iter_interface! above.
Ferrite.AMR.FacetSkeletonVisitor — Type
FacetSkeletonVisitor{dim}Per-tree face callback of facetskeleton's intra-tree traversal. At each (non-hanging) face point the LeafSupport holds the leaves of both sides of the face; the visitor recovers each side's local facet index from the face normal (the point's degenerate axis, sides told apart by the anchor comparison of _side_mask) and pushes the facet pair(s): one pair for a conforming face, one pair per fine child — fine side first, the coarse leaf second — for a non-conforming one. Face points on the tree boundary (anchor 0/2^b along the normal) are skipped: their support is one-sided within the tree, and they belong to either the inter-tree descent or the domain boundary.
Ferrite.AMR._iter_interface_facets! — Function
_iter_interface_facets!(
skel, perm, offL, offR, forest, kL, lvsL, octL, loL, hiL, fL,
kR, lvsR, octR, loR, hiR, fR, bL, bR
)facetskeleton's inter-tree counterpart of _iter_interface!: the same synchronized two-sided descent of a shared tree face, but emitting the facet pairs of the interface instead of hanging-node constraints. Each shared face is descended once per direction, so to emit exactly once a conforming leaf pair fires only from the direction with the smaller (tree, face) key, and a hanging interface only when the coarse side is kL — the fine subfacets then live in tree kR's frame and are enumerated by _emit_interface_facets!, fine side first.
Ferrite.AMR._emit_interface_facets! — Function
_emit_interface_facets!(skel, perm, off, lvs, lo, hi, oct, f, b, coarse)One-sided descent of the refined side of a hanging inter-tree interface: enumerate the leaf subfacets on oct's face f (leaves lvs[lo:hi], element offset off) and pair each with the coarse side's FacetIndex coarse, fine side first. Recursing to the leaves instead of stopping at oct's face children tolerates interfaces with a >1 level jump, like _subtree_corner_ref's descent.
Ferrite.AMR._element_offsets — Function
_element_offsets(forest::ForestBWG) -> Vector{Int}Element offset of each tree into the materialized cell vector: tree k's leaf j (Morton order) is cell offsets[k] + j of the grid returned by creategrid.
Conformity constraints
The hanging-node map produced by creategrid is turned into affine constraints by adding a ConformityConstraint to a ConstraintHandler; the constraint weights and their justification are user-facing and documented in the AMR topic guide and ConformityConstraint.