Skip to content

Geometry

Tagged CAD: the geometry seam, tagged build123d shapes, cross-sections, frame conversion, the winding packer and build123d helpers.

femforge.geometry

Tagged CAD infrastructure: the GeometryArea seam, tagged build123d shapes, cross-sections, frame conversion and the winding packer.

Modules:

  • build123d_geometry –

    A geometry authored as a build123d tagged assembly.

  • config –

    The parametric-geometry config area: a geometry the driver materializes into a STEP before meshing.

  • frame –

    Convert an authored STEP into the frame the solve meshes.

  • section –

    Tagged cross-sections of tagged 3D solids: the quarter-wedge cutaway and the axisymmetric r–z section the 2D solve meshes.

  • tags –

    Tagged build123d shapes whose tags ride their STEP names into the mesh.

  • utils –

    build123d helpers: boundary boxes, rectangles, assembly-tree pruning and collinearity checks.

  • winding –

    Round-wire winding packer: the orthocyclic layer stack a wire settles into in a rectangular window, every wire position, and the derived electrical quantities.

Classes:

  • Build123dGeometry –

    A GeometryArea whose STEP is the export of model, the tagged assembly in the authored frame.

  • GeometryArea –

    A parametric geometry the driver builds into a STEP in the authored frame; config.frame converts it for the solve.

Build123dGeometry pydantic-model

Bases: GeometryArea

A GeometryArea whose STEP is the export of model, the tagged assembly in the authored frame.

Config:

  • arbitrary_types_allowed: True
  • use_attribute_docstrings: True
  • extra: allow

model abstractmethod

model() -> TaggedCompound

The tagged assembly in the authored frame.

Source code in src/femforge/geometry/build123d_geometry.py
@abstractmethod
def model(self) -> TaggedCompound:
    """The tagged assembly in the authored frame."""

build_step

build_step(path: Path) -> Path

Export model as a STEP at path and return it.

Source code in src/femforge/geometry/build123d_geometry.py
def build_step(self, path: Path) -> Path:
    """Export [`model`][] as a STEP at ``path`` and return it."""
    self.model().export_step(str(path))
    return path

GeometryArea pydantic-model

Bases: ConfigArea

A parametric geometry the driver builds into a STEP in the authored frame; config.frame converts it for the solve.

build_step

build_step(path: Path) -> Path

Write the geometry as a STEP at path, in the authored frame, and return it.

Source code in src/femforge/geometry/config.py
def build_step(self, path: Path) -> Path:
    """Write the geometry as a STEP at ``path``, in the authored frame, and return it."""
    raise NotImplementedError

femforge.geometry.section

Tagged cross-sections of tagged 3D solids: the quarter-wedge cutaway and the axisymmetric r–z section the 2D solve meshes.

Functions:

  • cross_section_3D –

    Cut away the material on the positive side of every plane, copying each solid's tags and color; raises when the planes share no volume or the wedge consumes a solid whole.

  • axisymmetric_cross_section –

    Section every tagged solid on the half-plane through axis toward radial into the solve frame (X = radial >= 0, Y = along the axis, Z = 0); a missed solid raises.

cross_section_3D

cross_section_3D(compound: TaggedCompound, planes: Iterable[Plane] = (YZ, XY), margin: float = 1.0) -> TaggedCompound

Cut away the material on the positive side of every plane, copying each solid's tags and color; raises when the planes share no volume or the wedge consumes a solid whole.

Parameters:

  • compound (TaggedCompound) –

    The tagged assembly to cut.

  • planes (Iterable[Plane], default: (YZ, XY) ) –

    The cutting planes; the default cuts away the X >= 0, Z >= 0 quarter wedge.

  • margin (float, default: 1.0 ) –

    How far the cutting boxes reach past the parts.

Source code in src/femforge/geometry/section.py
def cross_section_3D(
    compound: TaggedCompound,
    planes: Iterable[b3d.Plane] = (b3d.Plane.YZ, b3d.Plane.XY),
    margin: float = 1.0,
) -> TaggedCompound:
    """Cut away the material on the positive side of every plane, copying each solid's tags and color; raises when the planes share no volume or the wedge consumes a solid whole.

    Args:
        compound: The tagged assembly to cut.
        planes: The cutting planes; the default cuts away the X >= 0, Z >= 0 quarter wedge.
        margin: How far the cutting boxes reach past the parts.
    """

    def half_space_box(plane: b3d.Plane, center: b3d.Vector, radius: float, margin: float) -> b3d.Part:
        """A box covering the ball of `radius` around `center` on the positive side of `plane`, its wall exactly on the plane."""
        offset: b3d.Vector = center - plane.origin
        reach: float = max(offset.dot(plane.z_dir) + radius, margin)
        box = b3d.Box(2 * radius, 2 * radius, reach, align=(b3d.Align.CENTER, b3d.Align.CENTER, b3d.Align.MIN))
        return plane.location * b3d.Pos(offset.dot(plane.x_dir), offset.dot(plane.y_dir)) * box

    parts: list[TaggedPart] = [leaf for leaf in compound.placed_leaves() if isinstance(leaf, TaggedPart)]
    if not parts:
        raise ValueError("cross_section_3D needs at least one TaggedPart solid to section.")

    plane_list: list[b3d.Plane] = list(planes)
    if not plane_list:
        raise ValueError("cross_section_3D needs at least one plane to cut with.")

    # Bounds come from the parts, not compound.bounding_box() — a Compound's box can silently omit children.
    boxes: list[b3d.BoundBox] = [part.bounding_box() for part in parts]
    minimum = b3d.Vector(min(box.min.X for box in boxes), min(box.min.Y for box in boxes), min(box.min.Z for box in boxes))
    maximum = b3d.Vector(max(box.max.X for box in boxes), max(box.max.Y for box in boxes), max(box.max.Z for box in boxes))
    center: b3d.Vector = (minimum + maximum) / 2
    radius: float = (maximum - minimum).length / 2 + margin

    half_spaces: list[b3d.Part] = [half_space_box(plane, center, radius, margin) for plane in plane_list]
    wedge: b3d.Part = half_spaces[0]
    for half_space in half_spaces[1:]:
        intersect = cast("Callable[[b3d.Part], b3d.Part | None]", wedge.intersect)
        overlap: b3d.Part | None = intersect(half_space)
        if overlap is None or not overlap.solids():
            raise ValueError("the planes' positive half-spaces share no volume — there is no wedge to cut away.")

        wedge = b3d.Part(overlap.solids())

    sections: list[TaggedPart] = []
    for part in parts:
        cut = cast("Callable[[b3d.Part], b3d.Part]", part.cut)
        section: b3d.Part = cut(wedge)
        if not section.solids():
            raise ValueError(f"the sectioning wedge consumed the solid tagged {part.tags_str()!r} whole.")

        # A list, not section.wrapped: wrapping a bare TopoDS_Solid where a TopoDS_Compound belongs reads volume 0.
        tagged = TaggedPart([section], tags=part.current_tags)
        tagged.color = part.color
        sections.append(tagged)

    result: TaggedCompound = TaggedCompound(children=sections)
    result.label = f"3D Cross Section of {compound.label}"
    return result

