gumyr / build123d

A python CAD programming library
Apache License 2.0
579 stars 94 forks source link

Feature Request: Create a `sweep` method which always forces the `section` to be normal to the path #762

Closed KilowattSynthesis closed 3 weeks ago

KilowattSynthesis commented 4 weeks ago

Every time I've used the sweep method, I've struggled pretty hard. The docs don't make it clear that it only works "as-expected" if the swept section is right at the end of the path, and is normal to the path. I just learned about the caret operator, which helps, but it's still a bit verbose/ugly.

When sweeping over a polyline, I can't find an elegant solution to keep the section normal to the path always. Perhaps I could create a bunch of sections (with iteration through the polyline), and then use bd.sweep(..., multisection=True). Pretty verbose though.

I propose that we add a method called bd.sweep_normal(path=..., section=...), which will take a 2d section (e.g., a bd.Circle), and sweep it along the path, such that the normal of the circle always lines up with the path.

Update 2024-11-10: Found a solution. Using the transition=bd.Transition.ROUND or transition=bd.Transition.RIGHT arguments makes the function work effectively as suggested above.

gumyr commented 4 weeks ago

It seems like the fundamental problem is "When sweeping over a polyline, I can't find an elegant solution to keep the section normal to the path always." - the location_at/^ method/operation will find the exact position/orientation anywhere along an Edge/Wire. Please provide an example of where this fails.

One problem that users have is that the rotation around the Edge/Wire provided by location_at may not be what one expects resulting in difficulties when attempting to manually continue a sweep. A sweep_normal operation wouldn't solve this problem though.

KilowattSynthesis commented 4 weeks ago

Sorry this is such a long example (ie not a "minimum reproducable example"), but it's what I've been working with, so take a look if you can! Thank you for the help :)

"""Create CAD model example of custom helix with variable radius extrude not working."""

import math
import os
from dataclasses import dataclass

import build123d as bd
from ocp_vscode import show

@dataclass
class ScrewSpec:
    """Specification for wavy ball screw."""

    ball_od: float = 1.588 + 0.1

    screw_pitch: float = 2.5
    screw_od: float = 2.4  # Just under 2.5mm
    screw_length: float = 2.5 * 4

    ball_points_per_turn: int = 32

# Define the varying radius function
def radius_function(
    z: float, *, pitch: float, start_radius: float, max_radius: float
) -> float:
    """Calculate the radius of a helix with a periodically varying radius."""
    # TODO: Consider adding an argument to support various functions,
    # like sine instead of linear.

    # Calculate position within a single pitch period
    z_normalized = z % pitch
    if z_normalized <= pitch / 2:
        return start_radius + (max_radius - start_radius) * (2 * z_normalized / pitch)

    # else:
    return max_radius - (max_radius - start_radius) * (
        2 * (z_normalized - pitch / 2) / pitch
    )

def insane_helix_points(
    *,
    start_radius: float,
    max_radius: float,
    pitch: float,
    height: float,
    points_per_turn: int = 100,
) -> list[tuple[float, float, float]]:
    """Generate points along a helix with a periodically varying radius."""
    # Number of turns
    num_turns = height / pitch

    # Generate points along the helix with varying radius
    points = []
    for i in range(int(num_turns * points_per_turn) + 1):
        z = i * pitch / points_per_turn
        theta = 2 * math.pi * z / pitch
        r = radius_function(
            z=z,
            pitch=pitch,
            start_radius=start_radius,
            max_radius=max_radius,
        )
        x = r * math.cos(theta)
        y = r * math.sin(theta)
        points.append((x, y, z))
    return points

