mirror of
https://github.com/gumyr/build123d.git
synced 2025-12-06 02:30:55 -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.ArcArcTangentArc`
|
||||||
| :class:`~objects_curve.ArcArcTangentLine`
|
| :class:`~objects_curve.ArcArcTangentLine`
|
||||||
| :class:`~objects_curve.Bezier`
|
| :class:`~objects_curve.Bezier`
|
||||||
|
| :class:`~objects_curve.BlendCurve`
|
||||||
| :class:`~objects_curve.CenterArc`
|
| :class:`~objects_curve.CenterArc`
|
||||||
| :class:`~objects_curve.DoubleTangentArc`
|
| :class:`~objects_curve.DoubleTangentArc`
|
||||||
| :class:`~objects_curve.EllipticalCenterArc`
|
| :class:`~objects_curve.EllipticalCenterArc`
|
||||||
|
|
|
||||||
|
|
@ -80,6 +80,7 @@ __all__ = [
|
||||||
# 1D Curve Objects
|
# 1D Curve Objects
|
||||||
"BaseLineObject",
|
"BaseLineObject",
|
||||||
"Bezier",
|
"Bezier",
|
||||||
|
"BlendCurve",
|
||||||
"CenterArc",
|
"CenterArc",
|
||||||
"DoubleTangentArc",
|
"DoubleTangentArc",
|
||||||
"EllipticalCenterArc",
|
"EllipticalCenterArc",
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,7 @@ from __future__ import annotations
|
||||||
|
|
||||||
import copy as copy_module
|
import copy as copy_module
|
||||||
from collections.abc import Iterable
|
from collections.abc import Iterable
|
||||||
|
from itertools import product
|
||||||
from math import copysign, cos, radians, sin, sqrt
|
from math import copysign, cos, radians, sin, sqrt
|
||||||
from scipy.optimize import minimize
|
from scipy.optimize import minimize
|
||||||
import sympy # type: ignore
|
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_common import WorkplaneList, flatten_sequence, validate_inputs
|
||||||
from build123d.build_enums import (
|
from build123d.build_enums import (
|
||||||
AngularDirection,
|
AngularDirection,
|
||||||
|
ContinuityLevel,
|
||||||
GeomType,
|
GeomType,
|
||||||
LengthMode,
|
LengthMode,
|
||||||
Keep,
|
Keep,
|
||||||
|
|
@ -78,6 +80,7 @@ class BaseLineObject(Wire):
|
||||||
def __init__(self, curve: Wire, mode: Mode = Mode.ADD):
|
def __init__(self, curve: Wire, mode: Mode = Mode.ADD):
|
||||||
# Use the helper function to handle adding the curve to the context
|
# Use the helper function to handle adding the curve to the context
|
||||||
_add_curve_to_context(curve, mode)
|
_add_curve_to_context(curve, mode)
|
||||||
|
if curve.wrapped is not None:
|
||||||
super().__init__(curve.wrapped)
|
super().__init__(curve.wrapped)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -128,6 +131,169 @@ class Bezier(BaseEdgeObject):
|
||||||
super().__init__(curve, mode=mode)
|
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):
|
class CenterArc(BaseEdgeObject):
|
||||||
"""Line Object: Center Arc
|
"""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