axisymmetric_cross_section

axisymmetric_cross_section(compound: TaggedCompound, axis: Axis = Z, radial: Vec3 | None = None, margin: float = 1.0) -> TaggedCompound

Section every tagged solid on the half-plane through axis toward radial into the solve frame (X = radial >= 0, Y = along the axis, Z = 0); a missed solid raises.

Parameters:

  • compound (TaggedCompound) –

    The tagged assembly to section.

  • axis (Axis, default: Z ) –

    The axis of symmetry.

  • radial (Vec3 | None, default: None ) –

    The direction from the axis toward the half-plane; None picks the world axis least aligned with axis.

  • margin (float, default: 1.0 ) –

    How far the half-plane reaches past the parts.

Source code in src/femforge/geometry/section.py
def axisymmetric_cross_section(compound: TaggedCompound, axis: Axis = Axis.Z, radial: Vec3 | None = None, margin: float = 1.0) -> TaggedCompound:
    """Section every tagged solid on the half-plane through ``axis`` toward ``radial`` into the solve frame (X = radial >= 0, Y = along the axis, Z = 0); a missed solid raises.

    Args:
        compound: The tagged assembly to section.
        axis: The axis of symmetry.
        radial: The direction from the axis toward the half-plane; None picks the world axis least aligned with ``axis``.
        margin: How far the half-plane reaches past the parts.
    """
    direction: b3d.Vector = b3d.Vector(axis.direction.x, axis.direction.y, axis.direction.z)

    # By default the half-plane faces the world axis least aligned with the symmetry axis (X first): Axis.Z maps
    # authored X to radial, and Axis.Y is the identity.
    basis: tuple[b3d.Vector, ...] = (b3d.Vector(1, 0, 0), b3d.Vector(0, 1, 0), b3d.Vector(0, 0, 1))
    seed: b3d.Vector = min(basis, key=lambda unit: abs(unit.dot(direction))) if radial is None else b3d.Vector(radial.x, radial.y, radial.z)
    toward: b3d.Vector = (seed - direction * seed.dot(direction)).normalized()
    origin: b3d.Vector = b3d.Vector(axis.origin.x, axis.origin.y, axis.origin.z)
    solve_frame = b3d.Plane(origin=origin, x_dir=toward, z_dir=toward.cross(direction))

    leaves = compound.placed_leaves(solve_frame.location.inverse())
    parts: list[TaggedPart] = [leaf for leaf in leaves if isinstance(leaf, TaggedPart)]
    if not parts:
        raise ValueError("axisymmetric_cross_section needs at least one TaggedPart solid to section.")

    # The X >= 0 half of the Z = 0 plane, past every part; a common with it keeps coplanar boundary faces of half models.
    boxes: list[b3d.BoundBox] = [part.bounding_box() for part in parts]
    reach: float = max(max(box.max.X for box in boxes), 0.0) + margin
    bottom: float = min(box.min.Y for box in boxes) - margin
    top: float = max(box.max.Y for box in boxes) + margin
    half_plane = b3d.Pos(0, bottom) * b3d.Rectangle(reach, top - bottom, align=(b3d.Align.MIN, b3d.Align.MIN))

    faces: list[TaggedFace] = []
    for part in parts:
        common = cast("Callable[[b3d.Sketch], b3d.Compound | None]", part.intersect)
        section = common(half_plane)
        section_faces: list[b3d.Face] = list(section.faces()) if section is not None else []
        if not section_faces:
            raise ValueError(f"the axisymmetric section missed the solid tagged {part.tags_str()!r}: it does not reach the half-plane through {axis!r}.")

        for face in section_faces:
            box: b3d.BoundBox = face.bounding_box()
            if box.min.X < -_TOLERANCE:
                raise ValueError(f"the section of the solid tagged {part.tags_str()!r} reaches X = {box.min.X:g} < 0, which Elmer's axisymmetric solve rejects silently.")

            tagged = TaggedFace(face, tags=part.current_tags)
            tagged.color = part.color
            faces.append(tagged)

    result: TaggedCompound = TaggedCompound(children=faces)
    result.label = f"Cross Section of {compound.label}"
    return result

femforge.geometry.frame

Convert an authored STEP into the frame the solve meshes.

Functions:

  • import_tagged_step –

    The STEP's named solids as world-placed TaggedPart children tagged from their name paths.

  • apply_frame –

    The STEP the solve meshes: step_in itself for a solid frame, else its axisymmetric section written to step_out.

import_tagged_step

import_tagged_step(path: Path) -> TaggedCompound

The STEP's named solids as world-placed TaggedPart children tagged from their name paths.

Source code in src/femforge/geometry/frame.py
def import_tagged_step(path: Path) -> TaggedCompound:
    """The STEP's named solids as world-placed [`TaggedPart`][] children tagged from their name paths."""
    import build123d as b3d

    from femforge.geometry.tags import TaggedCompound, TaggedPart

    def tagged_leaves(node: b3d.Compound | b3d.Solid, inherited: TagDict) -> list[TaggedPart]:
        tags: TagDict = {**inherited, **parse_tags(node.label)}
        children = cast("tuple[b3d.Compound | b3d.Solid, ...]", node.children)
        if children:
            return [leaf for child in children for leaf in tagged_leaves(child, tags)]

        if not tags:
            raise ValueError(f"{path}: a body has no key=value name to tag it by, so the mesh could not group it.")

        if not node.solids():
            raise ValueError(f"{path}: the body {node.label!r} has no solid (an already-2D STEP); author the 3D model and let the frame section it.")

        placed: b3d.Compound | b3d.Solid = node.located(node.global_location)
        part = TaggedPart([*placed.solids()], tags=tags)
        part.color = node.color
        return [part]

    read_step = cast("Callable[[Path], b3d.Compound]", b3d.import_step)
    imported: b3d.Compound = read_step(path)
    return TaggedCompound(children=tagged_leaves(imported, {}))

apply_frame

apply_frame(step_in: Path, step_out: Path, frame: Frame) -> Path

