Profiles: shapes the primitives do not cover#

Bricks, cylinders, cones and spheres carry most models, and Boolean operations carry most of the rest. Sooner or later a part resists both: a curved electrode that is a slice of a tube, a pad with one rounded end, a track that follows a route, a taper between two different cross-sections. Cutting those out of primitives means inventing tool bodies whose only purpose is to be subtracted, and getting their dimensions right by hand.

The way out is the one a draughtsman uses: draw the outline, then give it a third dimension. This tutorial builds that path — a curve, a closed profile, a solid — and then the three verbs that operate on whole shapes rather than outlines: hollowing, tracking, and lofting.

Nothing here runs a simulation; it is geometry only, and takes seconds.

import math

import magnelio as mio
from magnelio import geo, plots

pec = mio.Material.pec()
copper = mio.Material.lossy_metal(name="copper", sigma=5.8e7)

A worked example: a curved electrode#

A 20 degree slice of a tube — 24 mm across, 2 mm wall, 30 mm tall. Two independent ways to build it, and it is worth seeing both, because the choice between them comes up again for every part.

The first is a primitive. A cylinder takes a bore and an angular extent, so the electrode is one call:

R_OUT, R_IN, HEIGHT, SPAN = 12.0e-3, 10.0e-3, 30.0e-3, 20.0

electrode = geo.Cylinder(
    radius=R_OUT,
    inner_radius=R_IN,
    height=HEIGHT,
    angle_deg=(0.0, SPAN),
    material=pec,
)

fig, ax = plots.plot_cross_section(
    [electrode], "z", HEIGHT / 2, title="electrode, cut across the axis"
)
electrode, cut across the axis

The same part, drawn as a profile#

The second way draws the cross-section and extrudes it. A Path is a pen: it starts at a point and remembers where the last segment ended, so each call only names where the segment goes.

Two of the four sides are arcs about the axis. An arc through a centre has two solutions — the short way round and the long way — so normal= names the axis the arc turns about, and the arc runs counter-clockwise about it. That is the same sense as rotated(), and it settles the ambiguity even for a half-circle, where the two ends alone say nothing at all.

CENTRE = (0.0, 0.0, 0.0)


def on_circle(radius, angle_deg):
    """A point on a circle about the origin, in the z = 0 plane."""
    angle = math.radians(angle_deg)
    return (radius * math.cos(angle), radius * math.sin(angle), 0.0)


outline = (
    geo.Path(on_circle(R_IN, 0.0))
    .line_to(on_circle(R_OUT, 0.0))
    .arc_to(on_circle(R_OUT, SPAN), center=CENTRE, normal="z")
    .line_to(on_circle(R_IN, SPAN))
    .arc_to(on_circle(R_IN, 0.0), center=CENTRE, normal=(0.0, 0.0, -1.0))
    .closed()
)

drawn = outline.covered().extruded(vector=(0.0, 0.0, HEIGHT), material=pec)

# The two routes describe the same solid, and `volume()` is the way to
# say so: it reports what the CAD kernel actually built, not what the
# parameters nominally asked for.  They are built by different kernel
# operations, so they agree to numerical precision rather than bit for
# bit -- the level of agreement to expect whenever one part can be
# built two ways.
print(f"primitive: {electrode.volume() * 1e9:.6f} mm^3")
print(f"drawn:     {drawn.volume() * 1e9:.6f} mm^3")
print(f"relative difference: {abs(drawn.volume() / electrode.volume() - 1.0):.2e}")
primitive: 230.383461 mm^3
drawn:     230.383461 mm^3
relative difference: 1.11e-15

Note

The inner arc turns about -z while the outer turns about +z. The pen walks the outline as a loop, so it comes back along the inside — and “counter-clockwise” is then the other way round. If a profile comes out crossing itself, this is the first thing to check.

Which route to prefer? The primitive, whenever it fits: it is one line, it carries its dimensions as named parameters, and the mesher gets an exact analytic surface. The profile route earns its keep the moment the outline is not a plain sector — a broken edge, a keyway, a flat on one side. Adding a chamfer to the inner corner is one more segment in the pen stroke, and no new tool body:

CHAMFER = 1.0e-3

chamfered = (
    geo.Path(on_circle(R_IN, 0.0))
    .line_to(on_circle(R_OUT - CHAMFER, 0.0))
    .line_to((*on_circle(R_OUT, 0.0)[:2], 0.0))
    .arc_to(on_circle(R_OUT, SPAN), center=CENTRE, normal="z")
    .line_to(on_circle(R_IN, SPAN))
    .arc_to(on_circle(R_IN, 0.0), center=CENTRE, normal=(0.0, 0.0, -1.0))
    .closed()
    .covered()
    .extruded(vector=(0.0, 0.0, HEIGHT), material=pec)
)