def make_wavy_screw(spec: ScrewSpec) -> bd.Part:
    """Make the wavy screw that can raise and lower the balls.

    Lowest point will be where the center of the ball is at the
    radius of the screw. The circumference of the ball is at the
    radius of the screw, minus 0.1.

    Relevant learning docs: https://build123d.readthedocs.io/en/latest/examples_1.html#handle
    """
    p = bd.Part()

    _min_radius = spec.ball_od / 2 + 0.1  # - 0.5
    _max_radius = spec.screw_od / 2 + spec.ball_od / 2 - 0.2

    helix_points = insane_helix_points(
        start_radius=_min_radius,
        max_radius=_max_radius,
        pitch=spec.screw_pitch,
        # On height, subtract ball_od to prevent jamming/wedging.
        height=spec.screw_length - spec.ball_od,
        points_per_turn=spec.ball_points_per_turn,
    )
    helix = bd.Polyline(*helix_points)

    p += bd.sweep(
        path=helix,
        sections=(helix ^ 0) * bd.Circle(radius=spec.ball_od / 2),
        # multisection=True,
    )

    # Debugging: Helpful demo view.
    show(bd.Polyline(*helix_points))

    return p

if __name__ == "__main__":
    show(make_wavy_screw(ScrewSpec()))

The output is squished like this:

image

gumyr commented 4 weeks ago

OCCT has a very hard time with the helix so you'll be fighting OCCT not build123d to get this done. But here is the code:

# path = Helix(5, 10, 4, cone_angle=10) # Fails
# path = Helix(5, 10, 4)  # Fails
# path = Line((0, 0, 0), (10, 0, 0))  # Passes
path = CenterArc((0, 0), 20, start_angle=0, arc_size=90)  # Passes
profile_count = 40
profiles = [
    (path ^ (i / profile_count)) * Circle(1 + i / profile_count)
    for i in range(profile_count + 1)
]
screw = sweep(profiles, path, multisection=True)

image image

gumyr commented 4 weeks ago

However, I've found that if you break a helix up into small segments (less than one full revolution) it works a lot better (this is how thread is built):

path = Helix(5, 2.5, 4, cone_angle=10)
profile_count = 10
profiles = [
    (path ^ (i / profile_count)) * Circle(1 + i / profile_count)
    for i in range(profile_count + 1)
]
screw = sweep(profiles, path, multisection=True)

image

KilowattSynthesis commented 3 weeks ago

Note that my example above doesn't vary the radius of the circle, but rather of the spiral itself (i.e., the radius of the helix, not the circular sections). I can't use the helix function as the helix function has a constant radius.

Maybe I need to use arcs instead of line segments. Why can't line segments used to make a helix and then sweep it though? It seems like this should be possible.

Is there an issue we can open on OCCT if that's where the problems are? Any idea what the problem on that side is, in words?

gumyr commented 3 weeks ago
path = Helix(5, 10, 4, cone_angle=20) 
profile = (path ^ 0) * Circle(1)
screw = sweep(profile, path)

The cone_angle parameter varies the radius of the Helix. image

Any Edge/Wire can be used as the pah of the sweep, including a Polyline but it won't be smooth. Use a Spline if you want a smooth sweep. Splines can easily be joined together by specifying the tangents - s1, s2 (with tangents (s1 % 1, something), s3 (tangents=(s2%1, something), etc..

I still don't understand what actual problem you have.

gumyr commented 3 weeks ago

Here is an example of using straight line segments like those of a Polyline:

path = RegularPolygon(10, 6).wire()
swept = sweep((path ^ 0) * Circle(1), path, transition=Transition.RIGHT)

image

Note that the transition is set here - you might need to explore to find the best option for your specific use-case.

gautaz commented 3 weeks ago

Hello,

Just as an input from a newbie regarding this matter: When I looked at the introductory example 14, I was suprised that the Rectangle swept along the path was not normal to the Line considering it was normal to both JernArcs.

gumyr commented 3 weeks ago

In example 14 the path is created on Plane.XY (the default) while the Rectangle is created on Plane.XZ which is perpendicular to Plane.XY.

gautaz commented 3 weeks ago