The STEP the solve meshes: step_in itself for a solid frame, else its axisymmetric section written to step_out.

Parameters:

  • step_in (Path) –

    The authored STEP.

  • step_out (Path) –

    Where an axisymmetric section is written.

  • frame (Frame) –

    The solve frame.

Source code in src/femforge/geometry/frame.py
def apply_frame(step_in: Path, step_out: Path, frame: Frame) -> Path:
    """The STEP the solve meshes: ``step_in`` itself for a solid frame, else its axisymmetric section written to ``step_out``.

    Args:
        step_in: The authored STEP.
        step_out: Where an axisymmetric section is written.
        frame: The solve frame.
    """
    match frame:
        case Solid():
            return step_in

        case Axisymmetric(axis=axis, radial=radial):
            from femforge.geometry.section import axisymmetric_cross_section

            section: TaggedCompound = axisymmetric_cross_section(import_tagged_step(step_in), axis, radial)
            section.export_step(str(step_out))
            log.bind(step=str(step_out), axis=axis.model_dump(mode="json")).info("Axisymmetric section written.")
            return step_out

femforge.geometry.tags

Tagged build123d shapes whose tags ride their STEP names into the mesh.

Classes:

  • TaggedCompound –

    A build123d compound whose tags pass down to its tagged leaves on export.

  • TaggedFace –

    A build123d face whose tags ride its STEP name into a 2D mesh.

  • TaggedSketch –

    A build123d sketch that carries tags.

  • TaggedPart –

    A build123d part whose tags ride its STEP name into the mesh.

  • TaggedEdge –

    A build123d edge that carries tags.

Functions:

  • named –

    tags with name set as its TagKey.NAME tag, or tags unchanged when name is None.

TaggedCompound

TaggedCompound(*, children: Sequence[AnyShape] = (), tags: TagDict | None = None)

Bases: Compound, HasTags

A build123d compound whose tags pass down to its tagged leaves on export.

Methods:

  • rebuild_labels –

    Relabel every tagged part below this compound with its tags.

  • placed_leaves –

    Fresh copies of the tagged solids and faces, world-placed, moved by location, and carrying their ancestors' tags (deeper wins).

  • flatten_and_tag –

    A new compound of the world-placed tagged leaves (see placed_leaves), labelled with their tags; self is left untouched.

  • export_step –

    Export the compound, flattened into its tagged leaves unless flatten is False, to the STEP file filename.

Attributes:

Source code in src/femforge/geometry/tags.py
def __init__(self, *, children: Sequence[AnyShape] = (), tags: TagDict | None = None) -> None:
    """Build the assembly over ``children`` and tag it with ``tags``, the ``key=value`` pairs every tagged leaf below inherits on export."""
    b3d.Compound.__init__(self, children=children)  # pyright: ignore[reportUnknownMemberType]  # OCP ships no stubs
    HasTags.__init__(self, tags)
    self.label = self.tags_str()

children_at_global_location property

children_at_global_location: list[Any3D]

Return a list of children located at their global location.

rebuild_labels

rebuild_labels() -> None

Relabel every tagged part below this compound with its tags.

Source code in src/femforge/geometry/tags.py
def rebuild_labels(self) -> None:
    """Relabel every tagged part below this compound with its tags."""
    for child in cast("tuple[object, ...]", self.descendants):
        if isinstance(child, TaggedPart):
            child.label = child.tags_str()

placed_leaves

placed_leaves(location: Location | None = None) -> list[TaggedPart | TaggedFace]

Fresh copies of the tagged solids and faces, world-placed, moved by location, and carrying their ancestors' tags (deeper wins).

Source code in src/femforge/geometry/tags.py
def placed_leaves(self, location: b3d.Location | None = None) -> list["TaggedPart | TaggedFace"]:
    """Fresh copies of the tagged solids and faces, world-placed, moved by ``location``, and carrying their ancestors' tags (deeper wins)."""
    moved_by: b3d.Location = location if location is not None else b3d.Location()

    leaves: list[TaggedPart | TaggedFace] = []
    for leaf in cast("tuple[object, ...]", self.descendants):
        if not isinstance(leaf, (TaggedPart, TaggedFace)):
            continue

        # A compound's own location never reaches its children's shapes, and leaf.moved() deep-copies the whole
        # parent tree, so the shapes are rebuilt with every ancestor's location applied.
        parent = cast("b3d.Compound | None", leaf.parent)
        ancestors: b3d.Location = parent.global_location if parent is not None else b3d.Location()
        placement: b3d.Location = moved_by * ancestors
        tags: TagDict = {key: value for node in leaf.path if isinstance(node, HasTags) for key, value in node.tags.items()}

        if isinstance(leaf, TaggedPart):
            placed: list[TaggedPart | TaggedFace] = [TaggedPart([solid.moved(placement) for solid in leaf.solids()], tags=tags)]
        else:
            placed = [TaggedFace(face.moved(placement), tags=tags) for face in leaf.faces()]

        for copy in placed:
            copy.color = leaf.color

        leaves.extend(placed)

    return leaves

flatten_and_tag

flatten_and_tag() -> Compound

A new compound of the world-placed tagged leaves (see placed_leaves), labelled with their tags; self is left untouched.

Source code in src/femforge/geometry/tags.py
def flatten_and_tag(self) -> b3d.Compound:
    """A new compound of the world-placed tagged leaves (see [`placed_leaves`][]), labelled with their tags; ``self`` is left untouched."""
    return b3d.Compound(children=self.placed_leaves(), label=f"Flattened_{self.label}")

export_step

export_step(filename: str, flatten: bool = True) -> None

Export the compound, flattened into its tagged leaves unless flatten is False, to the STEP file filename.

Source code in src/femforge/geometry/tags.py
def export_step(self, filename: str, flatten: bool = True) -> None:
    """Export the compound, flattened into its tagged leaves unless ``flatten`` is False, to the STEP file ``filename``."""
    compound_to_export: b3d.Compound = self.flatten_and_tag() if flatten else self
    _ = b3d.export_step(compound_to_export, filename)  # pyright: ignore[reportUnknownMemberType]  # OCP ships no stubs

TaggedFace

TaggedFace(face: Face, *, tags: TagDict | None = None, name: str | None = None)

Bases: Face, HasTags

A build123d face whose tags ride its STEP name into a 2D mesh.

Parameters:

  • face (Face) –

    The face to wrap, placed where it is.

  • tags (TagDict | None, default: None ) –

    The key=value pairs domain materials and EntityTag selectors match, e.g. {TagKey.MATERIAL: N52().tag}.

  • name (str | None, default: None ) –

    The body name, kept as written in the Name tag and uppercased in the mesh, Elmer, entities.json and the viewer; None composes it from the tags.

