Write your own geometry or material
Implement the GeometrySpec and MaterialSpec protocols, or subclass GeometryBase to get to_mask for free, so that new shapes and materials work in a Core scene.
Structure checks two runtime protocols, not base classes. Any object that has the right attributes and methods works. This is how solver packages and Plugins add their own shapes and materials.
The geometry contract#
GeometrySpec requires:
kind: a string that names the shape type;to_mesh(segments=16): returns(vertices, faces), a(N, 3)float tensor and a(M, 3)integer tensor in world coordinates.
Solvers that work on volumes also call signed_distance(x, y, z) and to_mask(x, y, z, offset=0.0, beta=None); check what the solver needs.
Smallest possible geometry#
import torch
from witwin.core import GeometrySpec, PhysicalMaterial, Scene, Structure
class Tetrahedron:
kind = "tetrahedron"
def __init__(self, vertices):
self.vertices = torch.as_tensor(vertices, dtype=torch.float32)
self.faces = torch.tensor([[0, 2, 1], [0, 1, 3], [0, 3, 2], [1, 2, 3]])
def to_mesh(self, segments=16):
return self.vertices, self.faces
tet = Tetrahedron([[0, 0, 0], [1, 0, 0], [0, 1, 0], [0, 0, 1]])
print(isinstance(tet, GeometrySpec))
scene = Scene(structures=[Structure(tet, PhysicalMaterial())])TrueSubclass GeometryBase for volume queries#
GeometryBase stores position and rotation as tensors (with the device rules described in Tensors and gradients) and implements to_mask from your signed_distance. You write signed_distance and to_mesh:
from witwin.core import Cylinder, GeometryBase, Sphere
class Capsule(GeometryBase):
"""A z-aligned capsule: a cylinder of `length` with hemispherical caps. Rotation is not supported."""
kind = "capsule"
def __init__(self, position=(0.0, 0.0, 0.0), radius=0.25, length=1.0, *, device=None):
super().__init__(position=position, rotation=None, device=device)
self.radius = torch.as_tensor(radius, dtype=torch.float32, device=self.device)
self.length = torch.as_tensor(length, dtype=torch.float32, device=self.device)
def signed_distance(self, xx, yy, zz):
dz = (zz - self.position[2]).abs() - self.length / 2
radial = torch.sqrt((xx - self.position[0]) ** 2 + (yy - self.position[1]) ** 2)
return torch.sqrt(radial**2 + dz.clamp_min(0.0) ** 2) - self.radius
def to_mesh(self, segments=16):
cap = torch.stack((torch.zeros(()), torch.zeros(()), self.length / 2))
parts = [
Cylinder(position=self.position, radius=self.radius, height=self.length).to_mesh(segments),
Sphere(position=self.position + cap, radius=self.radius).to_mesh(segments),
Sphere(position=self.position - cap, radius=self.radius).to_mesh(segments),
]
vertices, faces, offset = [], [], 0
for v, f in parts:
vertices.append(v)
faces.append(f + offset)
offset += v.shape[0]
return torch.cat(vertices), torch.cat(faces)
capsule = Capsule(radius=0.25, length=1.0)
z = torch.tensor([0.0, 0.5, 0.75, 1.0])
print(capsule.signed_distance(torch.zeros(4), torch.zeros(4), z))
print(capsule.to_mask(torch.zeros(4), torch.zeros(4), z, beta=0.01).round(decimals=3))tensor([-0.2500, -0.2500, 0.0000, 0.2500])
tensor([1.0000, 1.0000, 0.5000, 0.0000])The mesh here is the union of three closed meshes. That is fine for display, but not watertight; build a single surface if a solver needs one.
Let versions see your tensors#
A scene fingerprints the tensors it can reach from each geometry's instance attributes (attributes whose names end in _cache are skipped). Keep every tensor that defines the shape as an attribute, as the examples above do, so that in-place updates change geometry_version. Topology tracking looks only at an attribute named _faces_tensor, which Mesh uses; a custom geometry with changing faces should be replaced through Scene.with_structure_geometry(..., topology_changed=True).
The material contract#
MaterialSpec requires:
name: a string orNone;capabilities(): returns aMaterialCapabilities;evaluate_static(): returns aStaticMaterialSample(eps_r, mu_r=1.0, sigma_e=0.0);evaluate_at_frequency(frequency): returns aFrequencyMaterialSample(eps_r, mu_r=1.0, sigma_e=0.0).
This example interpolates a measured permittivity table:
import numpy as np
from witwin.core import Box, FrequencyMaterialSample, MaterialCapabilities, MaterialSpec, StaticMaterialSample
class TabulatedMaterial:
def __init__(self, name, frequencies_hz, eps_real, eps_imag, material_id=None):
self.name = name
self.material_id = material_id
self._f = np.asarray(frequencies_hz, dtype=float)
self._re = np.asarray(eps_real, dtype=float)
self._im = np.asarray(eps_imag, dtype=float)
def capabilities(self):
return MaterialCapabilities(conductive=bool(np.any(self._im != 0.0)), dispersive=True)
def evaluate_static(self):
return StaticMaterialSample(eps_r=float(self._re[0]))
def evaluate_at_frequency(self, frequency):
f = float(frequency)
return FrequencyMaterialSample(eps_r=complex(np.interp(f, self._f, self._re), np.interp(f, self._f, self._im)))
board = TabulatedMaterial("FR-4", [1e9, 10e9], [4.4, 4.2], [-0.08, -0.09], material_id=50)
print(isinstance(board, MaterialSpec), board.evaluate_at_frequency(5.5e9).eps_r)
structure = Structure(Box(size=(0.1, 0.1, 0.0016)), board)
print(int(structure.material_id))True (4.300000000000001-0.08499999999999999j)
50A structure takes its material_id from the material's material_id attribute when it is not None; otherwise Core allocates one. Give your material a stable material_id and a version attribute: the scene's material_version reads both. Solvers decide which materials they accept, usually by checking capabilities().