Skip to content

Transform

This page contains classes defined in hakowan.transform module.

Composable geometry and attribute transform models.

Transform dataclass

Transform is the base class of all transforms.

Source code in hakowan/grammar/transform/transform.py
@dataclass(kw_only=True, slots=True)
class Transform:
    """Transform is the base class of all transforms."""

    _child: Optional["Transform"] = None

    def __imul__(self, other: "Transform") -> "Transform":
        """In place update by applying another transform after the current transform.

        Args:
            other: The transform to apply after the current transform.

        """
        # Because transform may be used in multiple places in the layer graph, and it may have a
        # child in the future, it must be deep copied to avoid undesired side effects.
        if self._child is None:
            self._child = copy.deepcopy(other)
        else:
            t = self._child
            while t._child is not None:
                t = t._child
            t._child = copy.deepcopy(other)
        return self

    def __mul__(self, other: "Transform") -> "Transform":
        """Apply another transform, `other`, after the current transform.

        Args:
            other: The other transform.

        Returns: A new transform that is the composition of the current transform and `other`.

        """
        r = copy.deepcopy(self)
        r *= other
        return r

__imul__(other)

In place update by applying another transform after the current transform.

Parameters:

Name Type Description Default
other Transform

The transform to apply after the current transform.

required
Source code in hakowan/grammar/transform/transform.py
def __imul__(self, other: "Transform") -> "Transform":
    """In place update by applying another transform after the current transform.

    Args:
        other: The transform to apply after the current transform.

    """
    # Because transform may be used in multiple places in the layer graph, and it may have a
    # child in the future, it must be deep copied to avoid undesired side effects.
    if self._child is None:
        self._child = copy.deepcopy(other)
    else:
        t = self._child
        while t._child is not None:
            t = t._child
        t._child = copy.deepcopy(other)
    return self

__mul__(other)

Apply another transform, other, after the current transform.

Parameters:

Name Type Description Default
other Transform

The other transform.

required

Returns: A new transform that is the composition of the current transform and other.

Source code in hakowan/grammar/transform/transform.py
def __mul__(self, other: "Transform") -> "Transform":
    """Apply another transform, `other`, after the current transform.

    Args:
        other: The other transform.

    Returns: A new transform that is the composition of the current transform and `other`.

    """
    r = copy.deepcopy(self)
    r *= other
    return r

Filter dataclass

Bases: Transform

Filter data based on a condition.

Attributes:

Name Type Description
data AttributeLike | None

The attribute to filter on. If None, the vertex position is used.

condition Callable

A callable that takes a single argument, the value of the attribute, and returns a boolean indicating whether the data should be kept.

Source code in hakowan/grammar/transform/transform.py
@dataclass(slots=True)
class Filter(Transform):
    """Filter data based on a condition.

    Attributes:
        data: The attribute to filter on. If None, the vertex position is used.
        condition: A callable that takes a single argument, the value of the attribute, and returns
            a boolean indicating whether the data should be kept.

    """

    data: AttributeLike | None = None
    condition: Callable = field(default=_default_condition)

Clip dataclass

Bases: Transform

Clip the mesh against a plane, keeping only the half-space the normal points into.

Unlike :class:Filter, which keeps or drops whole facets, Clip slices through triangles: facets straddling the plane are cut so that only the part on the positive side of the plane is kept (partial triangles are produced). The exposed cross-section is left open (it is not capped).

The plane is defined in the data/object coordinate space (the same space the raw mesh lives in, before any layer-level :class:Affine transform), keeping its meaning consistent with :class:Filter.

Attributes:

Name Type Description
point ArrayLike

A point lying on the clipping plane.

normal ArrayLike

The plane normal. The half-space where dot(normal, x - point) >= 0 is kept; the rest is clipped away.

Source code in hakowan/grammar/transform/transform.py
@dataclass(slots=True)
class Clip(Transform):
    """Clip the mesh against a plane, keeping only the half-space the normal points into.

    Unlike :class:`Filter`, which keeps or drops whole facets, ``Clip`` slices
    through triangles: facets straddling the plane are cut so that only the part
    on the positive side of the plane is kept (partial triangles are produced).
    The exposed cross-section is left open (it is not capped).

    The plane is defined in the data/object coordinate space (the same space the
    raw mesh lives in, before any layer-level :class:`Affine` transform), keeping
    its meaning consistent with :class:`Filter`.

    Attributes:
        point: A point lying on the clipping plane.
        normal: The plane normal. The half-space where
            ``dot(normal, x - point) >= 0`` is kept; the rest is clipped away.

    """

    point: npt.ArrayLike = field(default_factory=lambda: np.zeros(3))
    normal: npt.ArrayLike = field(default_factory=lambda: np.array([1.0, 0.0, 0.0]))