Source code in src/femforge/geometry/tags.py
def __init__(self, face: b3d.Face, *, tags: TagDict | None = None, name: str | None = None) -> None:
    """Wrap ``face`` and tag it.

    Args:
        face: The face to wrap, placed where it is.
        tags: The ``key=value`` pairs domain materials and ``EntityTag`` selectors match, e.g. ``{TagKey.MATERIAL: N52().tag}``.
        name: The body name, kept as written in the ``Name`` tag and uppercased in the mesh, Elmer, ``entities.json`` and the viewer; None composes it from the tags.
    """
    b3d.Face.__init__(self, cast("object", face.wrapped))  # pyright: ignore[reportUnknownMemberType]  # OCP ships no stubs
    HasTags.__init__(self, named(tags, name))
    self.label = self.tags_str()

TaggedSketch

TaggedSketch(shapes: Iterable[AnyShape] | None = None, *, tags: TagDict | None = None)

Bases: Sketch, HasTags

A build123d sketch that carries tags.

Source code in src/femforge/geometry/tags.py
def __init__(self, shapes: Iterable[AnyShape] | None = None, *, tags: TagDict | None = None) -> None:
    """Build the sketch from ``shapes``, such as faces, and tag it with ``tags``."""
    b3d.Sketch.__init__(self, shapes)  # pyright: ignore[reportUnknownMemberType]  # OCP ships no stubs
    HasTags.__init__(self, tags)
    self.label = self.tags_str()

TaggedPart

TaggedPart(shapes: Iterable[AnyShape], *, tags: TagDict | None = None, name: str | None = None)

Bases: Part, HasTags

A build123d part whose tags ride its STEP name into the mesh.

Parameters:

  • shapes (Iterable[AnyShape]) –

    The solids the part holds, such as [box] or a build123d part.

  • tags (TagDict | None, default: None ) –

    The key=value pairs domain materials and EntityTag selectors match, e.g. {TagKey.MATERIAL: N52().tag}.

  • name (str | None, default: None ) –

    The body name, kept as written in the Name tag and uppercased in the mesh, Elmer, entities.json and the viewer; None composes it from the tags.

Source code in src/femforge/geometry/tags.py
def __init__(self, shapes: Iterable[AnyShape], *, tags: TagDict | None = None, name: str | None = None) -> None:
    """Build the part from ``shapes`` and tag it.

    Args:
        shapes: The solids the part holds, such as ``[box]`` or a build123d part.
        tags: The ``key=value`` pairs domain materials and ``EntityTag`` selectors match, e.g. ``{TagKey.MATERIAL: N52().tag}``.
        name: The body name, kept as written in the ``Name`` tag and uppercased in the mesh, Elmer, ``entities.json`` and the viewer; None composes it from the tags.
    """
    b3d.Part.__init__(self, shapes)  # pyright: ignore[reportUnknownMemberType]  # OCP ships no stubs
    HasTags.__init__(self, named(tags, name))
    self.label = self.tags_str()

TaggedEdge

TaggedEdge(edge: Edge, *, tags: TagDict | None = None)

Bases: Edge, HasTags

A build123d edge that carries tags.

Source code in src/femforge/geometry/tags.py
def __init__(self, edge: b3d.Edge, *, tags: TagDict | None = None) -> None:
    """Wrap ``edge``, placed where it is, and tag it with ``tags``."""
    b3d.Edge.__init__(self, cast("object", edge.wrapped))  # pyright: ignore[reportUnknownMemberType]  # OCP ships no stubs
    HasTags.__init__(self, tags)
    self.label = self.tags_str()

named

named(tags: TagDict | None, name: str | None) -> TagDict | None

tags with name set as its TagKey.NAME tag, or tags unchanged when name is None.

Source code in src/femforge/geometry/tags.py
def named(tags: TagDict | None, name: str | None) -> TagDict | None:
    """``tags`` with ``name`` set as its [`TagKey.NAME`][] tag, or ``tags`` unchanged when ``name`` is None."""
    return tags if name is None else {**(tags or {}), TagKey.NAME: name}

femforge.geometry.winding

Round-wire winding packer: the orthocyclic layer stack a wire settles into in a rectangular window, every wire position, and the derived electrical quantities.

Classes:

  • OuterDiameterFit –

    How the specified window OD bounds the layer stack.

  • LengthFit –

    How the wires occupy the specified window length.

  • Wire –

    Round magnet wire.

  • CoilWindow –

    One coil's revolved winding window: the annular slot between the mandrel and the coil OD.

  • WireTurn –

    One turn's centerline circle.

  • WoundCoil –

    One packed coil, computed by wind; every quantity derives from the wire and window alone.

OuterDiameterFit

Bases: StrEnum

How the specified window OD bounds the layer stack.

Attributes:

  • UNDER –

    Keep only layers that stay inside the specified OD: actual coil OD <= specified.

  • OVER –

    Stop after the first layer that reaches the specified OD: actual coil OD >= specified.

UNDER class-attribute instance-attribute

UNDER = 'under'

Keep only layers that stay inside the specified OD: actual coil OD <= specified.

OVER class-attribute instance-attribute

OVER = 'over'

Stop after the first layer that reaches the specified OD: actual coil OD >= specified.

LengthFit

Bases: StrEnum

How the wires occupy the specified window length.

Attributes:

  • TANGENT –

    The largest integer count of mutually tangent wires, centered in the window; an end gap likely remains.

  • SPLAYED –

    The base layer spans the window wall to wall: the same wire count, splayed to an axial pitch above the wire OD.

TANGENT class-attribute instance-attribute

TANGENT = 'tangent'

The largest integer count of mutually tangent wires, centered in the window; an end gap likely remains.

SPLAYED class-attribute instance-attribute

SPLAYED = 'splayed'

The base layer spans the window wall to wall: the same wire count, splayed to an axial pitch above the wire OD.

Wire dataclass

Wire(copper_diameter: float, insulation_diameter: float)

Round magnet wire.

Attributes:

copper_diameter instance-attribute

copper_diameter: float

d_cu [m]: Bare copper diameter.

insulation_diameter instance-attribute

insulation_diameter: float

d_ins [m]: Overall diameter including the enamel.

copper_area property

copper_area: float

a_cu [m²]: Bare copper cross-section.

insulation_area property

insulation_area: float

a_ins [m²]: Insulated-wire cross-section.

CoilWindow dataclass

CoilWindow(inner_radius: float, outer_radius: float, length: float)

One coil's revolved winding window: the annular slot between the mandrel and the coil OD.

