Lofts with guide curves

A loft through a few sections is smooth, but between the sections it goes wherever its splines take it. When the sections change quickly, its walls overshoot, and a passage can pinch shut where it should stay open. Guide curves steer a loft. In planex2d, the guides go through the named corners of the sections, they are cut every few millimeters, and every cut becomes a section of its own, so occ.ThruSections has no room left between the sections. The construction is the same as for the 3D duct, here on a simpler part.

A blower nozzle takes air from a hose fitting, a rounded square of 40 × 40, and blows it out of a flat slot of 120 × 8 at 100 along the axis. It flattens early, to a neck of 60 × 10 at 30, and then fans out to the slot.

[1]:
import math
import netgen.occ as occ
import numpy as np
import planex as px

The sections

Each section is a rounded rectangle in a constraint sketch on its own plane, the xy plane lifted to its height with px.XY.Offset(z). It is made of four lines and four arcs that are tangent all around, centered on the axis, with the dimensions w, h and r. Figure 1 shows the three sketched sections at one scale. The dots are the eight named corners of each section, bottom.start, bottom.end, right.start and so on, through which the guides will pass.

[3]:
def section(w, h, r, z, id="section"):
    sk = px.Sketch(px.XY.Offset(z), id=id)
    sk.Move((-w / 2 + r, -h / 2)).Line(w - 2 * r, 0, px.Horizontal, id="bottom")
    sk.Arc(r, 90, px.Tangent, id="a1")
    sk.Line(0, h - 2 * r, px.Vertical, px.Tangent, id="right")
    sk.Arc(r, 90, px.Tangent, id="a2")
    sk.Line(-(w - 2 * r), 0, px.Horizontal, px.Tangent, id="top")
    sk.Arc(r, 90, px.Tangent, id="a3")
    sk.Line(0, -(h - 2 * r), px.Vertical, px.Tangent, id="left")
    sk.Arc(r, 90, px.Tangent, id="a4")
    sk.Constrain("a4.end", "bottom.start", px.Coincident)
    sk.Constrain("a4", "bottom", px.Tangent)
    sk.Constrain("a1", px.Radius, r, id="r")
    for arc in ("a2", "a3", "a4"):
        sk.Constrain("a1", arc, px.Equal)
    sk.Constrain("left", "right", px.Distance, w, id="w")
    sk.Constrain("bottom", "top", px.Distance, h, id="h")
    sk.Constrain("bottom.mid", px.V, px.Coincident)
    sk.Constrain("left.mid", px.H, px.Coincident)
    return sk


inlet = section(40, 40, 14, 0, "inlet")
neck = section(60, 10, 3, 30, "neck")
slot = section(120, 8, 3, 100, "slot")
sketched = [inlet, neck, slot]
CORNERS = [f"{edge}.{end}" for edge in ("bottom", "right", "top", "left")
           for end in ("start", "end")]
inlet, 40 × 40, z 0
(a) inlet, 40 × 40, z 0
neck, 60 × 10, z 30
(b) neck, 60 × 10, z 30
slot, 120 × 8, z 100
(c) slot, 120 × 8, z 100
Figure 1: The three sketched sections and their named corners

The plain loft

occ.ThruSections through the three sketches gives a smooth solid. The height of its passage along the axis, read from the loft’s long edges (the top ones are the highest points of each cross-section), shows the problem. After the neck, the walls swing past the slot’s height, and the passage closes almost completely before it opens up to the slot again.

[5]:
plain = occ.ThruSections([s.ToOCCWire() for s in sketched], solid=True)


def points(edge, n=200):
    t0, t1 = edge.parameter_interval
    return [edge.Value(t0 + (t1 - t0) * k / n) for k in range(n + 1)]


def passage(solid, z):
    top = np.full(len(z), -np.inf)
    for e in solid.edges:
        q = sorted(points(e), key=lambda p: p.z)
        if q[-1].z - q[0].z > 99:
            top = np.maximum(top, np.interp(z, [p.z for p in q], [p.y for p in q]))
    return 2 * top


z = np.linspace(0, 100, 201)
print("plain loft: smallest height", passage(plain, z).min(), "slot", slot["h"])
plain loft: smallest height 0.40861213982749645 slot 8.0

Guides

Two interpolated sections in each gap, at a third and at two thirds of the way, with width, height and corner radius interpolated linearly between their neighbors, make seven sections. A spline through each named corner of all seven, leaving the inlet and reaching the slot along the axis, is a guide (Figure 2a).

[6]:
def z_of(sk):
    return sk.ToXYZ((0, 0))[2]


def size(sk):
    x, y, _ = sk("right.start").center
    bottom = sk("bottom.start").center[1]
    return 2 * x, -2 * bottom, y - bottom


def between(a, b, t):
    w, h, r = (p + t * (q - p) for p, q in zip(size(a), size(b)))
    return section(w, h, r, z_of(a) + t * (z_of(b) - z_of(a)), "interpolated")