fig, ax = plots.plot_cross_section(
    [chamfered], "z", HEIGHT / 2, title="the same electrode, drawn and modified"
)
the same electrode, drawn and modified

A profile can also be revolved or swept#

The profile is the input, not the extrusion. The same closed curve feeds three verbs: extruded() pushes it along a vector, revolved() turns it about an axis, and swept() runs it along a path.

Revolving is the direct way to any rotationally symmetric part whose outline is not a rectangle — a rounded-nose centre conductor, a stepped transformer, a bead. Here the outline is drawn in the x-z plane and turned about z:

nose = (
    geo.Path((0.0, 0.0, 0.0))
    .line_to((3.0e-3, 0.0, 0.0))
    .line_to((3.0e-3, 0.0, 8.0e-3))
    .arc_to((0.0, 0.0, 11.0e-3), via=(2.1e-3, 0.0, 10.1e-3))
    .closed()
    .covered()
    .revolved(axis="z", material=pec)
)

fig, ax = plots.plot_cross_section([nose], "y", 0.0, title="revolved profile: a rounded pin")
revolved profile: a rounded pin

Hollowing a solid#

A housing is a block with its inside removed, and the inside of anything but a box is not the outside scaled down. shelled() builds it directly: walls grow inward, so the outer dimensions stay exactly as designed, and naming a face leaves it out of the shell to become an opening.

WALL = 1.5e-3
BOX = (40.0e-3, 25.0e-3, 12.0e-3)

housing = geo.Brick(origin=(0.0, 0.0, 0.0), size=BOX, material=pec).shelled(
    thickness=WALL,
    opening_face_near=[(0.0, BOX[1] / 2, BOX[2] / 2), (BOX[0], BOX[1] / 2, BOX[2] / 2)],
)

fig, ax = plots.plot_cross_section([housing], "z", BOX[2] / 2, title="a housing, open at both ends")
a housing, open at both ends

Tracks that follow a route#

A feed line is a centreline with a width and a metal thickness. traced() takes it that way, so a bend is one segment of the path rather than a separate body to place.

caps="flat" matters more than it looks: a track that ends at a port has to meet the port plane squarely, and the default rounded end would leave a sliver of air there. Outside corners come out rounded, which is what a fabricated track does too.

W_TRACK, T_COPPER = 0.6e-3, 35.0e-6

route = (
    geo.Path((0.0, 0.0, 0.0))
    .line_to((8.0e-3, 0.0, 0.0))
    .spline_to((14.0e-3, 3.0e-3, 0.0), (20.0e-3, 3.0e-3, 0.0))
    .curve()
)

track = route.traced(
    width=W_TRACK,
    thickness=T_COPPER,
    caps="flat",
    normal="z",
    material=copper,
)

fig, ax = plots.plot_cross_section(
    [track], "z", T_COPPER / 2, title="a routed track, square at both ends"
)
a routed track, square at both ends

Tapers between two cross-sections#

The last gap the primitives leave is a transition: a horn, a matching section, a change from a round cross-section to a rectangular one. Loft takes the cross-sections themselves, in the order the solid passes through them, and interpolates.

blend="ruled" joins them with straight surfaces, which is what a machined taper is; the default blend="spline" passes one smooth surface through all of them, for a flared horn.

Where the two ends are faces of solids that already exist, the lofted() verb takes those instead, and adds a third mode: blend="tangent" leaves each face along its own normal, so a bend between two parts that face different directions comes out smooth rather than creased.

def square(half, z):
    """A square outline of half-width *half*, at height *z*."""
    return geo.Face(
        normal="z",
        points=[(-half, -half), (half, -half), (half, half), (-half, half)],
        position=z,
    )


taper = geo.Loft(square(4.0e-3, 0.0), square(10.0e-3, 18.0e-3), blend="ruled", material=pec)

fig, ax = plots.plot_cross_section([taper], "y", 0.0, title="a ruled taper between two squares")
a ruled taper between two squares

What to take away#

  • Reach for a primitive first — a cylinder with inner_radius and angle_deg covers tubes and sectors without any drawing.

  • When the outline is the thing you actually know, draw it with Path, close it, and cover it. The resulting sheet is a profile for extruded / revolved / swept / thickened.

  • An arc through a centre is ambiguous; normal= removes the ambiguity, and the direction reverses when the pen comes back along the far side of a loop.

  • shelled hollows, traced follows a route, Loft interpolates cross-sections. Each replaces a construction that would otherwise be assembled from tool bodies by hand.

  • volume() checks a construction against what it was supposed to be — the metal fraction of a housing, or two routes to the same part agreeing.

A profile carrying no material is a construction profile: it is not a physical object and cannot be meshed on its own, which is why the verbs that turn it into a solid ask for the material explicitly.

Total running time of the script: (0 minutes 0.472 seconds)

Gallery generated by Sphinx-Gallery