Attributes:

  • inner_radius (float) –

    r_in [m]: Radius of the window's inner wall, where the first layer seats.

  • outer_radius (float) –

    r_out [m]: Radius of the window's outer wall (the CAD coil OD); the last layer may stand proud of it under OVER fit.

  • length (float) –

    w [m]: Axial length of the window: the coil width, which the geometry sets equal to the magnet length.

  • area (float) –

    A_w [m²]: The r-z cross-section of the window: radial depth × axial length.

  • mean_radius (float) –

    r_mean [m]: Mid-radius of the window.

  • volume (float) –

    V_w [m³]: The revolved window volume, 2π·r_mean·A_w.

inner_radius instance-attribute

inner_radius: float

r_in [m]: Radius of the window's inner wall, where the first layer seats.

outer_radius instance-attribute

outer_radius: float

r_out [m]: Radius of the window's outer wall (the CAD coil OD); the last layer may stand proud of it under OVER fit.

length instance-attribute

length: float

w [m]: Axial length of the window: the coil width, which the geometry sets equal to the magnet length.

area property

area: float

A_w [m²]: The r-z cross-section of the window: radial depth × axial length.

mean_radius property

mean_radius: float

r_mean [m]: Mid-radius of the window.

volume property

volume: float

V_w [m³]: The revolved window volume, 2π·r_mean·A_w.

WireTurn dataclass

WireTurn(layer: int, index: int, radius: float, axial_position: float)

One turn's centerline circle.

Attributes:

  • layer (int) –

    Zero-based layer index; layer 0 seats on the window's inner radius.

  • index (int) –

    Zero-based position along the layer.

  • radius (float) –

    r [m]: Radius of the turn's centerline.

  • axial_position (float) –

    z [m]: Axial position of the turn's center, measured from the window mid-plane.

layer instance-attribute

layer: int

Zero-based layer index; layer 0 seats on the window's inner radius.

index instance-attribute

index: int

Zero-based position along the layer.

radius instance-attribute

radius: float

r [m]: Radius of the turn's centerline.

axial_position instance-attribute

axial_position: float

z [m]: Axial position of the turn's center, measured from the window mid-plane.

WoundCoil dataclass

WoundCoil(wire: Wire, window: CoilWindow, outer_diameter_fit: OuterDiameterFit, length_fit: LengthFit, axial_pitch: float, radial_pitch: float, turns_per_layer: tuple[int, ...])

One packed coil, computed by wind; every quantity derives from the wire and window alone.

Methods:

  • wind –

    Pack the wire into the window: nested layers alternate n and n-1 turns, upper wires tangent in the grooves of the layer below.

  • wire_coordinates –

    Every turn's centerline circle, layer-major; odd layers sit in the grooves, offset by half the axial pitch.

  • coil_resistance –

    R_coil [Ω]: Per-coil DC resistance at temperature (K), by the same α law the FEA's σ(T) emits.

  • window_current_density –

    J [A/m²]: Homogenized azimuthal current density (signed): the ampere-turns spread over the specified window area — the FEA coil body's drive.

  • heat_density –

    q [W/m³]: Per-coil Joule density, I²·R_coil(T) over the revolved window volume.

  • wire_current_density –

    J_wire [A/m²]: Current density in the copper itself.

Attributes:

  • wire (Wire) –

    The wound wire.

  • window (CoilWindow) –

    The window the wire fills.

  • outer_diameter_fit (OuterDiameterFit) –

    Whether the stack stops under or just over the specified window OD.

  • length_fit (LengthFit) –

    Whether the base layer is tangent-packed or splayed wall to wall.

  • axial_pitch (float) –

    p_z [m]: Center-to-center spacing of neighbouring turns in a layer: the wire OD when tangent, wider when splayed.

  • radial_pitch (float) –

    p_r [m]: Center-to-center radial rise from one layer to the next, the groove-nested √(d² − (p_z/2)²).

  • turns_per_layer (tuple[int, ...]) –

    Turn count of each layer from the inside out; nested layers alternate n and n − 1.

  • turns (int) –

    N [-]: Wire turns in the coil.

  • num_layers (int) –

    The number of layers in the stack.

  • layer_radii (tuple[float, ...]) –

    [m]: Each layer's wire-center radius, layer 0 seated on the window's inner radius.

  • actual_outer_diameter (float) –

    [m]: The built coil's outer diameter: the outermost layer plus one wire radius.

  • actual_length (float) –

    [m]: The base layer's axial extent; upper layers are inset by half a pitch and never wider.

  • end_margin (float) –

    [m]: The gap between the base layer and each window wall (zero when splayed).

  • splay_gap (float) –

    [m]: The clearance between neighbouring wires in a layer (zero when tangent).

  • wire_length (float) –

    ℓ_wire [m]: Total wire length: each turn's circumference with the helical advance of one axial pitch.

  • copper_mass (float) –

    m_wire [kg]: Copper mass of the wire: length × bare-copper section × the Copper material's density.

  • copper_fill_factor (float) –

    f_cu [-]: Copper cross-section over the specified window cross-section.

  • mechanical_fill_factor (float) –

    f_mech [-]: Insulated-wire cross-section over the specified window cross-section; hex packing bounds it at π/(2√3) ≈ 0.907.

wire instance-attribute

wire: Wire

The wound wire.

window instance-attribute

window: CoilWindow

The window the wire fills.

outer_diameter_fit instance-attribute

outer_diameter_fit: OuterDiameterFit

Whether the stack stops under or just over the specified window OD.

length_fit instance-attribute

length_fit: LengthFit

Whether the base layer is tangent-packed or splayed wall to wall.

axial_pitch instance-attribute

axial_pitch: float

p_z [m]: Center-to-center spacing of neighbouring turns in a layer: the wire OD when tangent, wider when splayed.

radial_pitch instance-attribute

radial_pitch: float

p_r [m]: Center-to-center radial rise from one layer to the next, the groove-nested √(d² − (p_z/2)²).

turns_per_layer instance-attribute

turns_per_layer: tuple[int, ...]

Turn count of each layer from the inside out; nested layers alternate n and n − 1.

turns property

turns: int

N [-]: Wire turns in the coil.

num_layers property

num_layers: int

The number of layers in the stack.

layer_radii property

layer_radii: tuple[float, ...]

[m]: Each layer's wire-center radius, layer 0 seated on the window's inner radius.

actual_outer_diameter property

actual_outer_diameter: float

[m]: The built coil's outer diameter: the outermost layer plus one wire radius.

actual_length property

actual_length: float

[m]: The base layer's axial extent; upper layers are inset by half a pitch and never wider.