sections = [inlet]
for a, b in zip(sketched, sketched[1:]):
    sections += [between(a, b, 1 / 3), between(a, b, 2 / 3), b]
axial = {0: occ.Z, len(sections) - 1: occ.Z}
guides = []
for c in CORNERS:
    corners = [occ.Pnt(*s.ToXYZ(tuple(s(c).center[:2]))) for s in sections]
    guides.append(occ.SplineInterpolation(corners, tangents=axial))

Sections cut from the guides

Every 2.5 along the axis, the eight guides are cut, and their points give a new section, with the sides through the outermost corners and the corner radius from the corners between them. These sections cost little and hold the loft to the guides between the sketches (Figure 2b). One occ.ThruSections through all of them gives the nozzle.

[7]:
def meet(guide, z):
    a, b = guide.parameter_interval
    for _ in range(50):
        m = (a + b) / 2
        a, b = (m, b) if guide.Value(m).z < z else (a, m)
    return guide.Value(a)


def from_corners(points, z):
    x, y = np.array([(p.x, p.y) for p in points]).T
    left, right = x[6:].mean(), x[2:4].mean()
    bottom, top = y[:2].mean(), y[4:6].mean()
    r = np.mean([right - x[1], y[2] - bottom, top - y[3], right - x[4],
                 x[5] - left, top - y[6], y[7] - bottom, x[0] - left])
    return section(right - left, top - bottom, r, z, "cut")


step = 2.5
known = [z_of(s) for s in sections]
cuts = [from_corners([meet(g, at) for g in guides], at)
        for at in np.arange(step, z_of(slot), step)
        if min(abs(at - k) for k in known) > 1e-6]
everything = sorted(sections + cuts, key=z_of)
nozzle = occ.ThruSections([s.ToOCCWire() for s in everything], solid=True)
print(len(sketched), "sketched,", len(sections) - len(sketched), "interpolated,",
      len(cuts), "cut sections")
3 sketched, 4 interpolated, 36 cut sections
The seven sections and the guides
(a) The seven sections and the guides
all sections of the loft
(b) all sections of the loft
sketchedinterpolatedcut from the guidesguides
Figure 2: The sections and the guide curves

Plain and guided

Figure 3 shows the plain loft, drawn by its edges (c), and the guided one (d), together with the height of their passages along the axis. The guided loft goes from the neck to the slot without the pinch.

The plain loft, its edges
(c) The plain loft, its edges
The guided loft, its edges
(d) The guided loft, its edges
sketchedguides
[10]:
Chart("z", "height of the passage", lines={"plain loft": (list(z), list(passage(plain, z)))},
      curves={"guided loft": (list(z), list(passage(nozzle, z)))},
      dots={"sketched": ([z_of(s) for s in sketched], [s["h"] for s in sketched])},
      title="The height of the nozzle's passage along its axis")
height of the passage over z: sketched 0 40, 30 10, 100 8; guided loft 0 40, 8 34.5, 16 23.4, 24 15.8, 32 8.89, 40 7.59, 48 8.58, 56 9.56, 64 9.64, 72 9.11, 80 8.38, 88 8.06, 96 8
Figure 3: The height of the nozzle's passage along its axis

Checks

The end caps of the loft are the sketched inlet and slot. Its long edges, which leave the planes of the sections and run through their corners, lie on the guides up to the distance printed below. Its volume agrees with the areas of the sections summed along the axis by the trapezoidal rule, which is close because the sections are dense. Finally, the passage stays open, as its smallest height compared with that of the plain loft shows.

[11]:
for s, cap in ((inlet, nozzle.faces.Min(occ.Z)), (slot, nozzle.faces.Max(occ.Z))):
    print(s.id, "cap", cap.mass, "sketch", s.Face().mass)

tracks = []
for g in guides:
    q = points(g, 4000)
    tracks.append(np.array([(p.z, p.x, p.y) for p in q]).T)
worst = 0.0
for e in nozzle.edges:
    q = points(e, 20)
    if max(p.z for p in q) - min(p.z for p in q) < 1e-6:
        continue
    for p in q:
        off = [math.hypot(p.x - np.interp(p.z, gz, gx), p.y - np.interp(p.z, gz, gy)) for gz, gx, gy in tracks]
        worst = max(worst, min(off))
print("long edges off the guides: at most", worst)

areas = np.array([s.Face().mass for s in everything])
zs = np.array([z_of(s) for s in everything])
summed = np.sum(np.diff(zs) * (areas[1:] + areas[:-1]) / 2)
print("volume", nozzle.mass, "sections summed", summed, "relative", (nozzle.mass - summed) / summed)
print("smallest height: guided", passage(nozzle, z).min(), "plain", passage(plain, z).min())
inlet cap 1431.7521598176133 sketch 1431.7521601035994
slot cap 952.2743338691761 sketch 952.274333882308
long edges off the guides: at most 0.3344470312668278
volume 88110.88359715887 sections summed 88102.51107996187 relative 9.503153876512192e-05
smallest height: guided 7.57039630381177 plain 0.40861213982749645