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)) |
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.
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 d.by_region({...}) assembled from
every region that declared a k:
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. Used.by_region({...}, default=...)explicitly when some regions genuinely have none.- 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 by_tag 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".
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:
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.)
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
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.