end_margin property

end_margin: float

[m]: The gap between the base layer and each window wall (zero when splayed).

splay_gap property

splay_gap: float

[m]: The clearance between neighbouring wires in a layer (zero when tangent).

wire_length property

wire_length: float

ℓ_wire [m]: Total wire length: each turn's circumference with the helical advance of one axial pitch.

copper_mass property

copper_mass: float

m_wire [kg]: Copper mass of the wire: length × bare-copper section × the Copper material's density.

copper_fill_factor property

copper_fill_factor: float

f_cu [-]: Copper cross-section over the specified window cross-section.

mechanical_fill_factor property

mechanical_fill_factor: float

f_mech [-]: Insulated-wire cross-section over the specified window cross-section; hex packing bounds it at π/(2√3) ≈ 0.907.

wind classmethod

wind(wire: Wire, window: CoilWindow, outer_diameter_fit: OuterDiameterFit = UNDER, length_fit: LengthFit = TANGENT) -> WoundCoil

Pack the wire into the window: nested layers alternate n and n-1 turns, upper wires tangent in the grooves of the layer below.

Parameters:

  • wire (Wire) –

    The wire to wind.

  • window (CoilWindow) –

    The window to fill.

  • outer_diameter_fit (OuterDiameterFit, default: UNDER ) –

    Whether the stack stops under or just over the window OD.

  • length_fit (LengthFit, default: TANGENT ) –

    Whether the base layer is tangent-packed or splayed wall to wall.

Source code in src/femforge/geometry/winding.py
@classmethod
def wind(
    cls,
    wire: Wire,
    window: CoilWindow,
    outer_diameter_fit: OuterDiameterFit = OuterDiameterFit.UNDER,
    length_fit: LengthFit = LengthFit.TANGENT,
) -> WoundCoil:
    """Pack the wire into the window: nested layers alternate n and n-1 turns, upper wires tangent in the grooves of the layer below.

    Args:
        wire: The wire to wind.
        window: The window to fill.
        outer_diameter_fit: Whether the stack stops under or just over the window OD.
        length_fit: Whether the base layer is tangent-packed or splayed wall to wall.
    """
    diameter = wire.insulation_diameter
    base_turns = math.floor(window.length / diameter + _COUNT_TOL)
    if base_turns < 2:
        raise ValueError(f"window length {window.length} m fits {base_turns} wires of OD {diameter} m; the nested-layer model needs at least 2 in the base layer.")

    if length_fit is LengthFit.TANGENT:
        axial_pitch = diameter
    else:
        axial_pitch = (window.length - diameter) / (base_turns - 1)

    # A groove wire touches both neighbours (center distance d) at a radial rise of sqrt(d^2 - (p/2)^2);
    # p < 2d always holds because base_turns is the floor count, so the sqrt stays real and positive.
    radial_pitch = math.sqrt(diameter**2 - (axial_pitch / 2) ** 2)

    seat_radius = window.inner_radius + diameter / 2
    turns_per_layer: list[int] = []
    layer = 0
    while True:
        build_radius = seat_radius + layer * radial_pitch + diameter / 2

        if outer_diameter_fit is OuterDiameterFit.UNDER and build_radius > window.outer_radius + _DIAMETER_TOL:
            break

        turns_per_layer.append(base_turns if layer % 2 == 0 else base_turns - 1)
        layer += 1

        if outer_diameter_fit is OuterDiameterFit.OVER and build_radius >= window.outer_radius - _DIAMETER_TOL:
            break

    if not turns_per_layer:
        raise ValueError(f"window radial depth {window.outer_radius - window.inner_radius} m cannot hold one layer of OD {diameter} m wire under {outer_diameter_fit}.")

    return cls(
        wire=wire,
        window=window,
        outer_diameter_fit=outer_diameter_fit,
        length_fit=length_fit,
        axial_pitch=axial_pitch,
        radial_pitch=radial_pitch,
        turns_per_layer=tuple(turns_per_layer),
    )

wire_coordinates

wire_coordinates() -> tuple[WireTurn, ...]

Every turn's centerline circle, layer-major; odd layers sit in the grooves, offset by half the axial pitch.

Source code in src/femforge/geometry/winding.py
def wire_coordinates(self) -> tuple[WireTurn, ...]:
    """Every turn's centerline circle, layer-major; odd layers sit in the grooves, offset by half the axial pitch."""
    base_turns = self.turns_per_layer[0]
    first_position = -(base_turns - 1) * self.axial_pitch / 2

    turns: list[WireTurn] = []
    for layer, (radius, count) in enumerate(zip(self.layer_radii, self.turns_per_layer)):
        start = first_position + (self.axial_pitch / 2 if layer % 2 else 0.0)
        for index in range(count):
            turns.append(WireTurn(layer=layer, index=index, radius=radius, axial_position=start + index * self.axial_pitch))
    return tuple(turns)

coil_resistance

coil_resistance(temperature: float = REFERENCE_TEMPERATURE) -> float

R_coil [Ω]: Per-coil DC resistance at temperature (K), by the same α law the FEA's σ(T) emits.

Source code in src/femforge/geometry/winding.py
def coil_resistance(self, temperature: float = REFERENCE_TEMPERATURE) -> float:
    """`R_coil` `[Ω]`: Per-coil DC resistance at ``temperature`` (K), by the same α law the FEA's σ(T) emits."""
    at_reference = self.wire_length / (CONDUCTIVITY_AT_REFERENCE * self.wire.copper_area)
    return at_reference * (1.0 + RESISTIVITY_TEMPERATURE_COEFFICIENT * (temperature - REFERENCE_TEMPERATURE))

window_current_density

window_current_density(current: float) -> float

J [A/m²]: Homogenized azimuthal current density (signed): the ampere-turns spread over the specified window area — the FEA coil body's drive.

Source code in src/femforge/geometry/winding.py
def window_current_density(self, current: float) -> float:
    """`J` `[A/m²]`: Homogenized azimuthal current density (signed): the ampere-turns spread over the specified window area — the FEA coil body's drive."""
    return self.turns * current / self.window.area

heat_density

heat_density(current: float, temperature: float = REFERENCE_TEMPERATURE) -> float

q [W/m³]: Per-coil Joule density, I²·R_coil(T) over the revolved window volume.

Source code in src/femforge/geometry/winding.py
def heat_density(self, current: float, temperature: float = REFERENCE_TEMPERATURE) -> float:
    """`q` `[W/m³]`: Per-coil Joule density, I²·R_coil(T) over the revolved window volume."""
    return current**2 * self.coil_resistance(temperature) / self.window.volume