UVMesh dataclass

Bases: Transform

Extract UV mesh from data.

Attributes:

Name Type Description
uv AttributeLike | None

The attribute defining the UV coordinates. If None, automatically deetect the UV attribute from the data.

Source code in hakowan/grammar/transform/transform.py
@dataclass(slots=True)
class UVMesh(Transform):
    """Extract UV mesh from data.

    Attributes:
        uv: The attribute defining the UV coordinates. If None, automatically deetect the UV
            attribute from the data.

    """

    uv: AttributeLike | None = None

Affine dataclass

Bases: Transform

Apply affine transformation to data.

Attributes:

Name Type Description
matrix ArrayLike

The 4x4 affine matrix to apply.

Source code in hakowan/grammar/transform/transform.py
@dataclass(slots=True)
class Affine(Transform):
    """Apply affine transformation to data.

    Attributes:
        matrix: The 4x4 affine matrix to apply.

    """

    matrix: npt.ArrayLike

PrincipalAxes dataclass

Bases: Transform

Align PCA principal directions of vertex positions with a target orthonormal frame.

Covariance is computed from the current data-frame vertex positions. Principal axes are ordered by descending eigenvalue (largest variance first). The rotation and translation match those directions to the columns of frame: column 0 is the direction for the largest-variance axis, column 1 for the second, column 2 for the third.

The resulting affine is pre-composed with any prior global transform on the layer, so earlier Affine transforms (translate / rotate / scale) are preserved and applied before this PCA-based alignment.

Attributes:

Name Type Description
frame ArrayLike

3x3 matrix whose columns are the target orthonormal axes (see above).

orthonormalize_frame bool

If True (default), orthonormalize frame with QR so mildly skewed inputs still yield a proper rotation.

Source code in hakowan/grammar/transform/transform.py
@dataclass(slots=True)
class PrincipalAxes(Transform):
    """Align PCA principal directions of vertex positions with a target orthonormal frame.

    Covariance is computed from the current data-frame vertex positions. Principal
    axes are ordered by descending eigenvalue (largest variance first). The rotation and
    translation match those directions to the columns of ``frame``: column 0 is the
    direction for the largest-variance axis, column 1 for the second, column 2 for the third.

    The resulting affine is pre-composed with any prior global transform on the layer, so
    earlier ``Affine`` transforms (translate / rotate / scale) are preserved and applied
    before this PCA-based alignment.

    Attributes:
        frame: 3x3 matrix whose columns are the target orthonormal axes (see above).
        orthonormalize_frame: If True (default), orthonormalize ``frame`` with QR so mildly
            skewed inputs still yield a proper rotation.

    """

    frame: npt.ArrayLike = field(default_factory=lambda: np.eye(3))
    orthonormalize_frame: bool = True

Normalize dataclass

Bases: Transform

Recenter and uniformly scale the mesh to fit a unit box centered at the origin.

Vertex positions are translated so the bounding-box center sits at the origin and uniformly scaled so the bounding-box diagonal is 2 (i.e. the geometry fits inside the unit sphere). Use it to bring meshes from unrelated coordinate systems to a comparable on-screen size — for example when laying several meshes side by side with :meth:Layer.juxtapose.

Unlike a layer-level :class:Affine, this mutates the data-frame vertices in place, so it normalizes the geometry as it currently stands (after any earlier mesh-mutating transforms) and ignores prior global affine transforms — matching how :class:PrincipalAxes reads object-space positions.

Attributes:

Name Type Description
normalize_normals bool

Re-normalize normal attributes to unit length. Default True.

normalize_tangents_bitangents bool

Re-normalize tangent/bitangent attributes to unit length. Default True.