In example 14 the path is created on Plane.XY (the default) while the Rectangle is created on Plane.XZ which is perpendicular to Plane.XY.

I am not sure to get the point... Shouldn't each tangent to the path be normal to the swept rectangle all along the path? This seems the case along the two JenArcs but not along the Line part of the path which feels odd. I would have said that the section all along the path should keep the dimensions of the Rectangle. But along the Line part, the section is stretched because the swept Rectangle is "tilted".

jdegenstein commented 3 weeks ago

@gautaz I think this discussion is getting off topic from the original issue, but --

Between the arcs and the straight line segment there is a discontinuity. In most practical applications of sweep that I have seen the section is typically orthogonal to the path -- but that is NOT a requirement. The cross sectional area of the sweep along the line IS preserved, it is just that it is not orthogonal to the straight line path -- but rather parallel to Plane.YZ.

I personally do not support the overall feature request for this issue as I think it imposes too many assumptions about what a user wants.

KilowattSynthesis commented 3 weeks ago

I had tried lots of options with the transition argument before, but it appears that it works as-expected this time. It seems that the transition argument fulfills this feature request.

For sake of providing a minimum example of the technique I've been trying to do this whole time, see this example:

# ruff: noqa

import random

from ocp_vscode import show
import build123d as bd

def make_points() -> list[tuple[float, float, float]]:
    random.seed(32)  # Fix seed for reproducibility.
    points = []

    # Generate points moving rightward along the x-axis, with random y and z coordinates.
    for i in range(6):
        x = i * 10  # Increment x to move rightward
        y = random.uniform(-5, 5)
        z = random.uniform(-5, 5)
        points.append((x, y, z))

    return points

def main() -> bd.Part:
    points = make_points()

    path = bd.Polyline(points).wire()

    part = bd.Part() + bd.sweep(
        path=path,
        sections=(path ^ 0) * bd.Circle(radius=1),
        transition=bd.Transition.ROUND,
    )

    return part

show(main())

image

Note that the reason the cone_angle argument isn't applicable is because the radius varies with an arbitrary function of height, and not just linearly. Also, aligning the ends of two cones with a cone_angle is non-trivial, it seems.

gautaz commented 3 weeks ago

Hello,

@gautaz I think this discussion is getting off topic from the original issue, but --

My apologies, this was not my intent.

Between the arcs and the straight line segment there is a discontinuity. In most practical applications of sweep that I have seen the section is typically orthogonal to the path -- but that is NOT a requirement. The cross sectional area of the sweep along the line IS preserved, it is just that it is not orthogonal to the straight line path -- but rather parallel to Plane.YZ.

@jdegenstein I was suspecting that this was related to the discontinuity, thanks for confirming this. So this explains the current behavior and it seems to relate to what @KilowattSynthesis was expecting when he asked for keeping the "section to be normal to the path" behavior.

I personally do not support the overall feature request for this issue as I think it imposes too many assumptions about what a user wants.

That is in fact up to the user to decide. But one thing that the user wants for sure is consistency in behavior. If the path is normal to the section for part of the path and not for the rest, it will always feel odd and mind bugging for most users.

@KilowattSynthesis Thanks for providing this polyline sample. sections=(path ^ 0) * bd.Circle(radius=1) seems bound to the fact that the section is a circle, what if the section is not circular? Is there a way to generalize this technique?

Wouldn't the discontinuity be manageable with something like a loft between the section before the discontinuity and the section after it? (Sorry if this feels stupid, I am rather out of my depth here)

jdegenstein commented 3 weeks ago

That is in fact up to the user to decide. But one thing that the user wants for sure is consistency in behavior. If the path is normal to the section for part of the path and not for the rest, it will always feel odd and mind bugging for most users.

The problem is that the software would need to make an assumption about what to do at the discontinuity -- that is a very non-trivial choice and way beyond the scope of the sweep functionality from OCCT that build123d uses. I am closing this issue as it appears it is resolved according to the OP.