wire_current_density

wire_current_density(current: float) -> float

J_wire [A/m²]: Current density in the copper itself.

Source code in src/femforge/geometry/winding.py
def wire_current_density(self, current: float) -> float:
    """`J_wire` `[A/m²]`: Current density in the copper itself."""
    return abs(current) / self.wire.copper_area

femforge.geometry.utils

build123d helpers: boundary boxes, rectangles, assembly-tree pruning and collinearity checks.

Classes:

  • Rect –

    An axis-aligned 2D rectangle of build123d vectors, with edge and corner accessors.

  • NestedRect –

    The frame-shaped region between an inner and an outer rectangle.

Functions:

  • create_debug_arrow –

    A debug arrow part on axis, for the viewer.

  • boundary_box –

    A translucent box around shape's bounding box, grown by padding along each axis, half on each side.

  • remove_where –

    Filter nodes based on criteria and automatically vacuum empty leftover compounds.

  • are_colliinear –

    Check if three points are collinear within a given tolerance.

  • all_colliinear –

    Check if all points in the list are collinear within a given tolerance.

  • drop_collinear_points –

    points with interior points that continue the previous direction dropped (endpoints kept).

  • collinear_ends –

    Assume points are collinear, find the furthest and closest point along that line

  • compound_topology –

    A rich tree of compound's descendants, by type and label.

Rect

Rect(min: V | None = None, max: V | None = None, label: str = '')

An axis-aligned 2D rectangle of build123d vectors, with edge and corner accessors.

Parameters:

  • min (V | None, default: None ) –

    The lower-left corner; None is the origin.

  • max (V | None, default: None ) –

    The upper-right corner; None is the origin.

  • label (str, default: '' ) –

    A name shown in the repr.

Attributes:

  • left (float) –

    The x of the left edge; setting it moves only that edge.

  • right (float) –

    The x of the right edge; setting it moves only that edge.

  • bottom (float) –

    The y of the bottom edge; setting it moves only that edge.

  • top (float) –

    The y of the top edge; setting it moves only that edge.

  • width (float) –

    The extent along x.

  • height (float) –

    The extent along y.

  • size (V) –

    The width and height as a vector.

  • center (V) –

    The midpoint.

  • corners (list[V]) –

    The four corners, counter-clockwise from min.

  • points (list[V]) –

    The four corners, the same as corners.

Source code in src/femforge/geometry/utils.py
def __init__(self, min: V | None = None, max: V | None = None, label: str = ""):
    """Span ``min`` to ``max``.

    Args:
        min: The lower-left corner; None is the origin.
        max: The upper-right corner; None is the origin.
        label: A name shown in the repr.
    """
    self.min = min or V()
    self.max = max or V()
    self.label = label

left property writable

left: float

The x of the left edge; setting it moves only that edge.

right property writable

right: float

The x of the right edge; setting it moves only that edge.

bottom property writable

bottom: float

The y of the bottom edge; setting it moves only that edge.

top property writable

top: float

The y of the top edge; setting it moves only that edge.

width property

width: float

The extent along x.

height property

height: float

The extent along y.

size property

size: V

The width and height as a vector.

center property

center: V

The midpoint.

corners property

corners: list[V]

The four corners, counter-clockwise from min.

points property

points: list[V]

The four corners, the same as corners.

NestedRect

NestedRect(inside: Rect | None = None, outside: Rect | None = None, label: str = '')

The frame-shaped region between an inner and an outer rectangle.

Parameters:

  • inside (Rect | None, default: None ) –

    The inner rectangle; None starts an empty one.

  • outside (Rect | None, default: None ) –

    The outer rectangle; None starts an empty one.

  • label (str, default: '' ) –

    A name for the region and its faces; empty derives one from the object id.

Methods:

  • face –

    Return a face representing the area between the inside and outside rectangles.

  • extrude –

    Return a part representing the area between the inside and outside rectangles extruded to the given thickness.

Source code in src/femforge/geometry/utils.py
def __init__(self, inside: Rect | None = None, outside: Rect | None = None, label: str = ""):
    """Hold the two rectangles.

    Args:
        inside: The inner rectangle; None starts an empty one.
        outside: The outer rectangle; None starts an empty one.
        label: A name for the region and its faces; empty derives one from the object id.
    """

    self.label = label or f"NestedRect_{id(self)}"
    self.inside = inside or Rect(label=f"{self.label}.inside")
    self.outside = outside or Rect(label=f"{self.label}.outside")

face

face() -> Face

Return a face representing the area between the inside and outside rectangles.

Source code in src/femforge/geometry/utils.py
def face(self) -> b3d.Face:
    """Return a face representing the area between the inside and outside rectangles."""
    outer_wire = b3d.Rectangle(self.outside.width, self.outside.height).wire().translate(self.outside.center)
    inner_wire = b3d.Rectangle(self.inside.width, self.inside.height).wire().translate(self.inside.center)
    face = b3d.Face(outer_wire, [inner_wire], label=f"{self.label}_Face")
    return face

extrude

extrude(thickness: float) -> Part

Return a part representing the area between the inside and outside rectangles extruded to the given thickness.

Source code in src/femforge/geometry/utils.py
def extrude(self, thickness: float) -> b3d.Part:
    """Return a part representing the area between the inside and outside rectangles extruded to the given thickness."""
    face = self.face()
    part = b3d.extrude(face, amount=thickness)
    part.label = f"{self.label}_Part"
    return part

create_debug_arrow

create_debug_arrow(axis: Axis, length: float = 10, radius: float = 0.2, color: Color = Color('cyan')) -> Part

A debug arrow part on axis, for the viewer.

Parameters:

  • axis (Axis) –

    The axis the arrow starts on and points along.

  • length (float, default: 10 ) –

    The overall length.

  • radius (float, default: 0.2 ) –

    The stem radius; the head is 2.5 times wider.

  • color (Color, default: Color('cyan') ) –

    The arrow's color.