Source code in hakowan/grammar/transform/transform.py
@dataclass(slots=True)
class Normalize(Transform):
    """Recenter and uniformly scale the mesh to fit a unit box centered at the origin.

    Vertex positions are translated so the bounding-box center sits at the origin and
    uniformly scaled so the bounding-box diagonal is 2 (i.e. the geometry fits inside
    the unit sphere). Use it to bring meshes from unrelated coordinate systems to a
    comparable on-screen size — for example when laying several meshes side by side
    with :meth:`Layer.juxtapose`.

    Unlike a layer-level :class:`Affine`, this mutates the data-frame vertices in
    place, so it normalizes the geometry as it currently stands (after any earlier
    mesh-mutating transforms) and ignores prior global affine transforms — matching
    how :class:`PrincipalAxes` reads object-space positions.

    Attributes:
        normalize_normals: Re-normalize normal attributes to unit length. Default True.
        normalize_tangents_bitangents: Re-normalize tangent/bitangent attributes to
            unit length. Default True.

    """

    normalize_normals: bool = True
    normalize_tangents_bitangents: bool = True

Compute dataclass

Bases: Transform

Compute new attributes from the current data frame.

Attributes:

Name Type Description
x str | None

Extract the x coordinate as an attribute.

y str | None

Extract the y coordinate as an attribute.

z str | None

Extract the z coordinate as an attribute.

normal str | None

Compute the normal vector field as an attribute.

vertex_normal str | None

Compute the vertex normal vector field as an attribute.

facet_normal str | None

Compute the facet normal vector field as an attribute.

component str | None

Compute connected component ids.

Source code in hakowan/grammar/transform/transform.py
@dataclass(slots=True, kw_only=True)
class Compute(Transform):
    """Compute new attributes from the current data frame.

    Attributes:
        x: Extract the x coordinate as an attribute.
        y: Extract the y coordinate as an attribute.
        z: Extract the z coordinate as an attribute.
        normal: Compute the normal vector field as an attribute.
        vertex_normal: Compute the vertex normal vector field as an attribute.
        facet_normal: Compute the facet normal vector field as an attribute.
        component: Compute connected component ids.

    """

    x: str | None = None
    y: str | None = None
    z: str | None = None
    normal: str | None = None
    vertex_normal: str | None = None
    facet_normal: str | None = None
    component: str | None = None

Explode dataclass

Bases: Transform

Explode data into multiple pieces.

Attributes:

Name Type Description
pieces AttributeLike

The attribute defining the pieces.

magnitude float

The magnitude of the displacement.

Source code in hakowan/grammar/transform/transform.py
@dataclass(slots=True)
class Explode(Transform):
    """Explode data into multiple pieces.

    Attributes:
        pieces: The attribute defining the pieces.
        magnitude: The magnitude of the displacement.

    """

    pieces: AttributeLike
    magnitude: float = 1

Norm dataclass

Bases: Transform

Compute the row-wise norm of a given vector attribute.

Attributes:

Name Type Description
data AttributeLike

The vector attribute to compute the norm on.

norm_attr_name str

The name of the output norm attribute.

order int

The order of the norm. Default is 2, which is the L2 norm.

Source code in hakowan/grammar/transform/transform.py
@dataclass(slots=True)
class Norm(Transform):
    """Compute the row-wise norm of a given vector attribute.

    Attributes:
        data: The vector attribute to compute the norm on.
        norm_attr_name: The name of the output norm attribute.
        order: The order of the norm. Default is 2, which is the L2 norm.

    """

    data: AttributeLike
    norm_attr_name: str
    order: int = 2

Boundary dataclass

Bases: Transform

Compute the boundary of a mesh.

Attributes:

Name Type Description
attributes list[str]

The attributes to take into account when computing the boundary. i.e. discontinuities in these attributes will be considered as boundaries.

Source code in hakowan/grammar/transform/transform.py
@dataclass(slots=True)
class Boundary(Transform):
    """Compute the boundary of a mesh.

    Attributes:
        attributes: The attributes to take into account when computing the boundary.
            i.e. discontinuities in these attributes will be considered as boundaries.

    """

    attributes: list[str] = field(default_factory=list)

Streamline dataclass

Bases: Transform

Replace a triangular surface with traced vector- or cross-field curves.

Hakowan resolves facet, vertex, corner, and indexed three-channel fields to one tangent direction per facet. Vertex fields use Levi-Civita transport and symmetry-aware averaging (1-RoSy for vectors, 4-RoSy for cross fields); corner and indexed fields use arithmetic facet averaging. Traces cross triangle edges exactly and parallel-transport directions between facets.

The output mesh stores streamline points as vertices and consecutive line segments as two-vertex facets. A per-vertex int32 attribute named by id_attr_name identifies each streamline.

Attributes:

Name Type Description
vec_field AttributeLike

Three-channel vector attribute. Facet values are used directly; other supported domains are converted to facets.

n int

