mirror of
https://github.com/gumyr/build123d.git
synced 2025-12-05 18:20:46 -08:00
Adding BlendCurve Issue #1054
Some checks are pending
benchmarks / benchmarks (macos-13, 3.12) (push) Waiting to run
benchmarks / benchmarks (macos-14, 3.12) (push) Waiting to run
benchmarks / benchmarks (ubuntu-latest, 3.12) (push) Waiting to run
benchmarks / benchmarks (windows-latest, 3.12) (push) Waiting to run
Upload coverage reports to Codecov / run (push) Waiting to run
pylint / lint (3.10) (push) Waiting to run
Run type checker / typecheck (3.10) (push) Waiting to run
Run type checker / typecheck (3.13) (push) Waiting to run
Wheel building and publishing / Build wheel on ubuntu-latest (push) Waiting to run
Wheel building and publishing / upload_pypi (push) Blocked by required conditions
tests / tests (macos-13, 3.10) (push) Waiting to run
tests / tests (macos-13, 3.13) (push) Waiting to run
tests / tests (macos-14, 3.10) (push) Waiting to run
tests / tests (macos-14, 3.13) (push) Waiting to run
tests / tests (ubuntu-latest, 3.10) (push) Waiting to run
tests / tests (ubuntu-latest, 3.13) (push) Waiting to run
tests / tests (windows-latest, 3.10) (push) Waiting to run
tests / tests (windows-latest, 3.13) (push) Waiting to run
Some checks are pending
benchmarks / benchmarks (macos-13, 3.12) (push) Waiting to run
benchmarks / benchmarks (macos-14, 3.12) (push) Waiting to run
benchmarks / benchmarks (ubuntu-latest, 3.12) (push) Waiting to run
benchmarks / benchmarks (windows-latest, 3.12) (push) Waiting to run
Upload coverage reports to Codecov / run (push) Waiting to run
pylint / lint (3.10) (push) Waiting to run
Run type checker / typecheck (3.10) (push) Waiting to run
Run type checker / typecheck (3.13) (push) Waiting to run
Wheel building and publishing / Build wheel on ubuntu-latest (push) Waiting to run
Wheel building and publishing / upload_pypi (push) Blocked by required conditions
tests / tests (macos-13, 3.10) (push) Waiting to run
tests / tests (macos-13, 3.13) (push) Waiting to run
tests / tests (macos-14, 3.10) (push) Waiting to run
tests / tests (macos-14, 3.13) (push) Waiting to run
tests / tests (ubuntu-latest, 3.10) (push) Waiting to run
tests / tests (ubuntu-latest, 3.13) (push) Waiting to run
tests / tests (windows-latest, 3.10) (push) Waiting to run
tests / tests (windows-latest, 3.13) (push) Waiting to run
This commit is contained in:
parent
033ad04b70
commit
6028b14aa0
4 changed files with 313 additions and 1 deletions
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -80,6 +80,7 @@ __all__ = [
|
|||
# 1D Curve Objects
|
||||
"BaseLineObject",
|
||||
"Bezier",
|
||||
"BlendCurve",
|
||||
"CenterArc",
|
||||
"DoubleTangentArc",
|
||||
"EllipticalCenterArc",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
144
tests/test_blendcurve.py
Normal file
144
tests/test_blendcurve.py
Normal file
|
|
@ -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)),
|
||||
)
|
||||
Loading…
Add table
Add a link
Reference in a new issue