diff --git a/docs/cheat_sheet.rst b/docs/cheat_sheet.rst index 4b88edb..d46ccf8 100644 --- a/docs/cheat_sheet.rst +++ b/docs/cheat_sheet.rst @@ -18,6 +18,7 @@ Cheat Sheet | :class:`~objects_curve.ArcArcTangentArc` | :class:`~objects_curve.ArcArcTangentLine` | :class:`~objects_curve.Bezier` + | :class:`~objects_curve.BlendCurve` | :class:`~objects_curve.CenterArc` | :class:`~objects_curve.DoubleTangentArc` | :class:`~objects_curve.EllipticalCenterArc` diff --git a/src/build123d/__init__.py b/src/build123d/__init__.py index dc25ee1..a2ccbfc 100644 --- a/src/build123d/__init__.py +++ b/src/build123d/__init__.py @@ -80,6 +80,7 @@ __all__ = [ # 1D Curve Objects "BaseLineObject", "Bezier", + "BlendCurve", "CenterArc", "DoubleTangentArc", "EllipticalCenterArc", diff --git a/src/build123d/objects_curve.py b/src/build123d/objects_curve.py index 3ea3adf..e697145 100644 --- a/src/build123d/objects_curve.py +++ b/src/build123d/objects_curve.py @@ -30,6 +30,7 @@ from __future__ import annotations import copy as copy_module from collections.abc import Iterable +from itertools import product from math import copysign, cos, radians, sin, sqrt from scipy.optimize import minimize import sympy # type: ignore @@ -37,6 +38,7 @@ import sympy # type: ignore from build123d.build_common import WorkplaneList, flatten_sequence, validate_inputs from build123d.build_enums import ( AngularDirection, + ContinuityLevel, GeomType, LengthMode, Keep, @@ -78,7 +80,8 @@ class BaseLineObject(Wire): def __init__(self, curve: Wire, mode: Mode = Mode.ADD): # Use the helper function to handle adding the curve to the context _add_curve_to_context(curve, mode) - super().__init__(curve.wrapped) + if curve.wrapped is not None: + super().__init__(curve.wrapped) class BaseEdgeObject(Edge): @@ -128,6 +131,169 @@ class Bezier(BaseEdgeObject): super().__init__(curve, mode=mode) +class BlendCurve(BaseEdgeObject): + """Line Object: BlendCurve + + Create a smooth Bézier-based transition curve between two existing edges. + + The blend is constructed as a cubic (C1) or quintic (C2) Bézier curve + whose control points are determined from the position, first derivative, + and (for C2) second derivative of the input curves at the chosen endpoints. + Optional scalar multipliers can be applied to the endpoint tangents to + control the "tension" of the blend. + + Args: + curve0 (Edge): First curve to blend from. + curve1 (Edge): Second curve to blend to. + continuity (ContinuityLevel, optional): + Desired geometric continuity at the join: + - ContinuityLevel.C0: position match only (straight line) + - ContinuityLevel.C1: match position and tangent direction (cubic Bézier) + - ContinuityLevel.C2: match position, tangent, and curvature (quintic Bézier) + Defaults to ContinuityLevel.C2. + end_points (tuple[VectorLike, VectorLike] | None, optional): + Pair of points specifying the connection points on `curve0` and `curve1`. + Each must coincide (within TOLERANCE) with the start or end of the + respective curve. If None, the closest pair of endpoints is chosen. + Defaults to None. + tangent_scalars (tuple[float, float] | None, optional): + Scalar multipliers applied to the first derivatives at the start + of `curve0` and the end of `curve1` before computing control points. + Useful for adjusting the pull/tension of the blend without altering + the base curves. Defaults to (1.0, 1.0). + mode (Mode, optional): Boolean operation mode when used in a + BuildLine context. Defaults to Mode.ADD. + + Raises: + ValueError: `tangent_scalars` must be a pair of float values. + ValueError: If specified `end_points` are not coincident with the start + or end of their respective curves. + + Example: + >>> blend = BlendCurve(curve_a, curve_b, ContinuityLevel.C1, tangent_scalars=(1.2, 0.8)) + >>> show(blend) + """ + + def __init__( + self, + curve0: Edge, + curve1: Edge, + continuity: ContinuityLevel = ContinuityLevel.C2, + end_points: tuple[VectorLike, VectorLike] | None = None, + tangent_scalars: tuple[float, float] | None = None, + mode: Mode = Mode.ADD, + ): + # + # Process the inputs + + tan_scalars = (1.0, 1.0) if tangent_scalars is None else tangent_scalars + if len(tan_scalars) != 2: + raise ValueError("tangent_scalars must be a (start, end) pair") + + # Find the vertices that will be connected using closest if None + end_pnts = ( + min( + product(curve0.vertices(), curve1.vertices()), + key=lambda pair: pair[0].distance_to(pair[1]), + ) + if end_points is None + else end_points + ) + + # Find the Edge parameter that matches the end points + curves: tuple[Edge, Edge] = (curve0, curve1) + end_params = [0, 0] + for i, end_pnt in enumerate(end_pnts): + curve_start_pnt = curves[i].position_at(0) + curve_end_pnt = curves[i].position_at(1) + given_end_pnt = Vector(end_pnt) + if (given_end_pnt - curve_start_pnt).length < TOLERANCE: + end_params[i] = 0 + elif (given_end_pnt - curve_end_pnt).length < TOLERANCE: + end_params[i] = 1 + else: + raise ValueError( + "end_points must be at either the start or end of a curve" + ) + + # + # Bézier endpoint derivative constraints (degree n=5 case) + # + # For a degree-n Bézier curve: + # B(t) = Σ_{i=0}^n binom(n,i) (1-t)^(n-i) t^i P_i + # B'(t) = n(P_1 - P_0) at t=0 + # n(P_n - P_{n-1}) at t=1 + # B''(t) = n(n-1)(P_2 - 2P_1 + P_0) at t=0 + # n(n-1)(P_{n-2} - 2P_{n-1} + P_n) at t=1 + # + # Matching a desired start derivative D0 and curvature vector K0: + # P1 = P0 + (1/n) * D0 + # P2 = P0 + (2/n) * D0 + (1/(n*(n-1))) * K0 + # + # Matching a desired end derivative D1 and curvature vector K1: + # P_{n-1} = P_n - (1/n) * D1 + # P_{n-2} = P_n - (2/n) * D1 + (1/(n*(n-1))) * K1 + # + # For n=5 specifically: + # P1 = P0 + D0 / 5 + # P2 = P0 + (2*D0)/5 + K0/20 + # P4 = P5 - D1 / 5 + # P3 = P5 - (2*D1)/5 + K1/20 + # + # D0, D1 are first derivatives at endpoints (can be scaled for tension). + # K0, K1 are second derivatives at endpoints (for C² continuity). + # Works in any dimension; P_i are vectors in ℝ² or ℝ³. + + # + # | Math symbol | Meaning in code | Python name | + # | ----------- | -------------------------- | ------------ | + # | P_0 | start position | start_pos | + # | P_1 | 1st control pt after start | ctrl_pnt1 | + # | P_2 | 2nd control pt after start | ctrl_pnt2 | + # | P_{n-2} | 2nd control pt before end | ctrl_pnt3 | + # | P_{n-1} | 1st control pt before end | ctrl_pnt4 | + # | P_n | end position | end_pos | + # | D_0 | derivative at start | start_deriv | + # | D_1 | derivative at end | end_deriv | + # | K_0 | curvature vec at start | start_curv | + # | K_1 | curvature vec at end | end_curv | + + start_pos = curve0.position_at(end_params[0]) + end_pos = curve1.position_at(end_params[1]) + + # Note: derivative_at(..,1) is being used instead of tangent_at as + # derivate_at isn't normalized which allows for a natural "speed" to be used + # if no scalar is provided. + start_deriv = curve0.derivative_at(end_params[0], 1) * tan_scalars[0] + end_deriv = curve1.derivative_at(end_params[1], 1) * tan_scalars[1] + + if continuity == ContinuityLevel.C0: + joining_curve = Line(start_pos, end_pos) + elif continuity == ContinuityLevel.C1: + cntl_pnt1 = start_pos + start_deriv / 3 + cntl_pnt4 = end_pos - end_deriv / 3 + cntl_pnts = [start_pos, cntl_pnt1, cntl_pnt4, end_pos] # degree-3 Bézier + joining_curve = Bezier(*cntl_pnts) + else: # C2 + start_curv = curve0.derivative_at(end_params[0], 2) + end_curv = curve1.derivative_at(end_params[1], 2) + cntl_pnt1 = start_pos + start_deriv / 5 + cntl_pnt2 = start_pos + (2 * start_deriv) / 5 + start_curv / 20 + cntl_pnt4 = end_pos - end_deriv / 5 + cntl_pnt3 = end_pos - (2 * end_deriv) / 5 + end_curv / 20 + cntl_pnts = [ + start_pos, + cntl_pnt1, + cntl_pnt2, + cntl_pnt3, + cntl_pnt4, + end_pos, + ] # degree-5 Bézier + joining_curve = Bezier(*cntl_pnts) + + super().__init__(joining_curve, mode=mode) + + class CenterArc(BaseEdgeObject): """Line Object: Center Arc diff --git a/tests/test_blendcurve.py b/tests/test_blendcurve.py new file mode 100644 index 0000000..c3abafc --- /dev/null +++ b/tests/test_blendcurve.py @@ -0,0 +1,144 @@ +""" +build123d tests + +name: test_blendcurve.py +by: Gumyr +date: September 2, 2025 + +desc: + This python module contains pytests for the build123d BlendCurve object. + +license: + + Copyright 2025 Gumyr + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + +""" + +import pytest + +from build123d.objects_curve import BlendCurve, CenterArc, Spline, Line +from build123d.geometry import Vector, Pos, TOLERANCE +from build123d.build_enums import ContinuityLevel, GeomType + + +def _vclose(a: Vector, b: Vector, tol: float = TOLERANCE) -> bool: + return (a - b).length <= tol + + +def _either_close(p: Vector, a: Vector, b: Vector, tol: float = TOLERANCE) -> bool: + return _vclose(p, a, tol) or _vclose(p, b, tol) + + +def make_edges(): + """ + Arc + spline pair similar to the user demo: + - arc radius 5, moved left a bit, reversed so the join uses the arc's 'end' + - symmetric spline with a dip + """ + m1 = Pos(-10, 3) * CenterArc((0, 0), 5, -10, 200).reversed() + m2 = Pos(5, -13) * Spline((-3, 9), (0, 0), (3, 9)) + return m1, m2 + + +def test_c0_positions_match_endpoints(): + m1, m2 = make_edges() + + # No end_points passed -> should auto-pick closest pair of vertices. + bc = BlendCurve(m1, m2, continuity=ContinuityLevel.C0) + + # Start of connector must be one of m1's endpoints; end must be one of m2's endpoints. + m1_p0, m1_p1 = m1.position_at(0), m1.position_at(1) + m2_p0, m2_p1 = m2.position_at(0), m2.position_at(1) + + assert _either_close(bc.position_at(0), m1_p0, m1_p1) + assert _either_close(bc.position_at(1), m2_p0, m2_p1) + + # Geometry type should be a line for C0. + assert bc.geom_type == GeomType.LINE + + +@pytest.mark.parametrize("continuity", [ContinuityLevel.C1, ContinuityLevel.C2]) +def test_c1_c2_tangent_matches_with_scalars(continuity): + m1, m2 = make_edges() + + # Force a specific endpoint pairing to avoid ambiguity + start_pt = m1.position_at(1) # arc end + end_pt = m2.position_at(0) # spline start + s0, s1 = 1.7, 0.8 + + bc = BlendCurve( + m1, + m2, + continuity=continuity, + end_points=(start_pt, end_pt), + tangent_scalars=(s0, s1), + ) + + # Positions must match exactly at the ends + assert _vclose(bc.position_at(0), start_pt) + assert _vclose(bc.position_at(1), end_pt) + + # First-derivative (tangent) must match inputs * scalars + exp_d1_start = m1.derivative_at(1, 1) * s0 + exp_d1_end = m2.derivative_at(0, 1) * s1 + + got_d1_start = bc.derivative_at(0, 1) + got_d1_end = bc.derivative_at(1, 1) + + assert _vclose(got_d1_start, exp_d1_start) + assert _vclose(got_d1_end, exp_d1_end) + + # C1/C2 connectors are Bezier curves + assert bc.geom_type == GeomType.BEZIER + + if continuity == ContinuityLevel.C2: + # Second derivative must also match at both ends + exp_d2_start = m1.derivative_at(1, 2) + exp_d2_end = m2.derivative_at(0, 2) + + got_d2_start = bc.derivative_at(0, 2) + got_d2_end = bc.derivative_at(1, 2) + + assert _vclose(got_d2_start, exp_d2_start) + assert _vclose(got_d2_end, exp_d2_end) + + +def test_auto_select_closest_endpoints_simple_lines(): + # Construct two simple lines with an unambiguous closest-endpoint pair + a = Line((0, 0), (1, 0)) + b = Line((2, 0), (2, 1)) + + bc = BlendCurve(a, b, continuity=ContinuityLevel.C0) + + assert _vclose(bc.position_at(0), a.position_at(1)) # (1,0) + assert _vclose(bc.position_at(1), b.position_at(0)) # (2,0) + + +def test_invalid_tangent_scalars_raises(): + m1, m2 = make_edges() + with pytest.raises(ValueError): + BlendCurve(m1, m2, tangent_scalars=(1.0,), continuity=ContinuityLevel.C1) + + +def test_invalid_end_points_raises(): + m1, m2 = make_edges() + bad_point = m1.position_at(0.5) # not an endpoint + with pytest.raises(ValueError): + BlendCurve( + m1, + m2, + continuity=ContinuityLevel.C1, + end_points=(bad_point, m2.position_at(0)), + )