Number of blue-noise seed facets. Default 50. Ordinary fields produce up to n bidirectional streamlines; cross fields produce up to 2 * n by tracing both orthogonal axes.

cross_field bool

Treat the field as 4-RoSy. Default True.

length float | None

Maximum object-space length per half-trace, measured before layer-level affine transforms. A complete bidirectional trace can approach twice this length. None traces until a boundary or another termination condition. Default None.

seed int

RNG seed passed to blue-noise sampling. Default 0.

min_length int

Minimum retained sample-point count. Default 3.

max_steps int | None

Edge-crossing cap per half-trace. None uses half the number of facets.

id_attr_name str

Output per-vertex streamline-ID attribute name. Default _hakowan_streamline_id.

Source code in hakowan/grammar/transform/transform.py
@dataclass(slots=True, kw_only=True)
class Streamline(Transform):
    """Replace a triangular surface with traced vector- or cross-field curves.

    Hakowan resolves facet, vertex, corner, and indexed three-channel fields to
    one tangent direction per facet. Vertex fields use Levi-Civita transport and
    symmetry-aware averaging (1-RoSy for vectors, 4-RoSy for cross fields);
    corner and indexed fields use arithmetic facet averaging. Traces cross
    triangle edges exactly and parallel-transport directions between facets.

    The output mesh stores streamline points as vertices and consecutive line
    segments as two-vertex facets. A per-vertex ``int32`` attribute named by
    ``id_attr_name`` identifies each streamline.

    Attributes:
        vec_field: Three-channel vector attribute. Facet values are used
            directly; other supported domains are converted to facets.
        n: Number of blue-noise seed facets. Default 50. Ordinary fields produce
            up to ``n`` bidirectional streamlines; cross fields produce up to
            ``2 * n`` by tracing both orthogonal axes.
        cross_field: Treat the field as 4-RoSy. Default True.
        length: Maximum object-space length per half-trace, measured before
            layer-level affine transforms. A complete bidirectional trace can
            approach twice this length. ``None`` traces until a boundary or
            another termination condition. Default None.
        seed: RNG seed passed to blue-noise sampling. Default 0.
        min_length: Minimum retained sample-point count. Default 3.
        max_steps: Edge-crossing cap per half-trace. ``None`` uses half the
            number of facets.
        id_attr_name: Output per-vertex streamline-ID attribute name. Default
            ``_hakowan_streamline_id``.
    """

    vec_field: AttributeLike
    n: int = 50
    cross_field: bool = True
    length: float | None = None
    seed: int = 0
    min_length: int = 3
    max_steps: int | None = None
    id_attr_name: str = "_hakowan_streamline_id"

Fur dataclass

Bases: Transform

Replace a surface with tapered strands flowing along a vector field.

Each strand is a short, tapered curve that grows from the surface, leans in the direction of the vector field, and curls toward the surface flow — so a dense collection of them reads as realistic fur combed along the field. The output is a vertex-only mesh whose 2-vertex polygonal faces encode the line segments of every strand, suitable for the curve mark paired with a Hair material.

Two internal per-vertex attributes are written on the output mesh: _hakowan_strand_id (int32) identifies which strand each point belongs to, and _hakowan_strand_radius (float64) carries the root-to-tip taper radius. The Blender backend groups points by strand id into continuous tapered hair curves (rendered with the Principled Hair BSDF when a Hair material is used); the Mitsuba and WebGL backends render the same strands as tapered tubes, using the strand radius as the curve size when no size channel is set.

All lengths below are measured in object space on the data-frame mesh (before any layer-level affine transforms).

Attributes:

Name Type Description
vec_field AttributeLike

The per-facet vector field attribute name. Vertex- or corner-domain attributes are averaged to per-facet first.

n int

Number of fur strands to grow. Seed points are drawn area-uniformly over the surface. Default 2000.

length float | None

Strand length. None (default) picks 5% of the mesh bounding-box diagonal.

lift float

Angle in degrees by which each strand rises off the surface at its root (0 = lies flat along the field, 90 = stands straight up). Default 30.

curl float

How strongly the strand curls back toward the surface flow direction as it grows (0 = straight, larger = more droop). Default 0.35.

segments int

Number of segments per strand (points per strand is segments + 1). Higher gives smoother curls. Default 6.

root_radius float | None

Strand radius at the root. None (default) picks 6% of length.

tip_radius float

Strand radius at the tip. Default 0 (pointed hair tip).

randomness float

Amount of natural per-strand variation in length, lift, direction and curl, in [0, 1]. Default 0.3.

