Domain & Geometry
jno.domain holds a meshed geometry, its named regions, and the collocation points sampled on
them. Build the geometry with the shape DSL (gmsh-OpenCASCADE), load a mesh file, or pass a
point cloud.
import jno
from jno import shape
solid = (shape.rect(0, 0, 4, 1) - shape.disk(2, 1, 0.4)).extrude(0.6) # a strip under a roll
d = solid.domain() # one-liner; == jno.domain(solid)
x, y, z, t = d.variable("interior") # coords (+ a trailing time coord t)
xb, yb, zb, tb, nx, ny, nz = d.variable("top", normals=True, split=True) # a named boundary + outward normals
The shape DSL
A shape is an immutable build-plan: make primitives, combine them with boolean operators,
reshape with transforms, then hand the result to jno.domain. Every call returns a new shape.
Primitives
Each takes an optional size= (target element size near it).
| Primitive | Auto-named boundaries |
|---|---|
shape.rect(x0, y0, x1, y1) |
left right top bottom |
shape.disk(cx, cy, r) |
arc |
shape.polygon(points) |
e0 e1 … eN (one per edge) |
shape.box(x0, y0, z0, x1, y1, z1) |
left right top bottom front back |
shape.cylinder(x, y, z, dx, dy, dz, r) |
side top bottom (any axis) |
shape.sphere(cx, cy, cz, r) |
surface |
Path(…).face() |
per-segment (see Contours) |
interior (the area/volume) and boundary (all of it) are always present.
Combine
Transforms
| Transform | Meaning |
|---|---|
.extrude(h) |
sweep a 2-D shape straight up into 3-D |
.revolve(axis_point, axis_dir, angle=2π) |
rotate a 2-D profile about an axis (x- or y-axis through the origin); a partial angle gives a wedge/half-donut |
.sweep(path) |
sweep a 2-D profile along a smooth Path (bent pipe) |
.array(n, step=v) / .array(n, about=(pt, dir), angle=2π) |
n fused copies — linear or polar |
.translate(v) · .rotate(axis_point, axis_dir, angle) |
place/orient (names are preserved) |
.fillet(radius, where=None) |
round edges; where=f(x,y,z) selects them by midpoint |
.sized(size) |
set the target mesh size (scalar or f(x,y,z)) |
.structured(n=None) |
mesh an axis-aligned rect/box as a regular lattice (see below) |
extrude is a special case of sweep; revolve is a distinct rotation. Mesh size lives on the
shape it describes, not on jno.domain.
Contours with arcs
Path chains line_to/arc_to segments (points are 3-D, z defaults to 0); .face() closes it
into a 2-D shape. This is the general 2-D generator — a diameter plus a semicircular arc revolves
into a sphere:
from jno import Path
sphere = (
Path(0, -1)
.line_to(0, 1, name="axis") # the diameter, on the revolve axis
.arc_to(0, -1, through=(1, 0), name="dome") # a semicircular arc (3-point arc)
.face()
.revolve((0, 0, 0), (0, 1, 0)) # -> a sphere; the "dome" arc names its surface
)
An open Path is instead a sweep trajectory. Segments may be name='d as you draw them; the name
flows onto the swept face. A sharp line_to→line_to corner is rejected (round it with arc_to).
Naming & selection
Primitives auto-name their boundaries (table above); rename, merge, or carve regions afterwards with
d.tag, which accepts:
d.tag("inlet", lambda x, y, z: x < 1e-6) # a coordinate predicate
d.tag("outlet", lambda x, n, name: (n[:, 0] > 0.9) & (name != "left")) # coords + outward normal + current name
The second form selects boundary facets by position, orientation, and existing name — inclusion
and exclusion in one predicate. n is the outward normal (boundary only), name each facet's
current region.
A coordinate predicate works in 1-D too, where a boundary facet is a single vertex: d.tag("right_end",
lambda x: x > 1 - 1e-9) names an endpoint, carries its outward normal (-1 at the left end, +1 at the
right), and a u(right_end) - g term is an ordinary Dirichlet condition.
To name a region and grab its coordinates in one line, pass the predicate straight to
variable (it forwards to tag, then returns the split coords):
Mesh density
Control element size by attaching size= to a shape — gmsh's mesh-size fields do the work
(a Distance+Threshold field near a shape, a size callback for f(x,y,z)), combined by min:
shape.disk(2, 1, 0.4, size=0.02) # fine in the band around this shape's boundary
(strip - roll).sized(0.05) # a global size cap for the whole shape
solid.sized(lambda x, y, z: 0.03 + 0.10 * y) # graded: denser where the function is smaller
So "denser in a region" = give a small size= to the shape covering it, or a callable that is small
there.
Cell shape — .quad()
Meshes are simplicial by default (triangles, tetrahedra). .quad() asks gmsh to recombine them into
quadrilaterals, which works on any 2-D geometry:
d = shape.disk(0, 0, 1, size=0.1).quad().domain() # pure quads, curved boundary and all
(plate.quad() - hole).sized(0.05) # survives the plan operators, like .sized()
Like size= and .curved(), this is a property of the shape rather than an argument to the solve.
3-D needs .structured(). gmsh cannot hexahedral-mesh general geometry — Recombine3DAll on a
plain box returns 944 tetrahedra and no hexahedra — so hexes come from a regular lattice:
d = shape.box(0, 0, 0, 1, 1, 1).structured(n=16).quad().domain() # 4096 hexahedra
d = shape.rect(0, 0, 1, 1).structured(n=40).quad().domain() # 1600 quadrilaterals
.quad() and .structured() compose in either order.
Regular lattices — .structured()
.structured() meshes an axis-aligned rect/box as a regular grid instead of calling gmsh. Three
things follow that a gmsh mesh cannot give:
- hexahedra, as above — the lattice is the one 3-D plan that can be hex-meshed;
- a grid descriptor on
domain.grid, which is what letsjno.fdmtake its assembly-free 5-/7-point stencils instead of the cotangent operator, and what a nodal field reshapes against; - exactly matched opposite faces, so a whole-domain periodic tie collapses onto one DOF rather than holding to a tolerance.
d = shape.rect(0, 0, 1, 1, size=0.1).structured().domain() # counts from size=: 10x10 cells
d = shape.box(...).structured(n=(32, 16, 16)).domain() # explicit, per axis
d.grid # {"shape": (Nx, Ny[, Nz]), "spacing": (...), "origin": (...)}
u.reshape(d.grid["shape"]) # nodes are C-ordered
n counts cells, so a lattice has n + 1 nodes per axis and d.grid["shape"] is n + 1 —
consistent with the nx/ny/nz of every other grid in jNO. A 128×128 pixel grid for a
foundation model is therefore .structured(n=127). Omit n to derive it from the shape's size=.
A plan that cannot be a lattice — a CSG cut, a disk, a graded size= callable, .curved() — is
refused by name rather than quietly meshed with gmsh, since the caller who then reads d.grid or
expects hexes would otherwise fail somewhere else. The named faces (left/right/bottom/top,
plus front/back in 3-D), boundary, interior, and any .name()/.attach() region come with
the lattice as usual.
See the FEM guide's tensor-product section for what these support: volume terms and Dirichlet
conditions work; surface integrals, order > 1 and h-adaptivity refuse by name.
Multi-material regions
Name each material with .name(...) and combine with + to build a multi-material domain: the
sub-shapes are fragmented so element edges align exactly with every material interface (a conforming
mesh), and each cell is assigned to the first region — left-to-right order is priority — whose shape
contains its centroid. Each region becomes its own variable set; the outer boundary keeps its
auto-names and internal interface facets are not boundary.
plate = shape.rect(0, 0, 2, 1)
core = shape.disk(1, 0.5, 0.3)
d = (core.name("inclusion") + plate.name("matrix")).sized(0.05).domain()
d.variable("inclusion") # the disk's cells/points (a distinct material)
d.variable("matrix") # everything else
d.boundary_tags() # {left, right, top, bottom, boundary} — the plate's outer edges only
+ keeps the pieces as distinct materials with a conforming interface — unlike | (fuse), which
merges them into one. It composes n-ary (a + b + c), and shape.regions(inclusion=core,
matrix=plate) is the equivalent keyword form. Regions may overlap: here the disk overlaps the plate,
and inclusion wins inside the disk because it is listed first. Use each region tag to restrict a
jno.fem term or coefficient to that material (per-region volume integration), or to sample it in a
PINN. A multi-material shape is a top-level construct — call .domain() on it directly; it does not
compose with boolean operators or transforms.
Region names that are not valid Python identifiers go through the dict form:
shape.regions({"Quartz.1": q1, "Quartz.2": q2}). Dict order is priority order exactly as for
keywords, and the two forms combine (dict entries first).
Material properties — .attach(...)
A region can carry its own material properties, read back off the domain as a per-region
coefficient ready to drop into a weak form. d.k is exactly the per-region coefficient assembled from
every region that declared a k.
This is the preferred way to express materials. It puts each property next to the region it
describes, lets several properties travel together, and leaves the weak form reading in physical names
(d.k, d.eps) instead of coefficients assembled elsewhere. Use attach for these when
the mapping is data rather than a material — values computed elsewhere, or a case needing an explicit
default= for regions that genuinely have no value.
kri = shape.polygon(v).name("Kristall").attach(k=220.0, eps=0.794)
gas = shape.polygon(w).name("Gas").attach(k=0.186, eps=1.0)
d = (kri + gas).domain()
heat = d.k * (T.x*s.x + T.y*s.y) - d.q * s # one equation, both materials
A value may be anything jNO treats as a coefficient: a scalar, an array, a symbolic expression, a
typed view (ScalarView / VectorView / MatrixView — the view type survives, so d.eps @ u still
works), or a trainable jno.np.parameter, so an attached property can be fitted or differentiated
through. A plain function is also accepted and is called with the domain's spatial coordinates
when the property is read (.attach(k=lambda r, z: 2.0 + 0.5*z)); it has to be deferred that way
because a spatially varying coefficient is built from d.variable(...), which does not exist while
the geometry plan is being written.
d.attached("eps") gives the raw {region: value} mapping instead of the coefficient, for consumers
that need a dict — gap.emissivity(d.attached("eps")).
Rules worth knowing:
- Apply after
.name(...); likename, a later transform drops the attachment, and attaching to a shape that was never named raises. - Repeated calls merge (last wins), so properties can be built up in stages.
d.<name>raises if any region failed to declare that name, listing the ones that did not — a forgotten material surfaces at first use rather than as a region that silently conducts nothing. Use a bared.attach(name=...)to declare the default when some regions genuinely have none. This check coversshaperegions only. It reads_shape_regions, so on a mesh-file domain it never fires: a region you forgot to attach falls through to the bare-attach default rather than raising. That matters most for a coefficient whose absence changes the operator's character — a forgotten reluctivity leavesnu = 0and with it no curl-curl term at all — so on a mesh-file domain attach every region explicitly and do not rely on this guard.- Values must agree on one view type across regions; a matrix on one region and a vector on another raises rather than silently taking whichever came last.
- Properties are declared, not typed for volume regions: whether
epsis used as a volume or a surface quantity is decided by the term that consumes it. Boundary tags are the exception — seed.attach(...)below, where the kind is resolved when it is declared.
A property can also be attached after the domain exists, which is the only way to attach to a
domain.tag — or to a mesh-file domain, which has no shape to declare on:
d.tag("wall", lambda x, y: x < 1e-9)
d.tag("lid", lambda x, y: y > 1 - 1e-9)
d.attach("wall", h=25.0).attach("lid", h=5.0) # a SURFACE property, per boundary facet
Here the kind is decided once, from what the target owns on this mesh: a tag owning boundary facets
is a surface quantity (d.h becomes a per-facet coefficient, see the jno.fem docs), a tag
owning only cells is a volume quantity, and a tag owning both is ambiguous and raises rather than
guessing — split it in two, or build the coefficient explicitly.
Interfaces between materials are auto-named by the region pair, sorted — d.variable("inclusion|matrix")
gives every facet where those two materials meet (however many flat faces that spans — an E-core's
air|core boundary is one tag, not one per face). Impose a coupling/flux condition there, or sample it.
Interfaces are listed by d.interface_tags() and kept out of d.boundary_tags() (an interface is not
the outer boundary). When an interface is topologically disjoint — two separate inclusions, or the two
stacked layers of a winding — the connected pieces are additionally exposed as "a|b.0", "a|b.1", …
alongside the union "a|b".
Signed distance — .sdf(...)
contains answers inside or not; sdf answers how far — negative inside, zero on the boundary,
positive outside. It takes either an array of points or one coordinate per axis:
d = shape.sdf(points) # (N, dim) numpy or jax array -> an array
d = shape.sdf(x, y) # jno Variables -> an expression, usable in a form
The expression form is the useful one. Because the distance is exactly zero on the boundary, it turns a boundary condition that a PINN would otherwise have to learn into one that holds:
plate = jno.shape.rect(0, 0, 2, 1) - jno.shape.disk(0.7, 0.5, 0.28)
dom = plate.domain()
x, y = dom.variable("interior", split=True)[:2]
phi = plate.sdf(x, y) # 0 on every boundary, hole included
u = g + phi * net(x, y) # u == g on the boundary, for ANY network weights
No hand-written x * (1 - x) factor is needed, and none exists for a rectangle with a hole in it.
The same expression gives a material that changes by region without a conforming mesh:
eps = 1 + (eps_r - 1) * 0.5 * (1 - jno.np.tanh(shape.sdf(x, y) / w)).
What is exact, and what is only a bound
Distances to a single primitive — rect, box, disk, sphere, cylinder, polygon — are exact, and
translating or rotating preserves them. Combining shapes (|, &, -) is exact on the
boundary, which is all a boundary condition or a smoothed indicator needs, but strictly inside a
combined shape the value is only a bound on the true distance. sweep and fillet have no closed
form and raise; revolve is not wired yet.
Differentiable in the coordinates, not in the shape
Gradients with respect to the query point are finite everywhere, in float32 as well as float64.
The shape's own radii, corners and vertices are fixed numbers, so optimising the geometry itself
does not go through sdf.
Worked examples
strip = shape.rect(0, 0, 4, 1, size=0.1) # coarse in the bulk
roll = shape.disk(2, 1, 0.4, size=0.02) # fine near the contact arc
d = jno.domain((strip - roll).extrude(0.6)) # the carved arc is auto-named "arc"
xc, yc, zc, tc, nx, ny, nz = d.variable("arc", normals=True, split=True) # contact points + normals
Other domain sources
Pre-meshed file — physical-group names become region tags:
interior and boundary are derived automatically when the file does not define them, so a
mesh exported from anywhere is immediately usable — no physical groups required. The boundary is
topological (facets belonging to exactly one cell), which is also the only option for the many
files that store no surface block at all. Names the file does define always win: a physical group
called boundary keeps its own meaning, and only the missing tag is derived.
When the boundary falls into more than one connected shell, each is additionally exposed as
boundary_0, boundary_1, … so an internal cavity or a second body can be addressed on its own; a
single-shell part gains no numbered tags.
Two kinds of file are refused rather than half-loaded, both by name:
- Mixed cell types (a mesh with both tetrahedra and hexahedra, say). jNO assembles on one element family, so it would otherwise take the first block it recognised and silently ignore the rest — measured on a real gmsh benchmark as 70 % of the domain. The error reports the blocks and how many cells would be lost.
- A surface (shell) mesh — triangles in 3-D with no volume behind them, a routine CAD/STL export. Solving on a manifold is a different discretisation, not a missing setting.
Reader limits are reported as such rather than as jNO errors: an unsupported .msh version (only
2.2 and 4.1 are read) and gmsh element types meshio does not implement both say so, and say what to
re-export.
Point cloud — coordinates you already have (no mesh, so no .integrate()/FD):
1-D domains — an open path, exactly like every other dimension's shape:
jno.Path(0, 0).line_to(1, 0).curve(size=0.01).domain() (ends named left/right). This is the
form used throughout the docs; the jno.domain.line(...) shorthand still exists. jno.domain
also keeps the structured grids equi_distant_rect / poseidon and the point-cloud from_array.
For 2-D/3-D geometry build the shape with shape — shape.rect(...).domain(),
shape.box(...).domain(), and so on. (Shapely geometries and vertex lists are also still accepted.)
Mesh-free sampling — what a PINN actually needs
shape.domain() does not mesh. A shape knows its own extent, its own membership test and its
own boundary in closed form, so collocation points are drawn from the geometry directly:
d = jno.shape.box(0, 0, 0, 1, 1, 1).domain() # no gmsh, in 1-D, 2-D or 3-D
x, y, z, t = d.variable("interior", sample=(20_000, None), split=True)
Two things differ from sampling a mesh. 20_000 means twenty thousand — there is no node set to be
clipped to — and every draw is fresh, so an adaptive strategy explores the region instead of
reshuffling one fixed cloud. Tagging is unchanged, and works on the boundary as well as the interior:
d.tag("hot", lambda x, y, z: (x-.5)**2 + (y-.5)**2 + (z-.5)**2 < .04) # a lump of interior
d.tag("inlet", lambda x, y, z: x < 1e-9) # a face
bx, by, bz, t, nx, ny, nz = d.variable("inlet", sample=(500, None), normals=True, split=True)
Which of the two a predicate means is measured — jNO draws from both and sees which one it accepts
— because a boundary predicate applied to an interior draw matches nothing and would look like an
empty region. Boundary points land on the analytic surface, so a disk's samples lie on the circle to
machine precision with exactly radial normals, not on a chord with a per-facet normal. The
primitives' auto-names (left, arc, surface, …) are available before any mesh exists.
When a mesh does get built
Some things are defined on a mesh, not on the geometry: jno.fem and fem_symbols(),
.integrate(), the finite-difference schemes, .points / normals_by_tag / tag_indices, and
a facet predicate f(x, n, names). Reading any of them builds the mesh once, and says so:
Nothing you write has to change; a tag declared while mesh-free gains its mesh-derived half at that point.
Shapes with no closed form — the boundary tessellation
fillet has no analytic membership: it removes material near edges, so recursing to the child
would answer for the un-filleted solid — a wrong-but-plausible mask. It is served by meshing the
boundary only — the perimeter in 2-D, the surface in 3-D — which shape.tessellate() does
once and caches. No volume fill runs, and contains, sample_interior and sample_boundary
fall back to it automatically. The domain says which path it took:
INFO: Mesh-free domain from fillet plan (dim 3); tags [...] sample from a 372-facet boundary
tessellation.
What this costs. A facet is straight, so on a curved boundary the normal is piecewise
constant and O(h) wrong — 7.5° median on a unit sphere at h=0.5, 2.2° at h=0.15, halving
with h. Tighten it with .size(h). At a crease the facet normal is exact, because a facet
lies wholly on one face. Membership is the polyhedron's rather than the true solid's; the two
differ by the sagitta, O(h²).
sweep is not served this way. A sweep along a straight line is rewritten as an extrude
and stays fully analytic, so it never needed the tessellation. A sweep along an arc is
refused by name: gmsh returns that surface with its seam unsewn, and refining makes it worse —
measured across h = 0.5 → 0.08 the number of edges bounding a single facet runs 7, 7, 7, 10,
13, 16, 24. Without a closed surface there is no inside to test for, so that one meshes eagerly
through .build(), and the domain logs why rather than doing it silently.
Plans that stay eager
.name(...) / shape.regions(...) (region and interface tags are the mesher's conforming
sub-bodies) and .structured() (already a lattice) mesh at construction as before, rather than
being half-served.
translate, rotate, extrude and revolve are covered analytically — the query point is
mapped into the child's frame, and a revolved boundary is drawn from the profile weighted by its
swept radius (Pappus), so the inner rim of a ring is not over-sampled relative to the outer.
Sampling, time, and batching
x, y, z, t = d.variable("interior") # all interior mesh nodes
x, y, z, t = d.variable("interior", sample=(500, None)) # 500 sampled points
xb, yb, zb, tb, nx, ny, nz = d.variable("top", normals=True, split=True) # boundary + outward normals
The first line — no count — is read from whether you declared a resolution. size= is that
declaration: asking for a mesh of a given density and then for "the interior" unambiguously means
its nodes, so it builds that mesh and hands them back, exactly as before. A convergence study that
varies size therefore keeps working unchanged.
With no size= there is no declared resolution and no node set, and "no count" could equally
mean collocation points or nodes — which depends on what you do afterwards. Neither is chosen for
you:
ValueError: domain.variable('interior'): this domain is mesh-free and no mesh size was declared,
so the tag has no node set to hand back and no natural point count. Say which you want:
- continuous collocation points: variable('interior', sample=(n, None))
- a mesh's nodes: give the shape a size, e.g. shape.rect(..., size=0.05),
or read d.mesh first
Guessing would be silent rather than wrong-looking: finite differences over a single collocation
point return a number, it is just not the one you asked for. An explicit sample=(n, None) always
means mesh-free, size= or not.
variable always returns a trailing time coordinate t (a constant for steady domains), so a 3-D
domain unpacks as (x, y, z, t). Time-dependent domains take time=(t0, t1, n); variable("initial")
is the t=0 slice. Multiply a
domain by an integer B (B * jno.domain(...), or B * shape.domain()) to replicate it across B
operator-learning samples. shape.domain(**kwargs) forwards the domain arguments (time=, sample=,
name=, …) — but not a constructor or mesh_size, since size lives on the shape — so a full domain is
one line. d.plot("domain.png") renders the mesh, regions, and normals.
Attaching a tensor
Pass an array instead of a sampling spec to attach it as a tensor tag — the operator-learning pattern, where each batch sample carries its own field:
from jno.domain.geometries import Geometries
dom = 256 * jno.domain(constructor=Geometries.poseidon(nx=64, ny=64))
dom.variable("_f", forcing) # (256, 64, 64, 1) — the shape you actually have
Context tensors are laid out (B, T, ...). On a steady domain the time axis is 1 by
definition, so it is inserted for you: attach (B, H, W, C) and it is stored as (B, 1, H, W, C),
and per-node data (B, n, k) as (B, 1, n, k). Writing the axis yourself still works and is left
untouched.
This is load-bearing, not cosmetic
Without a time axis the compiler reads the first grid axis as the timestep count and hands
the expression a single slice — an (4, 8, 5, 1) attach used to arrive as (5, 1), with the
rest of the field discarded and no error raised.
On a time-dependent domain axis 1 is genuinely ambiguous (is it T, or a grid axis?), so a
mismatch raises instead: pass T entries, or 1 to share one field across all steps.
Only tensors of rank ≥ 3 whose leading dimension is B or 1 are touched — a parameter like
(B, 1, 1) or (B, F), or a shared lookup table, is left exactly as given. Multiply the domain
by B before attaching, so the batch count is known.
Per-node data (B, n, k) — one value per node per sample, the operator-learning target — is
stored as (B, 1, n, k) when n is the node count of one of the domain's point sets. It used to
arrive as (B, k), the first node of each sample, with nothing raised. When axis 1 could be read
two ways (n equals B or the timestep count) or matches no point set, the attach raises and
names the layout to write instead: (B, 1, n, k) for one value per node (or any other per-sample
array), (B, T, 1, k) for one value per timestep. On a time grid, (B, T, k) with no point set of
T nodes is one value per sample and step, and is stored as (B, T, 1, k); a lazy source in that
layout is refused (reshaping it would read it) — store it as (B, T, 1, k) yourself.
Datasets larger than memory
The same slot accepts a lazy source — anything exposing .shape and __getitem__. The handle is
stored unread and sliced one batch at a time, so the dataset never has to fit:
dom.variable("_f", h5py.File("well.h5")["forcing"]) # or zarr, tensorstore, np.memmap
crux.solve(epochs=600, batchsize=32, offload_data=True)
No new dependency and no new argument — the contract is duck-typed, so jNO imports none of those
libraries. offload_data=True is required, because the on-device path holds the whole array; asking
for it with a lazy source raises and names the fix.
What a lazy source gives up
Indices are sorted before each gather, since h5py and zarr only accept increasing fancy indices. That reorders samples within a batch — invisible to a mean-reduced loss, but a run is not bitwise identical to one recorded before this existed.
The (B, T, ...) time axis is validated, not inserted: rewriting the layout would mean
reading the source, which is the one thing it exists to avoid. Store it with the time axis, or
pass an eager array.
Adaptive resampling cannot target a lazy tag — it replaces the whole point set each round. It raises rather than silently skipping.