Source code in src/femforge/geometry/utils.py
def create_debug_arrow(  # pragma: no cover - viewer-only debug geometry
    axis: b3d.Axis,
    length: float = 10,
    radius: float = 0.2,
    color: b3d.Color = b3d.Color("cyan"),
) -> b3d.Part:
    """A debug arrow part on ``axis``, for the viewer.

    Args:
        axis: The axis the arrow starts on and points along.
        length: The overall length.
        radius: The stem radius; the head is 2.5 times wider.
        color: The arrow's color.
    """
    arrow_compound = b3d.Compound()

    proportion_body = 0.85
    proportion_head = 1 - proportion_body

    with b3d.BuildPart() as arrow:
        # Arrow stem
        b3d.Cylinder(
            radius=radius,
            height=length * proportion_body,
            align=(b3d.Align.CENTER, b3d.Align.CENTER, b3d.Align.MIN),
        )

        # Arrow head
        with b3d.Locations((0, 0, length * proportion_body)):
            b3d.Cone(
                bottom_radius=radius * 2.5,
                top_radius=0,
                height=length * proportion_head,
                align=(b3d.Align.CENTER, b3d.Align.CENTER, b3d.Align.MIN),
            )

    arrow_compound += arrow.part
    arrow_compound.color = color
    arrow_compound.label = "Debug Arrow"
    # build123d's Compound.locate() is typed as a Compound | Wire union; this is always a solid Part.
    return cast(b3d.Part, arrow_compound.locate(axis.location))

boundary_box

boundary_box(shape: Compound, padding: Vec3 = Vec3(0, 0, 0)) -> Part

A translucent box around shape's bounding box, grown by padding along each axis, half on each side.

Source code in src/femforge/geometry/utils.py
def boundary_box(shape: b3d.Compound, padding: Vec3 = Vec3(0, 0, 0)) -> b3d.Part:
    """A translucent box around ``shape``'s bounding box, grown by ``padding`` along each axis, half on each side."""
    half_pad = b3d.Vector(padding.x, padding.y, padding.z) * 0.5

    bounds = shape.bounding_box()
    min_pt = bounds.min - half_pad
    max_pt = bounds.max + half_pad
    size = max_pt - min_pt

    with b3d.BuildPart(mode=b3d.Mode.PRIVATE) as box:
        with b3d.BuildSketch(b3d.Plane.XY.offset(min_pt.Z)):
            with b3d.Locations(min_pt):
                b3d.Rectangle(size.X, size.Y, align=(b3d.Align.MIN, b3d.Align.MIN))

        b3d.extrude(amount=size.Z, dir=b3d.Axis.Z.direction)

    assert box.part is not None  # BuildPart always yields a part after the context

    result = box.part
    result.color = b3d.Color(1, 1, 1, 0.2)
    result.label = "Boundary Box"
    return result

remove_where

remove_where(assembly: Compound, criteria_func: Callable[[Compound], bool]) -> Compound

Filter nodes based on criteria and automatically vacuum empty leftover compounds.

Source code in src/femforge/geometry/utils.py
def remove_where(assembly: b3d.Compound, criteria_func: Callable[[b3d.Compound], bool]) -> b3d.Compound:
    """Filter nodes based on criteria and automatically vacuum empty leftover compounds."""

    nodes_to_remove = set(node for node in _tree_nodes(assembly, PostOrderIter) if criteria_func(node))

    for node in _tree_nodes(assembly, PostOrderIter):
        children = cast("tuple[b3d.Compound, ...]", node.children)
        kept = [child for child in children if child not in nodes_to_remove]
        if children:
            node.children = kept

        if isinstance(node, b3d.Compound) and not kept and len(node.solids()) == 0 and node != assembly:
            nodes_to_remove.add(node)

    return assembly

are_colliinear

are_colliinear(p1: V, p2: V, p3: V, tolerance: float = 1e-09) -> bool

Check if three points are collinear within a given tolerance.

Parameters:

  • p1 (V) –

    The first point.

  • p2 (V) –

    The middle point.

  • p3 (V) –

    The last point.

  • tolerance (float, default: 1e-09 ) –

    The largest cross product of the two segments still counted as collinear.

Source code in src/femforge/geometry/utils.py
def are_colliinear(p1: V, p2: V, p3: V, tolerance: float = 1e-9) -> bool:
    """Check if three points are collinear within a given tolerance.

    Args:
        p1: The first point.
        p2: The middle point.
        p3: The last point.
        tolerance: The largest cross product of the two segments still counted as collinear.
    """
    v1 = p2 - p1
    v2 = p3 - p2
    cross_product = v1.cross(v2)
    return cross_product.length <= tolerance

all_colliinear

all_colliinear(points: list[V], tolerance: float = 1e-09) -> bool

Check if all points in the list are collinear within a given tolerance.

Source code in src/femforge/geometry/utils.py
def all_colliinear(points: list[V], tolerance: float = 1e-9) -> bool:
    """Check if all points in the list are collinear within a given tolerance."""
    if len(points) < 3:
        return True  # Less than 3 points are always collinear

    p1 = points[0]
    p2 = points[1]

    for i in range(2, len(points)):
        p3 = points[i]
        if not are_colliinear(p1, p2, p3, tolerance):
            return False

    return True

drop_collinear_points

drop_collinear_points(points: list[V]) -> list[V]

points with interior points that continue the previous direction dropped (endpoints kept).

Source code in src/femforge/geometry/utils.py
def drop_collinear_points(points: list[V]) -> list[V]:
    """``points`` with interior points that continue the previous direction dropped (endpoints kept)."""
    kept: list[V] = points[:1]
    for index in range(1, len(points) - 1):
        if not are_colliinear(points[index - 1], points[index], points[index + 1]):
            kept.append(points[index])

    kept.append(points[-1])
    return kept

collinear_ends

collinear_ends(points: list[V]) -> list[V]

Assume points are collinear, find the furthest and closest point along that line

Source code in src/femforge/geometry/utils.py
def collinear_ends(points: list[V]) -> list[V]:
    """Assume points are collinear, find the furthest and closest point along that line"""
    if len(points) < 2:
        return points

    # Use the first two points to define the line
    p1 = points[0]
    p2 = points[1]
    direction = (p2 - p1).normalized()

    # Project all points onto the line defined by p1 and direction
    projections = [(point, (point - p1).dot(direction)) for point in points]

    # Find the minimum and maximum projections
    min_proj = min(projections, key=lambda x: x[1])
    max_proj = max(projections, key=lambda x: x[1])

    return [min_proj[0], max_proj[0]]

compound_topology

compound_topology(compound: Compound)

A rich tree of compound's descendants, by type and label.

Source code in src/femforge/geometry/utils.py
def compound_topology(compound: b3d.Compound):
    """A rich tree of ``compound``'s descendants, by type and label."""
    from rich.tree import Tree

    tree = Tree(f"Compound: {compound.label or 'Unnamed'}")

    def walk(node: b3d.Compound, parent_tree: Tree):
        for child in cast("tuple[b3d.Compound, ...]", node.children):
            label = f"{type(child).__name__}: {child.label or 'Unnamed'}"
            child_tree = parent_tree.add(label)
            if isinstance(child, b3d.Compound):
                walk(child, child_tree)

    walk(compound, tree)
    return tree