follow_surface bool

When True, trace each strand as a short streamline on the surface (following the field and the surface curvature) and give it only a gentle lift/curl, so it hugs the surface instead of standing off it. When False (default), strands are analytic and lean off the surface by lift.

children int

Number of child hairs grown around each guide strand for dense, clumped fur. 0 (default) keeps guide strands only. Only the Blender backend expands children (via a Geometry Nodes modifier); the Mitsuba and WebGL backends render the guide strands alone.

clump float

How strongly child-hair tips converge onto their guide strand, in [0, 1] (0 = parallel children, 1 = tips meet at the guide tip). Only used when children > 0. Default 0.6.

spread float | None

Radius over which child-hair roots scatter around each guide root. None (default) picks 15% of length. Only used when children > 0.

seed int

RNG seed for seed-point sampling and per-strand variation. Default 0.

Source code in hakowan/grammar/transform/transform.py
@dataclass(slots=True, kw_only=True)
class Fur(Transform):
    """Replace a surface with tapered strands flowing along a vector field.

    Each strand is a short, tapered curve that grows from the surface, leans in
    the direction of the vector field, and curls toward the surface flow — so a
    dense collection of them reads as realistic fur combed along the field.  The
    output is a vertex-only mesh whose 2-vertex polygonal faces encode the line
    segments of every strand, suitable for the ``curve`` mark paired with a
    ``Hair`` material.

    Two internal per-vertex attributes are written on the output mesh:
    ``_hakowan_strand_id`` (``int32``) identifies which strand each point
    belongs to, and ``_hakowan_strand_radius`` (``float64``) carries the
    root-to-tip taper radius.  The Blender backend groups points by strand id
    into continuous tapered hair curves (rendered with the Principled Hair BSDF
    when a ``Hair`` material is used); the Mitsuba and WebGL backends render the
    same strands as tapered tubes, using the strand radius as the curve size
    when no ``size`` channel is set.

    All lengths below are measured in object space on the data-frame mesh
    (before any layer-level affine transforms).

    Attributes:
        vec_field: The per-facet vector field attribute name.  Vertex- or
            corner-domain attributes are averaged to per-facet first.
        n: Number of fur strands to grow.  Seed points are drawn area-uniformly
            over the surface.  Default 2000.
        length: Strand length.  ``None`` (default) picks 5% of the mesh
            bounding-box diagonal.
        lift: Angle in degrees by which each strand rises off the surface at its
            root (0 = lies flat along the field, 90 = stands straight up).
            Default 30.
        curl: How strongly the strand curls back toward the surface flow
            direction as it grows (0 = straight, larger = more droop).  Default
            0.35.
        segments: Number of segments per strand (points per strand is
            ``segments + 1``).  Higher gives smoother curls.  Default 6.
        root_radius: Strand radius at the root.  ``None`` (default) picks 6% of
            ``length``.
        tip_radius: Strand radius at the tip.  Default 0 (pointed hair tip).
        randomness: Amount of natural per-strand variation in length, lift,
            direction and curl, in ``[0, 1]``.  Default 0.3.
        follow_surface: When True, trace each strand as a short streamline on the
            surface (following the field and the surface curvature) and give it
            only a gentle lift/curl, so it hugs the surface instead of standing
            off it.  When False (default), strands are analytic and lean off the
            surface by ``lift``.
        children: Number of child hairs grown around each guide strand for dense,
            clumped fur.  ``0`` (default) keeps guide strands only.  Only the
            Blender backend expands children (via a Geometry Nodes modifier); the
            Mitsuba and WebGL backends render the guide strands alone.
        clump: How strongly child-hair tips converge onto their guide strand, in
            ``[0, 1]`` (0 = parallel children, 1 = tips meet at the guide tip).
            Only used when ``children > 0``.  Default 0.6.
        spread: Radius over which child-hair roots scatter around each guide
            root.  ``None`` (default) picks 15% of ``length``.  Only used when
            ``children > 0``.
        seed: RNG seed for seed-point sampling and per-strand variation.
            Default 0.

    """

    vec_field: AttributeLike
    n: int = 2000
    length: float | None = None
    lift: float = 30.0
    curl: float = 0.35
    segments: int = 6
    root_radius: float | None = None
    tip_radius: float = 0.0
    randomness: float = 0.3
    follow_surface: bool = False
    children: int = 0
    clump: float = 0.6
    spread: float | None = None
    seed: int = 0