Mesh-less domains
Sampled on the fly, at every training step: a domain is a level set plus a mapping, and a solver only ever sees the points a sampler drew from it.
Every VolumetricDomain is defined by a level set
(a signed distance function, tested as sdf(x) < 0) and reshaped
by a mapping; below, a main domain built that way also carries
a subdomain and a hole, sampled by a DomainSampler (interior
points, boundary points and normals).
1. A volumetric domain, with a subdomain and a hole
A VolumetricDomain that is a main domain can be
extended with subdomains, holes and a mapping. Each piece carries its own
label, which shows up as a key in the dictionaries returned by samplers.
Square2D and Disk2D come from
domains/meshless_domains/; arbitrary-dimension equivalents
(HypercubeND, BallND) live in
domains_nd.py. Under the hood, each is just a level set and a
mapping — see below.
from scimba_jax.domains.meshless_domains.domains_2d import Square2D, Disk2D
bounds_outer = [(-1.0, 1.0), (-1.0, 1.0)]
bounds_inner = [(-0.7, 0.3), (-0.25, 0.25)]
outer = Square2D(bounds_outer, is_main_domain=True, label_str="outer")
inner = Square2D(bounds_inner, is_main_domain=False, label_str="inner")
hole = Disk2D((0.55, 0.0), 0.2, is_main_domain=False, label_str="hole")
outer.add_subdomain(inner)
outer.add_hole(hole)
2. Defining a domain from a level set and a mapping
A VolumetricDomain is fundamentally a SignedDistance
(the level set: < 0 inside, > 0 outside) plus
bounds. Square2D, Disk2D and friends are thin
wrappers around this pattern; you can write the same thing directly, then
reshape it with a Mapping.
import jax.numpy as jnp
from scimba_jax.domains.sdf import SignedDistance
from scimba_jax.domains.meshless_domains.base import VolumetricDomain
from scimba_jax.domains.domain_mapping import DomainMapping as Mapping
class Disk2DSignedDistance(SignedDistance):
def __init__(self, center, radius):
super().__init__(dim=2, threshold=0.0)
self.center = center
self.radius = radius
def _sdf_pointwise(self, x):
return (jnp.linalg.norm(x - self.center, axis=0) - self.radius)[None]
disk = VolumetricDomain(
domain_type="Disk2D",
dim=2,
sdf=Disk2DSignedDistance(jnp.array([0.0, 0.0]), 1.0),
bounds=[(-1.0, 1.0), (-1.0, 1.0)],
is_main_domain=True,
)
disk.set_mapping(
Mapping.rot_2d(angle=jnp.pi / 6.0, center=jnp.array([0.0, 0.0])),
[(-1.5, 1.5), (-1.5, 1.5)],
)
3. Mapping propagates to subdomains and holes
Calling set_mapping on a composite main domain — like
outer from step 1 — reshapes it and automatically propagates
the same mapping to its subdomains and holes. Built-ins cover common
cases such as rotations; custom mappings are plain JAX-traceable
functions, so vmap and autodiff (e.g. for normals) work out
of the box.
import jax.numpy as jnp
from scimba_jax.domains.domain_mapping import DomainMapping as Mapping
outer.set_mapping(
Mapping.rot_2d(angle=jnp.pi / 3.0, center=jnp.array([0.0, 0.0])),
[(-2.0, 2.0), (-2.0, 2.0)],
)
4. Boundary domains are parameterized curves
A boundary piece is a SurfacicDomain: a 1D
parametric_domain (an interval) mapped into physical space by
a surface (a DomainMapping). Segment2D,
Circle2D and ArcCircle2D are built exactly this
way — ArcCircle2D simply maps an angle interval through
Mapping.circle.
Splitting hole's boundary into two named arcs and registering
them with add_bc_domain lets you target each one
independently — e.g. with different boundary conditions north vs. south.
import jax.numpy as jnp
from scimba_jax.domains.meshless_domains.domains_2d import ArcCircle2D
# split the hole's circular boundary into two named, parameterized arcs
disk_up = ArcCircle2D((0.55, 0.0), 0.2, (0.0, jnp.pi), label_str="bc hole north")
disk_down = ArcCircle2D(
(0.55, 0.0), 0.2, (jnp.pi, 2 * jnp.pi), label_str="bc hole south"
)
hole.add_bc_domain(disk_up)
hole.add_bc_domain(disk_down)
A boundary curve is just a parametric domain (here an angle interval) pushed
through a surface mapping. Splitting [0, 2π] into
[0, π] and [π, 2π] and mapping each half with
Mapping.circle gives two independent boundary pieces — the same
construction used by ArcCircle2D above, and how the north/south
boundary conditions on hole are told apart.
Meshes
Four kinds, chosen by geometry rather than by solver: a single mapped grid, an arbitrary boundary imported through Gmsh, a handful of smooth patches, or the product of two meshes already in hand.
1. A structured mesh for the DG/FEM solver
Classical solvers (linear_approximation/dg/) work on a
Mesh instead: a cartesian grid of n_cells
reference cells, each with its own Gauss quadrature
(ref_quad), mapped to physical space through the same
Mapping abstraction as the mesh-less domains above.
Adapted from the diffusion–advection DG example — view source ↗.
from scimba_jax.linear_approximation.meshes.mesh import Mesh
from scimba_jax.linear_approximation.quad.gauss_quad import UnitSquareTensorized
from scimba_jax.mapping.mapping import InvertibleFunction, Mapping
physical_dim = 2
quad_order = 3
n_cells = 40
mapping_id = InvertibleFunction(lambda x: x, lambda y: y)
mesh = Mesh(
dim=physical_dim,
n_cells=(n_cells, n_cells),
ref_quad=UnitSquareTensorized(dim=physical_dim, order=quad_order),
mapping=Mapping(mappings=[mapping_id]),
)
One reference cell, [0, 1]², mapped onto a physical
Mesh by the same Mapping abstraction — here onto a
curved sector; every cell carries its own Gauss quadrature points.
2. Unstructured quads, imported through Gmsh
Use an unstructured mesh when a curved or arbitrary boundary is not
naturally described by one mapping. Gmsh returns a MacroMesh
with complete tensor-product quadrilaterals;
UnstructuredMesh.from_macro_mesh turns it into the mesh
consumed by a solver.
Gmsh is optional: install scimba[mesh]. The requested
geometry order is retained, including the nodes on curved cell edges.
import numpy as np
from scimba_jax.linear_approximation.meshes.unstructured_mesh import UnstructuredMesh
from scimba_jax.linear_approximation.quad.gauss_quad import UnitSquareTensorized
from scimba_jax.mapping.macro_mesh import macro_mesh_from_points
# Requires: pip install scimba[mesh]
# Gmsh closes this ordered boundary and fills it with curved Q_p cells.
boundary = np.array([[-1.0, -0.7], [0.8, -0.9], [1.2, 0.1], [0.2, 1.0], [-0.9, 0.6]])
macro = macro_mesh_from_points(boundary, order=3, mesh_size=0.18, smooth=True)
mesh = UnstructuredMesh.from_macro_mesh(
macro,
UnitSquareTensorized(dim=2, order=5),
)
A mesh is just points (nodes), the quads they form
(cells), and which quads touch (faces). Cells
shrink near a tight curve and grow where the boundary is flat; a curved
cell follows the same recipe — only its corner nodes move.
3. Block-structured meshes for patchwise geometry
This representation is useful when a domain has a small number of smooth patches — for example an O-grid around a disk or hole. Every patch has its own mapped Cartesian grid, while the container owns the interface pairings. Patches may use different resolutions when an interface is non-conforming.
For DG, an interface uses the same numerical flux as an interior face. For continuous FEM, supply an explicit Nitsche/SIPG interface flux: there is deliberately no implicit penalty choice.
from scimba_jax.linear_approximation.meshes.block_structured_mesh import BlockStructuredMesh
from scimba_jax.linear_approximation.quad.gauss_quad import UnitSquareTensorized
from scimba_jax.mapping.macro_mesh import macro_mesh_ogrid_disk
# Five macro-patches: a square centre plus four curved outer patches.
macro = macro_mesh_ogrid_disk(radius=1.0, inner=0.45, n=1, order=3)
quad = UnitSquareTensorized(dim=2, order=5)
# One structured 12 x 12 mesh in every patch.
mesh = BlockStructuredMesh(macro, n_cells=(12, 12), ref_quad=quad)
# Per-patch counts are also valid, e.g. [(8, 8), (16, 16), ...].
Each patch is an ordinary regular grid; five of them, sharing edges exactly, make one mesh whose outer boundary can be round even though every single patch is simple.
4. Tensor meshes: the product of two meshes already in hand
tensor_mesh(mesh_a, mesh_b) builds no new mesh class: a
cell of the product is cell_a × cell_b, its quadrature is
quad_a ⊗ quad_b, and the result is an ordinary
Mesh, BlockStructuredMesh or
UnstructuredMesh — whichever of the two factors is the
least structured. Everything already written for that class
keeps working unchanged: faces, fluxes, the fast Cartesian paths.
This is how a space-time slab, a phase-space mesh, or a toroidal
tokamak cross-section gets built: as the product of two ordinary
meshes, with names so the physics reads
f(x, t) rather than slices of a flat array, and
periodic_directions to glue an axis of the product —
the toroidal angle, say.
from scimba_jax.linear_approximation.meshes.mesh import Mesh
from scimba_jax.linear_approximation.meshes.tensor_mesh import tensor_mesh
from scimba_jax.linear_approximation.quad.gauss_quad import UnitSquareTensorized
from scimba_jax.mapping.mapping import InvertibleFunction, Mapping
def _line(n_cells, length, order=3):
physical = InvertibleFunction(lambda x: length * x, lambda y: y / length)
return Mesh(
dim=1,
n_cells=(n_cells,),
ref_quad=UnitSquareTensorized(dim=1, order=order),
mapping=Mapping(mappings=[physical]),
)
# A 1-D mesh in x, times a 1-D mesh in t: one 2-D space-time slab, no
# time-stepping. The product is a plain `Mesh` -- the LEAST structured of
# its two factors -- so every DG/FEM scheme works on it unchanged.
mesh_x, mesh_t = _line(32, length=1.0), _line(12, length=0.1)
slab = tensor_mesh(mesh_x, mesh_t, names=("x", "t"))
Two 1-D meshes in, one 2-D mesh out: the product is a plain
Mesh because both factors already were. The basis and the
physics stay whatever they were on either factor — the tensor structure
lives in the mesh and its connectivity, not in the basis.
For the full walkthrough — holes, custom boundaries, kinetic/parametric domains, generic N-dimensional domains — see the Domains and samplers tutorial, or the Scimba Jax API reference.