Merge branch 'dev' into lexer
2
.github/workflows/benchmark.yml
vendored
|
|
@ -12,7 +12,7 @@ jobs:
|
|||
# "3.11",
|
||||
"3.12",
|
||||
]
|
||||
os: [macos-13, macos-14, ubuntu-latest, windows-latest]
|
||||
os: [macos-15-intel, macos-14, ubuntu-latest, windows-latest]
|
||||
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
|
|
|
|||
2
.github/workflows/test.yml
vendored
|
|
@ -13,7 +13,7 @@ jobs:
|
|||
# "3.12",
|
||||
"3.13",
|
||||
]
|
||||
os: [macos-13, macos-14, ubuntu-latest, windows-latest]
|
||||
os: [macos-15-intel, macos-14, ubuntu-latest, windows-latest]
|
||||
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
|
|
|
|||
|
|
@ -10,6 +10,10 @@ build:
|
|||
python: "3.10"
|
||||
apt_packages:
|
||||
- graphviz
|
||||
jobs:
|
||||
post_checkout:
|
||||
# necessary to ensure that the development builds get a correct version tag
|
||||
- git fetch --unshallow || true
|
||||
|
||||
# Build from the docs/ directory with Sphinx
|
||||
sphinx:
|
||||
|
|
@ -21,8 +25,3 @@ python:
|
|||
path: .
|
||||
extra_requirements:
|
||||
- docs
|
||||
|
||||
# Explicitly set the version of Python and its requirements
|
||||
# python:
|
||||
# install:
|
||||
# - requirements: docs/requirements.txt
|
||||
|
|
|
|||
BIN
docs/_static/spitfire_wing.glb
vendored
Normal file
8
docs/assets/example_airfoil.svg
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
<?xml version='1.0' encoding='utf-8'?>
|
||||
<svg width="100.089989mm" height="13.093747mm" viewBox="-0.000798 -0.085182 1.001332 0.130994" version="1.1" xmlns="http://www.w3.org/2000/svg">
|
||||
<g transform="scale(1,-1)" stroke-linecap="round">
|
||||
<g fill="none" stroke="rgb(0,0,0)" stroke-width="0.0009003885533792664">
|
||||
<path d="M 1.0,-0.0 C 0.829823,0.035412 0.658159,0.060683 0.48501,0.075813 C 0.403802,0.083052 0.322454,0.085791 0.240967,0.084029 C 0.198485,0.08366 0.156475,0.079149 0.114938,0.070497 C 0.074523,0.05954 0.027058,0.048994 0.001793,0.012613 C -0.0025,0.000458 0.001085,-0.00896 0.012548,-0.01564 C 0.02178,-0.020765 0.031585,-0.024392 0.041962,-0.026521 C 0.062557,-0.030869 0.083358,-0.033834 0.104363,-0.035416 C 0.149789,-0.038322 0.195215,-0.041227 0.24064,-0.044133 C 0.321442,-0.046309 0.402208,-0.045216 0.482939,-0.040853 C 0.655652,-0.03182 0.828005,-0.018202 1.0,-0.0" />
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 924 B |
BIN
docs/assets/surface_modeling/heart_token.png
Normal file
|
After Width: | Height: | Size: 27 KiB |
BIN
docs/assets/surface_modeling/spitfire_wing.png
Normal file
|
After Width: | Height: | Size: 44 KiB |
|
|
@ -0,0 +1,11 @@
|
|||
<?xml version='1.0' encoding='utf-8'?>
|
||||
<svg width="100.09mm" height="66.151142mm" viewBox="-541.648921 -836.924186 4767.847817 3151.14975" version="1.1" xmlns="http://www.w3.org/2000/svg">
|
||||
<g transform="scale(1,-1)" stroke-linecap="round">
|
||||
<g fill="none" stroke="rgb(0,0,0)" stroke-width="4.28720455097892">
|
||||
<path d="M 1445.821235,834.74528 C 1353.526765,803.718243 1260.88331,770.545273 1167.994034,735.580745 C 1083.806246,703.883592 999.414296,670.712071 914.854789,636.113423 C 835.638754,603.69438 756.274425,570.022422 676.77418,534.952257 C 592.583511,497.803918 508.238612,459.095508 423.75228,418.390166 C 381.475605,398.017947 339.163641,377.140102 296.820744,355.66775 C 255.380094,334.651781 213.909574,313.067151 172.417787,290.800527 C 132.498839,269.376021 92.559713,247.323024 52.616058,224.499576 C 33.433555,213.541072 14.251486,202.395739 -4.930148,191.063577 C -23.484296,180.09936 -42.03687,168.953007 -60.583168,157.587759 C -78.431472,146.650245 -96.273964,135.510001 -114.110039,124.195809 C -131.629708,113.082326 -149.143185,101.801012 -166.62766,90.088045 C -183.749938,78.617716 -200.844402,66.733433 -217.887184,54.406354 C -249.053676,31.859239 -280.057077,7.844124 -310.705712,-17.830688 C -324.493158,-29.38285 -338.206616,-41.273839 -351.816854,-53.534273 C -364.358757,-64.832326 -376.813009,-76.4441 -389.144607,-88.405566 C -400.391943,-99.315314 -411.537246,-110.515963 -422.53694,-122.049338 C -432.452058,-132.445516 -442.248865,-143.112041 -451.870806,-154.096773 C -460.428098,-163.866068 -468.84708,-173.887048 -477.05134,-184.212339 C -484.238191,-193.257193 -491.260272,-202.535563 -498.010658,-212.099865 C -503.829085,-220.343718 -509.445657,-228.800001 -514.706406,-237.507752 C -519.174839,-244.90404 -523.386558,-252.481753 -527.120208,-260.233274 C -530.2732,-266.779278 -533.085259,-273.449232 -535.256161,-280.125931 C -537.126266,-285.877518 -538.520579,-291.634109 -539.139475,-297.089851 C -539.710024,-302.119395 -539.621572,-306.893256 -538.815367,-311.085197 C -538.009295,-315.276448 -536.485707,-318.88597 -534.41361,-321.787038 C -532.173969,-324.922679 -529.293531,-327.230668 -526.080734,-328.912927 C -522.384603,-330.848265 -518.248591,-331.955443 -513.912155,-332.549837 C -508.837989,-333.245352 -503.489405,-333.238769 -498.016517,-332.811088 C -491.653993,-332.313885 -485.123469,-331.247553 -478.514247,-329.834557 C -470.940437,-328.21534 -463.263282,-326.140884 -455.535716,-323.780678 C -446.817103,-321.117779 -438.03432,-318.091135 -429.219119,-314.830251 C -419.4158,-311.203845 -409.572388,-307.287744 -399.708184,-303.182158 C -388.876522,-298.673905 -378.01979,-293.937177 -367.149854,-289.051048 C -355.343719,-283.74409 -343.522009,-278.26089 -331.692086,-272.665067 C -318.963322,-266.644073 -306.225051,-260.492693 -293.48194,-254.263696 C -279.88052,-247.615147 -266.273587,-240.878173 -252.664091,-234.095751 C -238.238065,-226.906402 -223.809159,-219.665987 -209.379903,-212.417551 C -194.175885,-204.777738 -178.971635,-197.135641 -163.767154,-189.491259 C -147.290706,-181.209732 -130.81417,-172.933298 -114.327757,-164.445833 C -96.638051,-155.338894 -78.936973,-145.988995 -61.23183,-136.489711 C -42.83628,-126.618999 -24.437139,-116.590472 -6.034405,-106.404129 C 12.988826,-95.875361 32.015044,-85.181632 51.041971,-74.350743 C 90.663309,-51.796303 130.287184,-28.649245 169.904219,-5.058022 C 211.090446,19.468633 252.269483,44.474456 293.437331,69.839459 C 335.517614,95.767754 377.586396,122.072076 419.642854,148.660515 C 503.76059,201.847286 587.828904,256.147719 671.854879,311.120502 C 751.312767,363.113312 830.733706,415.715977 910.121501,468.777679 C 995.01224,525.524664 1079.865697,582.797171 1164.674248,640.617824 C 1192.283028,659.441198 1219.886541,678.326945 1247.484787,697.275067 C 1270.73957,713.242184 1293.996044,729.258338 1317.241888,745.321373 C 1327.107887,752.139255 1336.976715,758.968255 1346.848371,765.808374 C 1355.493823,771.798596 1364.146132,777.799924 1372.767724,783.793106 C 1380.127277,788.908999 1387.464446,794.018955 1394.889693,799.180553 C 1401.009487,803.434676 1407.189112,807.723879 1413.119965,811.887911 C 1417.7599,815.145595 1422.247573,818.326668 1427.380659,821.845665 C 1431.063255,824.370277 1435.078042,827.068818 1437.610752,828.998585 C 1439.134823,830.159833 1440.12222,831.0427 1443.76641,833.306567 C 1444.982916,834.062292 1446.495484,834.97191 1445.821235,834.74528" />
|
||||
<path d="M 4088.698422,-2201.97176 C 4071.863286,-2209.539657 4054.994642,-2217.480665 4038.106206,-2225.738798 C 4022.573261,-2233.338843 4007.022586,-2241.206509 3991.458597,-2249.421198 C 3983.516432,-2253.614748 3975.570735,-2257.900919 3967.622793,-2262.30905 C 3960.261943,-2266.392612 3952.898981,-2270.579106 3945.536956,-2274.915202 C 3943.845675,-2275.912386 3942.154454,-2276.917073 3940.463292,-2277.929262 C 3938.829503,-2278.906677 3937.195728,-2279.889856 3935.56293,-2280.905309 C 3931.124503,-2283.667466 3926.692829,-2286.658395 3922.292205,-2289.898531 C 3918.777241,-2292.48891 3915.280436,-2295.235265 3911.829585,-2298.184808 C 3910.073846,-2299.687297 3908.329767,-2301.242202 3906.611091,-2302.876449 C 3905.350277,-2304.077409 3904.101247,-2305.31876 3902.891914,-2306.64651 C 3902.453922,-2307.124054 3902.026791,-2307.61984 3901.610521,-2308.133868 C 3901.299294,-2308.518314 3901.002293,-2308.919081 3900.719517,-2309.336169 C 3900.527397,-2309.621912 3900.344384,-2309.916518 3900.220419,-2310.243794 C 3900.138415,-2310.46029 3900.08225,-2310.691082 3900.113043,-2310.851579 C 3900.143825,-2311.012017 3900.2615,-2311.10221 3900.393136,-2311.146406 C 3900.591347,-2311.212954 3900.82121,-2311.175213 3901.054853,-2311.119196 C 3901.395934,-2311.034587 3901.742062,-2310.920466 3902.093238,-2310.776833 C 3902.560145,-2310.589421 3903.03076,-2310.365917 3903.501882,-2310.129577 C 3904.091767,-2309.832167 3904.682134,-2309.519273 3905.272983,-2309.190897 C 3906.803549,-2308.34235 3908.334632,-2307.429826 3909.864882,-2306.507962 C 3911.837632,-2305.316998 3913.809818,-2304.101833 3915.782074,-2302.891418 C 3918.161608,-2301.426976 3920.541478,-2299.976332 3922.921685,-2298.539486 C 3927.169463,-2295.979761 3931.419103,-2293.492922 3935.667474,-2291.12314 C 3937.263565,-2290.232961 3938.85938,-2289.359703 3940.455335,-2288.478295 C 3942.141204,-2287.546398 3943.827175,-2286.607933 3945.51325,-2285.6629 C 3952.851904,-2281.554558 3960.1915,-2277.358362 3967.530929,-2273.121841 C 3975.456854,-2268.545879 3983.382716,-2263.92158 3991.308409,-2259.279052 C 4006.85028,-2250.172017 4022.391679,-2241.00337 4037.933217,-2231.854444 C 4045.982183,-2227.115049 4054.031164,-2222.377487 4062.08016,-2217.64176 C 4066.897545,-2214.806906 4071.71493,-2211.972052 4076.532315,-2209.137199 C 4078.284341,-2208.105943 4080.036161,-2207.074566 4081.787775,-2206.043066 C 4082.482313,-2205.633843 4083.180026,-2205.222922 4083.880913,-2204.810304 C 4084.459844,-2204.469581 4085.044497,-2204.12585 4085.605526,-2203.794355 C 4086.04433,-2203.535079 4086.468682,-2203.283289 4086.954417,-2202.999576 C 4087.302828,-2202.796074 4087.682821,-2202.576147 4087.921957,-2202.429398 C 4088.065839,-2202.341102 4088.15873,-2202.279298 4088.504102,-2202.086294 C 4088.619387,-2202.02187 4088.762804,-2201.942827 4088.698422,-2201.97176" />
|
||||
<path d="M -538.815367,-311.085197 L -91.009814,-565.713777 L 357.94024,-813.228178 L 802.388988,-1050.515762 L 1236.747229,-1274.592501 L 1655.552655,-1482.6405 L 2053.538547,-1672.043435 L 2425.7,-1840.419453 L 2767.356868,-1985.651129 L 3074.212618,-2105.912089 L 3342.40836,-2199.689984 L 3568.571378,-2265.8055 L 3749.85754,-2303.427198 L 3883.987068,-2312.081962 L 3969.273206,-2291.660954" />
|
||||
<path d="M 3969.273206,-2291.660954 L 4106.195866,-2183.789653 L 4191.480731,-2048.45593 L 4224.055294,-1887.361685 L 4203.50991,-1702.532772 L 4130.102951,-1496.293521 L 4004.75755,-1271.237512 L 3829.05,-1030.194953 L 3605.189924,-776.197095 L 3335.992494,-512.438108 L 3024.843022,-242.234914 L 2675.654394,31.014524 L 2292.817859,303.873937 L 1881.14781,572.911958 L 1445.821235,834.74528" />
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 7.9 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 54 KiB After Width: | Height: | Size: 54 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 26 KiB |
|
|
@ -15,6 +15,7 @@ Cheat Sheet
|
|||
|
||||
.. grid-item-card:: 1D - BuildLine
|
||||
|
||||
| :class:`~objects_curve.Airfoil`
|
||||
| :class:`~objects_curve.ArcArcTangentArc`
|
||||
| :class:`~objects_curve.ArcArcTangentLine`
|
||||
| :class:`~objects_curve.Bezier`
|
||||
|
|
|
|||
68
docs/heart_token.py
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
# [Code]
|
||||
from build123d import *
|
||||
from ocp_vscode import show
|
||||
|
||||
# Create the edges of one half the heart surface
|
||||
l1 = JernArc((0, 0), (1, 1.4), 40, -17)
|
||||
l2 = JernArc(l1 @ 1, l1 % 1, 4.5, 175)
|
||||
l3 = IntersectingLine(l2 @ 1, l2 % 1, other=Edge.make_line((0, 0), (0, 20)))
|
||||
l4 = ThreePointArc(l3 @ 1, (0, 0, 1.5) + (l3 @ 1 + l1 @ 0) / 2, l1 @ 0)
|
||||
heart_half = Wire([l1, l2, l3, l4])
|
||||
# [SurfaceEdges]
|
||||
|
||||
# Create a point elevated off the center
|
||||
surface_pnt = l2.arc_center + (0, 0, 1.5)
|
||||
# [SurfacePoint]
|
||||
|
||||
# Create the surface from the edges and point
|
||||
top_right_surface = Pos(Z=0.5) * -Face.make_surface(heart_half, [surface_pnt])
|
||||
# [Surface]
|
||||
|
||||
# Use the mirror method to create the other top and bottom surfaces
|
||||
top_left_surface = top_right_surface.mirror(Plane.YZ)
|
||||
bottom_right_surface = top_right_surface.mirror(Plane.XY)
|
||||
bottom_left_surface = -top_left_surface.mirror(Plane.XY)
|
||||
# [Surfaces]
|
||||
|
||||
# Create the left and right sides
|
||||
left_wire = Wire([l3, l2, l1])
|
||||
left_side = Pos(Z=-0.5) * Shell.extrude(left_wire, (0, 0, 1))
|
||||
right_side = left_side.mirror(Plane.YZ)
|
||||
# [Sides]
|
||||
|
||||
# Put all of the faces together into a Shell/Solid
|
||||
heart = Solid(
|
||||
Shell(
|
||||
[
|
||||
top_right_surface,
|
||||
top_left_surface,
|
||||
bottom_right_surface,
|
||||
bottom_left_surface,
|
||||
left_side,
|
||||
right_side,
|
||||
]
|
||||
)
|
||||
)
|
||||
# [Solid]
|
||||
|
||||
# Build a frame around the heart
|
||||
with BuildPart() as heart_token:
|
||||
with BuildSketch() as outline:
|
||||
with BuildLine():
|
||||
add(l1)
|
||||
add(l2)
|
||||
add(l3)
|
||||
Line(l3 @ 1, l1 @ 0)
|
||||
make_face()
|
||||
mirror(about=Plane.YZ)
|
||||
center = outline.sketch
|
||||
offset(amount=2, kind=Kind.INTERSECTION)
|
||||
add(center, mode=Mode.SUBTRACT)
|
||||
extrude(amount=2, both=True)
|
||||
add(heart)
|
||||
|
||||
heart_token.part.color = "Red"
|
||||
|
||||
show(heart_token)
|
||||
# [End]
|
||||
# export_gltf(heart_token.part, "heart_token.glb", binary=True)
|
||||
|
|
@ -76,6 +76,13 @@ The following objects all can be used in BuildLine contexts. Note that
|
|||
|
||||
.. grid:: 3
|
||||
|
||||
.. grid-item-card:: :class:`~objects_curve.Airfoil`
|
||||
|
||||
.. image:: assets/example_airfoil.svg
|
||||
|
||||
+++
|
||||
Airfoil described by 4 digit NACA profile
|
||||
|
||||
.. grid-item-card:: :class:`~objects_curve.Bezier`
|
||||
|
||||
.. image:: assets/bezier_curve_example.svg
|
||||
|
|
@ -228,6 +235,7 @@ Reference
|
|||
.. py:module:: objects_curve
|
||||
|
||||
.. autoclass:: BaseLineObject
|
||||
.. autoclass:: Airfoil
|
||||
.. autoclass:: Bezier
|
||||
.. autoclass:: BlendCurve
|
||||
.. autoclass:: CenterArc
|
||||
|
|
|
|||
77
docs/spitfire_wing_gordon.py
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
"""
|
||||
Supermarine Spitfire Wing
|
||||
"""
|
||||
|
||||
# [Code]
|
||||
|
||||
from build123d import *
|
||||
from ocp_vscode import show
|
||||
|
||||
wing_span = 36 * FT + 10 * IN
|
||||
wing_leading = 2.5 * FT
|
||||
wing_trailing = wing_span / 4 - wing_leading
|
||||
wing_leading_fraction = wing_leading / (wing_leading + wing_trailing)
|
||||
wing_tip_section = wing_span / 2 - 1 * IN # distance from root to last section
|
||||
|
||||
# Create leading and trailing edges
|
||||
leading_edge = EllipticalCenterArc(
|
||||
(0, 0), wing_span / 2, wing_leading, start_angle=270, end_angle=360
|
||||
)
|
||||
trailing_edge = EllipticalCenterArc(
|
||||
(0, 0), wing_span / 2, wing_trailing, start_angle=0, end_angle=90
|
||||
)
|
||||
|
||||
# [AirfoilSizes]
|
||||
# Calculate the airfoil sizes from the leading/trailing edges
|
||||
airfoil_sizes = []
|
||||
for i in [0, 1]:
|
||||
tip_axis = Axis(i * (wing_tip_section, 0, 0), (0, 1, 0))
|
||||
leading_pnt = leading_edge.intersect(tip_axis)[0]
|
||||
trailing_pnt = trailing_edge.intersect(tip_axis)[0]
|
||||
airfoil_sizes.append(trailing_pnt.Y - leading_pnt.Y)
|
||||
|
||||
# [Airfoils]
|
||||
# Create the root and tip airfoils - note that they are different NACA profiles
|
||||
airfoil_root = Plane.YZ * scale(
|
||||
Airfoil("2213").translate((-wing_leading_fraction, 0, 0)), airfoil_sizes[0]
|
||||
)
|
||||
airfoil_tip = (
|
||||
Plane.YZ
|
||||
* Pos(Z=wing_tip_section)
|
||||
* scale(Airfoil("2205").translate((-wing_leading_fraction, 0, 0)), airfoil_sizes[1])
|
||||
)
|
||||
|
||||
# [Profiles]
|
||||
# Create the Gordon surface profiles and guides
|
||||
profiles = airfoil_root.edges() + airfoil_tip.edges()
|
||||
profiles.append(leading_edge @ 1) # wing tip
|
||||
guides = [leading_edge, trailing_edge]
|
||||
# Create the wing surface as a Gordon Surface
|
||||
wing_surface = -Face.make_gordon_surface(profiles, guides)
|
||||
# Create the root of the wing
|
||||
wing_root = -Face(Wire(wing_surface.edges().filter_by(Edge.is_closed)))
|
||||
|
||||
# [Solid]
|
||||
# Create the wing Solid
|
||||
wing = Solid(Shell([wing_surface, wing_root]))
|
||||
wing.color = 0x99A3B9 # Azure Blue
|
||||
|
||||
show(wing)
|
||||
# [End]
|
||||
# Documentation artifact generation
|
||||
# wing_control_edges = Curve(
|
||||
# [airfoil_root, airfoil_tip, Vertex(leading_edge @ 1), leading_edge, trailing_edge]
|
||||
# )
|
||||
# visible, _ = wing_control_edges.project_to_viewport((50 * FT, -50 * FT, 50 * FT))
|
||||
# max_dimension = max(*Compound(children=visible).bounding_box().size)
|
||||
# svg = ExportSVG(scale=100 / max_dimension)
|
||||
# svg.add_shape(visible)
|
||||
# svg.write("assets/surface_modeling/spitfire_wing_profiles_guides.svg")
|
||||
|
||||
# export_gltf(
|
||||
# wing,
|
||||
# "assets/surface_modeling/spitfire_wing.glb",
|
||||
# binary=True,
|
||||
# linear_deflection=0.1,
|
||||
# angular_deflection=1,
|
||||
# )
|
||||
106
docs/tutorial_spitfire_wing_gordon.rst
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
#############################################
|
||||
Tutorial: Spitfire Wing with Gordon Surface
|
||||
#############################################
|
||||
|
||||
In this advanced tutorial we construct a Supermarine Spitfire wing as a
|
||||
:meth:`~topology.Face.make_gordon_surface`—a powerful technique for surfacing
|
||||
from intersecting *profiles* and *guides*. A Gordon surface blends a grid of
|
||||
curves into a smooth, coherent surface as long as the profiles and guides
|
||||
intersect consistently.
|
||||
|
||||
.. note::
|
||||
Gordon surfaces work best when *each profile intersects each guide exactly
|
||||
once*, producing a well‑formed curve network.
|
||||
|
||||
Overview
|
||||
========
|
||||
|
||||
We will:
|
||||
|
||||
1. Define overall wing dimensions and elliptic leading/trailing edge guide curves
|
||||
2. Sample the guides to size the root and tip airfoils (different NACA profiles)
|
||||
3. Build the Gordon surface from the airfoil *profiles* and wing‑edge *guides*
|
||||
4. Close the root with a planar face and build the final :class:`~topology.Solid`
|
||||
|
||||
.. raw:: html
|
||||
|
||||
<script type="module" src="https://unpkg.com/@google/model-viewer/dist/model-viewer.min.js"></script>
|
||||
<model-viewer poster="_images/spitfire_wing.png" src="_static/spitfire_wing.glb" alt="A tea cup modelled in build123d" auto-rotate camera-controls style="width: 100%; height: 50vh;"></model-viewer>
|
||||
|
||||
Step 1 — Dimensions and guide curves
|
||||
====================================
|
||||
|
||||
We model a single wing (half‑span), with an elliptic leading and trailing edge.
|
||||
These two edges act as the *guides* for the Gordon surface.
|
||||
|
||||
.. literalinclude:: spitfire_wing_gordon.py
|
||||
:start-after: [Code]
|
||||
:end-before: [AirfoilSizes]
|
||||
|
||||
|
||||
Step 2 — Root and tip airfoil sizing
|
||||
====================================
|
||||
|
||||
We intersect the guides with planes normal to the span to size the airfoil sections.
|
||||
The resulting chord lengths define uniform scales for each airfoil curve.
|
||||
|
||||
.. literalinclude:: spitfire_wing_gordon.py
|
||||
:start-after: [AirfoilSizes]
|
||||
:end-before: [Airfoils]
|
||||
|
||||
Step 3 — Build airfoil profiles (root and tip)
|
||||
==============================================
|
||||
|
||||
We place two different NACA airfoils on :data:`Plane.YZ`—with the airfoil origins
|
||||
shifted so the leading edge fraction is aligned—then scale to the chord lengths
|
||||
from Step 2.
|
||||
|
||||
.. literalinclude:: spitfire_wing_gordon.py
|
||||
:start-after: [Airfoils]
|
||||
:end-before: [Profiles]
|
||||
|
||||
|
||||
Step 4 — Gordon surface construction
|
||||
====================================
|
||||
|
||||
A Gordon surface needs *profiles* and *guides*. Here the airfoil edges are the
|
||||
profiles; the elliptic edges are the guides. We also add the wing tip section
|
||||
so the profile grid closes at the tip.
|
||||
|
||||
.. literalinclude:: spitfire_wing_gordon.py
|
||||
:start-after: [Profiles]
|
||||
:end-before: [Solid]
|
||||
|
||||
.. image:: ./assets/surface_modeling/spitfire_wing_profiles_guides.svg
|
||||
:align: center
|
||||
:alt: Elliptic leading/trailing guides
|
||||
|
||||
|
||||
Step 5 — Cap the root and create the solid
|
||||
==========================================
|
||||
|
||||
We extract the closed root edge loop, make a planar cap, and form a solid shell.
|
||||
|
||||
.. literalinclude:: spitfire_wing_gordon.py
|
||||
:start-after: [Solid]
|
||||
:end-before: [End]
|
||||
|
||||
.. image:: ./assets/surface_modeling/spitfire_wing.png
|
||||
:align: center
|
||||
:alt: Final wing solid
|
||||
|
||||
Tips for robust Gordon surfaces
|
||||
-------------------------------
|
||||
|
||||
- Ensure each profile intersects each guide once and only once
|
||||
- Keep the curve network coherent (no duplicated or missing intersections)
|
||||
- When possible, reuse the same :class:`~topology.Edge` objects across adjacent faces
|
||||
|
||||
Complete listing
|
||||
================
|
||||
|
||||
For convenience, here is the full script in one block:
|
||||
|
||||
.. literalinclude:: spitfire_wing_gordon.py
|
||||
:start-after: [Code]
|
||||
:end-before: [End]
|
||||
125
docs/tutorial_surface_heart_token.rst
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
##################################
|
||||
Tutorial: Heart Token (Basics)
|
||||
##################################
|
||||
|
||||
This hands‑on tutorial introduces the fundamentals of surface modeling by building
|
||||
a heart‑shaped token from a small set of non‑planar faces. We’ll create
|
||||
non‑planar surfaces, mirror them, add side faces, and assemble a closed shell
|
||||
into a solid.
|
||||
|
||||
As described in the `topology_` section, a BREP model consists of vertices, edges, faces,
|
||||
and other elements that define the boundary of an object. When creating objects with
|
||||
non-planar faces, it is often more convenient to explicitly create the boundary faces of
|
||||
the object. To illustrate this process, we will create the following game token:
|
||||
|
||||
.. raw:: html
|
||||
|
||||
<script type="module" src="https://unpkg.com/@google/model-viewer/dist/model-viewer.min.js"></script>
|
||||
<model-viewer poster="_images/heart_token.png" src="_static/heart_token.glb" alt="Game Token" auto-rotate camera-controls style="width: 100%; height: 50vh;"></model-viewer>
|
||||
|
||||
Useful :class:`~topology.Face` creation methods include
|
||||
:meth:`~topology.Face.make_surface`, :meth:`~topology.Face.make_bezier_surface`,
|
||||
and :meth:`~topology.Face.make_surface_from_array_of_points`. See the
|
||||
:doc:`surface_modeling` overview for the full list.
|
||||
|
||||
In this case, we'll use the ``make_surface`` method, providing it with the edges that define
|
||||
the perimeter of the surface and a central point on that surface.
|
||||
|
||||
To create the perimeter, we'll define the perimeter edges. Since the heart is
|
||||
symmetric, we'll only create half of its surface here:
|
||||
|
||||
.. literalinclude:: heart_token.py
|
||||
:start-after: [Code]
|
||||
:end-before: [SurfaceEdges]
|
||||
|
||||
Note that ``l4`` is not in the same plane as the other lines; it defines the center line
|
||||
of the heart and archs up off ``Plane.XY``.
|
||||
|
||||
.. image:: ./assets/surface_modeling/token_heart_perimeter.png
|
||||
:align: center
|
||||
:alt: token perimeter
|
||||
|
||||
In preparation for creating the surface, we'll define a point on the surface:
|
||||
|
||||
.. literalinclude:: heart_token.py
|
||||
:start-after: [SurfaceEdges]
|
||||
:end-before: [SurfacePoint]
|
||||
|
||||
We will then use this point to create a non-planar ``Face``:
|
||||
|
||||
.. literalinclude:: heart_token.py
|
||||
:start-after: [SurfacePoint]
|
||||
:end-before: [Surface]
|
||||
|
||||
.. image:: ./assets/surface_modeling/token_half_surface.png
|
||||
:align: center
|
||||
:alt: token perimeter
|
||||
|
||||
Note that the surface was raised up by 0.5 using an Algebra expression with Pos. Also,
|
||||
note that the ``-`` in front of ``Face`` simply flips the face normal so that the colored
|
||||
side is up, which isn't necessary but helps with viewing.
|
||||
|
||||
Now that one half of the top of the heart has been created, the remainder of the top
|
||||
and bottom can be created by mirroring:
|
||||
|
||||
.. literalinclude:: heart_token.py
|
||||
:start-after: [Surface]
|
||||
:end-before: [Surfaces]
|
||||
|
||||
The sides of the heart are going to be created by extruding the outside of the perimeter
|
||||
as follows:
|
||||
|
||||
.. literalinclude:: heart_token.py
|
||||
:start-after: [Surfaces]
|
||||
:end-before: [Sides]
|
||||
|
||||
.. image:: ./assets/surface_modeling/token_sides.png
|
||||
:align: center
|
||||
:alt: token sides
|
||||
|
||||
With the top, bottom, and sides, the complete boundary of the object is defined. We can
|
||||
now put them together, first into a :class:`~topology.Shell` and then into a
|
||||
:class:`~topology.Solid`:
|
||||
|
||||
.. literalinclude:: heart_token.py
|
||||
:start-after: [Sides]
|
||||
:end-before: [Solid]
|
||||
|
||||
.. image:: ./assets/surface_modeling/token_heart_solid.png
|
||||
:align: center
|
||||
:alt: token heart solid
|
||||
|
||||
.. note::
|
||||
When creating a Solid from a Shell, the Shell must be "water-tight," meaning it
|
||||
should have no holes. For objects with complex Edges, it's best practice to reuse
|
||||
Edges in adjoining Faces whenever possible to avoid slight mismatches that can
|
||||
create openings.
|
||||
|
||||
Finally, we'll create the frame around the heart as a simple extrusion of a planar
|
||||
shape defined by the perimeter of the heart and merge all of the components together:
|
||||
|
||||
.. literalinclude:: heart_token.py
|
||||
:start-after: [Solid]
|
||||
:end-before: [End]
|
||||
|
||||
Note that an additional planar line is used to close ``l1`` and ``l3`` so a ``Face``
|
||||
can be created. The :func:`~operations_generic.offset` function defines the outside of
|
||||
the frame as a constant distance from the heart itself.
|
||||
|
||||
Summary
|
||||
-------
|
||||
|
||||
In this tutorial, we've explored surface modeling techniques to create a non-planar
|
||||
heart-shaped object using build123d. By utilizing methods from the :class:`~topology.Face`
|
||||
class, such as :meth:`~topology.Face.make_surface`, we constructed the perimeter and
|
||||
central point of the surface. We then assembled the complete boundary of the object
|
||||
by creating the top, bottom, and sides, and combined them into a :class:`~topology.Shell`
|
||||
and eventually a :class:`~topology.Solid`. Finally, we added a frame around the heart
|
||||
using the :func:`~operations_generic.offset` function to maintain a constant distance
|
||||
from the heart.
|
||||
|
||||
Next steps
|
||||
----------
|
||||
|
||||
Continue to :doc:`tutorial_heart_token` for an advanced example using
|
||||
:meth:`~topology.Face.make_gordon_surface` to create a Supermarine Spitfire wing.
|
||||
|
|
@ -1,156 +1,55 @@
|
|||
################
|
||||
#################
|
||||
Surface Modeling
|
||||
################
|
||||
#################
|
||||
|
||||
Surface modeling is employed to create objects with non-planar surfaces that can't be
|
||||
generated using functions like :func:`~operations_part.extrude`,
|
||||
:func:`~operations_generic.sweep`, or :func:`~operations_part.revolve`. Since there are no
|
||||
specific builders designed to assist with the creation of non-planar surfaces or objects,
|
||||
the following should be considered a more advanced technique.
|
||||
|
||||
As described in the `topology_` section, a BREP model consists of vertices, edges, faces,
|
||||
and other elements that define the boundary of an object. When creating objects with
|
||||
non-planar faces, it is often more convenient to explicitly create the boundary faces of
|
||||
the object. To illustrate this process, we will create the following game token:
|
||||
Surface modeling refers to the direct creation and manipulation of the skin of a 3D
|
||||
object—its bounding faces—rather than starting from volumetric primitives or solid
|
||||
operations.
|
||||
|
||||
.. raw:: html
|
||||
Instead of defining a shape by extruding or revolving a 2D profile to fill a volume,
|
||||
surface modeling focuses on building the individual curved or planar faces that together
|
||||
define the outer boundary of a part. This approach allows for precise control of complex
|
||||
freeform geometry such as aerodynamic surfaces, boat hulls, or organic transitions that
|
||||
cannot easily be expressed with simple parametric solids.
|
||||
|
||||
<script type="module" src="https://unpkg.com/@google/model-viewer/dist/model-viewer.min.js"></script>
|
||||
<model-viewer poster="_images/heart_token.png" src="_static/heart_token.glb" alt="Game Token" auto-rotate camera-controls style="width: 100%; height: 50vh;"></model-viewer>
|
||||
In build123d, as in other CAD kernels based on BREP (Boundary Representation) modeling,
|
||||
all solids are ultimately defined by their boundaries: a hierarchy of faces, edges, and
|
||||
vertices. Each face represents a finite patch of a geometric surface (plane, cylinder,
|
||||
Bézier patch, etc.) bounded by one or more edge loops or wires. When adjacent faces share
|
||||
edges consistently and close into a continuous boundary, they form a manifold
|
||||
:class:`~topology.Shell`—the watertight surface of a volume. If this shell is properly
|
||||
oriented and encloses a finite region of space, the model becomes a solid.
|
||||
|
||||
There are several methods of the :class:`~topology.Face` class that can be used to create
|
||||
non-planar surfaces:
|
||||
Surface modeling therefore operates at the most fundamental level of BREP construction.
|
||||
Rather than relying on higher-level modeling operations to implicitly generate faces,
|
||||
it allows you to construct and connect those faces explicitly. This provides a path to
|
||||
build geometry that blends analytical and freeform shapes seamlessly, with full control
|
||||
over continuity, tangency, and curvature across boundaries.
|
||||
|
||||
* :meth:`~topology.Face.make_bezier_surface`,
|
||||
* :meth:`~topology.Face.make_surface`, and
|
||||
* :meth:`~topology.Face.make_surface_from_array_of_points`.
|
||||
This section provides:
|
||||
- A concise overview of surface‑building tools in build123d
|
||||
- Hands‑on tutorials, from fundamentals to advanced techniques like Gordon surfaces
|
||||
|
||||
In this case, we'll use the ``make_surface`` method, providing it with the edges that define
|
||||
the perimeter of the surface and a central point on that surface.
|
||||
.. rubric:: Available surface methods
|
||||
|
||||
To create the perimeter, we'll use a ``BuildLine`` instance as follows. Since the heart is
|
||||
symmetric, we'll only create half of its surface here:
|
||||
Methods on :class:`~topology.Face` for creating non‑planar surfaces:
|
||||
|
||||
.. code-block:: build123d
|
||||
|
||||
with BuildLine() as heart_half:
|
||||
l1 = JernArc((0, 0), (1, 1.4), 40, -17)
|
||||
l2 = JernArc(l1 @ 1, l1 % 1, 4.5, 175)
|
||||
l3 = IntersectingLine(l2 @ 1, l2 % 1, other=Edge.make_line((0, 0), (0, 20)))
|
||||
l4 = ThreePointArc(l3 @ 1, Vector(0, 0, 1.5) + (l3 @ 1 + l1 @ 0) / 2, l1 @ 0)
|
||||
|
||||
Note that ``l4`` is not in the same plane as the other lines; it defines the center line
|
||||
of the heart and archs up off ``Plane.XY``.
|
||||
|
||||
.. image:: ./assets/token_heart_perimeter.png
|
||||
:align: center
|
||||
:alt: token perimeter
|
||||
|
||||
In preparation for creating the surface, we'll define a point on the surface:
|
||||
|
||||
.. code-block:: build123d
|
||||
|
||||
surface_pnt = l2.edge().arc_center + Vector(0, 0, 1.5)
|
||||
|
||||
We will then use this point to create a non-planar ``Face``:
|
||||
|
||||
.. code-block:: build123d
|
||||
|
||||
top_right_surface = -Face.make_surface(heart_half.wire(), [surface_pnt]).locate(
|
||||
Pos(Z=0.5)
|
||||
)
|
||||
|
||||
.. image:: ./assets/token_half_surface.png
|
||||
:align: center
|
||||
:alt: token perimeter
|
||||
|
||||
Note that the surface was raised up by 0.5 using the locate method. Also, note that
|
||||
the ``-`` in front of ``Face`` simply flips the face normal so that the colored side
|
||||
is up, which isn't necessary but helps with viewing.
|
||||
|
||||
Now that one half of the top of the heart has been created, the remainder of the top
|
||||
and bottom can be created by mirroring:
|
||||
|
||||
.. code-block:: build123d
|
||||
|
||||
top_left_surface = top_right_surface.mirror(Plane.YZ)
|
||||
bottom_right_surface = top_right_surface.mirror(Plane.XY)
|
||||
bottom_left_surface = -top_left_surface.mirror(Plane.XY)
|
||||
|
||||
The sides of the heart are going to be created by extruding the outside of the perimeter
|
||||
as follows:
|
||||
|
||||
.. code-block:: build123d
|
||||
|
||||
left_wire = Wire([l3.edge(), l2.edge(), l1.edge()])
|
||||
left_side = Face.extrude(left_wire, (0, 0, 1)).locate(Pos(Z=-0.5))
|
||||
right_side = left_side.mirror(Plane.YZ)
|
||||
|
||||
.. image:: ./assets/token_sides.png
|
||||
:align: center
|
||||
:alt: token sides
|
||||
|
||||
With the top, bottom, and sides, the complete boundary of the object is defined. We can
|
||||
now put them together, first into a :class:`~topology.Shell` and then into a
|
||||
:class:`~topology.Solid`:
|
||||
|
||||
.. code-block:: build123d
|
||||
|
||||
heart = Solid(
|
||||
Shell(
|
||||
[
|
||||
top_right_surface,
|
||||
top_left_surface,
|
||||
bottom_right_surface,
|
||||
bottom_left_surface,
|
||||
left_side,
|
||||
right_side,
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
.. image:: ./assets/token_heart_solid.png
|
||||
:align: center
|
||||
:alt: token heart solid
|
||||
* :meth:`~topology.Face.make_bezier_surface`
|
||||
* :meth:`~topology.Face.make_gordon_surface`
|
||||
* :meth:`~topology.Face.make_surface`
|
||||
* :meth:`~topology.Face.make_surface_from_array_of_points`
|
||||
* :meth:`~topology.Face.make_surface_from_curves`
|
||||
* :meth:`~topology.Face.make_surface_patch`
|
||||
|
||||
.. note::
|
||||
When creating a Solid from a Shell, the Shell must be "water-tight," meaning it
|
||||
should have no holes. For objects with complex Edges, it's best practice to reuse
|
||||
Edges in adjoining Faces whenever possible to avoid slight mismatches that can
|
||||
create openings.
|
||||
Surface modeling is an advanced technique. Robust results usually come from
|
||||
reusing the same :class:`~topology.Edge` objects across adjacent faces and
|
||||
ensuring the final :class:`~topology.Shell` is *water‑tight* or *manifold* (no gaps).
|
||||
|
||||
Finally, we'll create the frame around the heart as a simple extrusion of a planar
|
||||
shape defined by the perimeter of the heart and merge all of the components together:
|
||||
.. toctree::
|
||||
:maxdepth: 1
|
||||
|
||||
.. code-block:: build123d
|
||||
tutorial_surface_heart_token.rst
|
||||
tutorial_spitfire_wing_gordon.rst
|
||||
|
||||
with BuildPart() as heart_token:
|
||||
with BuildSketch() as outline:
|
||||
with BuildLine():
|
||||
add(l1)
|
||||
add(l2)
|
||||
add(l3)
|
||||
Line(l3 @ 1, l1 @ 0)
|
||||
make_face()
|
||||
mirror(about=Plane.YZ)
|
||||
center = outline.sketch
|
||||
offset(amount=2, kind=Kind.INTERSECTION)
|
||||
add(center, mode=Mode.SUBTRACT)
|
||||
extrude(amount=2, both=True)
|
||||
add(heart)
|
||||
|
||||
Note that an additional planar line is used to close ``l1`` and ``l3`` so a ``Face``
|
||||
can be created. The :func:`~operations_generic.offset` function defines the outside of
|
||||
the frame as a constant distance from the heart itself.
|
||||
|
||||
Summary
|
||||
-------
|
||||
|
||||
In this tutorial, we've explored surface modeling techniques to create a non-planar
|
||||
heart-shaped object using build123d. By utilizing methods from the :class:`~topology.Face`
|
||||
class, such as :meth:`~topology.Face.make_surface`, we constructed the perimeter and
|
||||
central point of the surface. We then assembled the complete boundary of the object
|
||||
by creating the top, bottom, and sides, and combined them into a :class:`~topology.Shell`
|
||||
and eventually a :class:`~topology.Solid`. Finally, we added a frame around the heart
|
||||
using the :func:`~operations_generic.offset` function to maintain a constant distance
|
||||
from the heart.
|
||||
|
|
@ -24,7 +24,7 @@ keywords = [
|
|||
"brep",
|
||||
"cad",
|
||||
"cadquery",
|
||||
"opencscade",
|
||||
"opencascade",
|
||||
"python",
|
||||
]
|
||||
license = {text = "Apache-2.0"}
|
||||
|
|
@ -44,6 +44,7 @@ dependencies = [
|
|||
"ipython >= 8.0.0, < 10",
|
||||
"lib3mf >= 2.4.1",
|
||||
"ocpsvg >= 0.5, < 0.6",
|
||||
"ocp_gordon >= 0.1.17",
|
||||
"trianglesolver",
|
||||
"sympy",
|
||||
"scipy",
|
||||
|
|
|
|||
|
|
@ -55,11 +55,13 @@ __all__ = [
|
|||
"Intrinsic",
|
||||
"Keep",
|
||||
"Kind",
|
||||
"Sagitta",
|
||||
"LengthMode",
|
||||
"MeshType",
|
||||
"Mode",
|
||||
"NumberDisplay",
|
||||
"PageSize",
|
||||
"Tangency",
|
||||
"PositionMode",
|
||||
"PrecisionMode",
|
||||
"Select",
|
||||
|
|
@ -79,6 +81,7 @@ __all__ = [
|
|||
"BuildSketch",
|
||||
# 1D Curve Objects
|
||||
"BaseLineObject",
|
||||
"Airfoil",
|
||||
"Bezier",
|
||||
"BlendCurve",
|
||||
"CenterArc",
|
||||
|
|
|
|||
|
|
@ -29,9 +29,15 @@ license:
|
|||
from __future__ import annotations
|
||||
|
||||
from enum import Enum, auto, IntEnum, unique
|
||||
from typing import Union
|
||||
from typing import TypeAlias, Union
|
||||
|
||||
from typing import TypeAlias
|
||||
from OCP.GccEnt import (
|
||||
GccEnt_unqualified,
|
||||
GccEnt_enclosing,
|
||||
GccEnt_enclosed,
|
||||
GccEnt_outside,
|
||||
GccEnt_noqualifier,
|
||||
)
|
||||
|
||||
|
||||
class Align(Enum):
|
||||
|
|
@ -248,6 +254,17 @@ class FontStyle(Enum):
|
|||
return f"<{self.__class__.__name__}.{self.name}>"
|
||||
|
||||
|
||||
class Sagitta(Enum):
|
||||
"""Sagitta selection"""
|
||||
|
||||
SHORT = 0
|
||||
LONG = -1
|
||||
BOTH = 1
|
||||
|
||||
def __repr__(self):
|
||||
return f"<{self.__class__.__name__}.{self.name}>"
|
||||
|
||||
|
||||
class LengthMode(Enum):
|
||||
"""Method of specifying length along PolarLine"""
|
||||
|
||||
|
|
@ -303,6 +320,18 @@ class PageSize(Enum):
|
|||
return f"<{self.__class__.__name__}.{self.name}>"
|
||||
|
||||
|
||||
class Tangency(Enum):
|
||||
"""Tangency constraint for solvers edge selection"""
|
||||
|
||||
UNQUALIFIED = GccEnt_unqualified
|
||||
ENCLOSING = GccEnt_enclosing
|
||||
ENCLOSED = GccEnt_enclosed
|
||||
OUTSIDE = GccEnt_outside
|
||||
|
||||
def __repr__(self):
|
||||
return f"<{self.__class__.__name__}.{self.name}>"
|
||||
|
||||
|
||||
class PositionMode(Enum):
|
||||
"""Position along curve mode"""
|
||||
|
||||
|
|
|
|||
|
|
@ -52,7 +52,7 @@ from build123d.objects_curve import Line, TangentArc
|
|||
from build123d.objects_sketch import BaseSketchObject, Polygon, Text
|
||||
from build123d.operations_generic import fillet, mirror, sweep
|
||||
from build123d.operations_sketch import make_face, trace
|
||||
from build123d.topology import Compound, Curve, Edge, Sketch, Vertex, Wire
|
||||
from build123d.topology import Compound, Curve, Edge, ShapeList, Sketch, Vertex, Wire
|
||||
|
||||
|
||||
class ArrowHead(BaseSketchObject):
|
||||
|
|
@ -709,7 +709,7 @@ class TechnicalDrawing(BaseSketchObject):
|
|||
# Text Box Frame
|
||||
bf_pnt1 = frame_wire.edges().sort_by(Axis.Y)[0] @ 0.5
|
||||
bf_pnt2 = frame_wire.edges().sort_by(Axis.X)[-1] @ 0.75
|
||||
box_frame_curve = Wire.make_polygon(
|
||||
box_frame_curve: Edge | Wire | ShapeList[Edge] = Wire.make_polygon(
|
||||
[bf_pnt1, (bf_pnt1.X, bf_pnt2.Y), bf_pnt2], close=False
|
||||
)
|
||||
bf_pnt3 = box_frame_curve.edges().sort_by(Axis.X)[0] @ (1 / 3)
|
||||
|
|
|
|||
|
|
@ -527,18 +527,22 @@ class Vector:
|
|||
|
||||
@overload
|
||||
def intersect(self, location: Location) -> Vector | None:
|
||||
"""Find intersection of location and vector"""
|
||||
"""Find intersection of vector and location"""
|
||||
|
||||
@overload
|
||||
def intersect(self, axis: Axis) -> Vector | None:
|
||||
"""Find intersection of axis and vector"""
|
||||
"""Find intersection of vector and axis"""
|
||||
|
||||
@overload
|
||||
def intersect(self, plane: Plane) -> Vector | None:
|
||||
"""Find intersection of plane and vector"""
|
||||
"""Find intersection of vector and plane"""
|
||||
|
||||
@overload
|
||||
def intersect(self, shape: Shape) -> Shape | None:
|
||||
"""Find intersection of vector and shape"""
|
||||
|
||||
def intersect(self, *args, **kwargs):
|
||||
"""Find intersection of geometric objects and vector"""
|
||||
"""Find intersection of vector and geometric object or shape"""
|
||||
axis, plane, vector, location, shape = _parse_intersect_args(*args, **kwargs)
|
||||
|
||||
if axis is not None:
|
||||
|
|
@ -906,22 +910,26 @@ class Axis(metaclass=AxisMeta):
|
|||
|
||||
@overload
|
||||
def intersect(self, vector: VectorLike) -> Vector | None:
|
||||
"""Find intersection of vector and axis"""
|
||||
"""Find intersection of axis and vector"""
|
||||
|
||||
@overload
|
||||
def intersect(self, location: Location) -> Location | None:
|
||||
"""Find intersection of location and axis"""
|
||||
def intersect(self, location: Location) -> Vector | Location | None:
|
||||
"""Find intersection of axis and location"""
|
||||
|
||||
@overload
|
||||
def intersect(self, axis: Axis) -> Axis | None:
|
||||
def intersect(self, axis: Axis) -> Vector | Axis | None:
|
||||
"""Find intersection of axis and axis"""
|
||||
|
||||
@overload
|
||||
def intersect(self, plane: Plane) -> Axis | None:
|
||||
"""Find intersection of plane and axis"""
|
||||
def intersect(self, plane: Plane) -> Vector | Axis | None:
|
||||
"""Find intersection of axis and plane"""
|
||||
|
||||
@overload
|
||||
def intersect(self, shape: Shape) -> Shape | None:
|
||||
"""Find intersection of axis and shape"""
|
||||
|
||||
def intersect(self, *args, **kwargs):
|
||||
"""Find intersection of geometric object and axis"""
|
||||
"""Find intersection of axis and geometric object or shape"""
|
||||
axis, plane, vector, location, shape = _parse_intersect_args(*args, **kwargs)
|
||||
|
||||
if axis is not None:
|
||||
|
|
@ -965,12 +973,12 @@ class Axis(metaclass=AxisMeta):
|
|||
# Find the "direction" of the location
|
||||
location_dir = Plane(location).z_dir
|
||||
|
||||
if self.intersect(location.position) is not None:
|
||||
# Is the location on the axis with the same direction?
|
||||
if (
|
||||
self.intersect(location.position) is not None
|
||||
and location_dir == self.direction
|
||||
):
|
||||
if location_dir == self.direction:
|
||||
return location
|
||||
else:
|
||||
return location.position
|
||||
|
||||
if shape is not None:
|
||||
return shape.intersect(self)
|
||||
|
|
@ -1929,22 +1937,26 @@ class Location:
|
|||
|
||||
@overload
|
||||
def intersect(self, vector: VectorLike) -> Vector | None:
|
||||
"""Find intersection of vector and location"""
|
||||
"""Find intersection of location and vector"""
|
||||
|
||||
@overload
|
||||
def intersect(self, location: Location) -> Location | None:
|
||||
def intersect(self, location: Location) -> Vector | Location | None:
|
||||
"""Find intersection of location and location"""
|
||||
|
||||
@overload
|
||||
def intersect(self, axis: Axis) -> Location | None:
|
||||
"""Find intersection of axis and location"""
|
||||
def intersect(self, axis: Axis) -> Vector | Location | None:
|
||||
"""Find intersection of location and axis"""
|
||||
|
||||
@overload
|
||||
def intersect(self, plane: Plane) -> Location | None:
|
||||
"""Find intersection of plane and location"""
|
||||
def intersect(self, plane: Plane) -> Vector | Location | None:
|
||||
"""Find intersection of location and plane"""
|
||||
|
||||
@overload
|
||||
def intersect(self, shape: Shape) -> Shape | None:
|
||||
"""Find intersection of location and shape"""
|
||||
|
||||
def intersect(self, *args, **kwargs):
|
||||
"""Find intersection of geometric object and location"""
|
||||
"""Find intersection of location and geometric object or shape"""
|
||||
axis, plane, vector, location, shape = _parse_intersect_args(*args, **kwargs)
|
||||
|
||||
if axis is not None:
|
||||
|
|
@ -1956,8 +1968,11 @@ class Location:
|
|||
if vector is not None and self.position == vector:
|
||||
return vector
|
||||
|
||||
if location is not None and self == location:
|
||||
if location is not None:
|
||||
if self == location:
|
||||
return self
|
||||
elif self.position == location.position:
|
||||
return self.position
|
||||
|
||||
if shape is not None:
|
||||
return shape.intersect(self)
|
||||
|
|
@ -3128,18 +3143,18 @@ class Plane(metaclass=PlaneMeta):
|
|||
|
||||
@overload
|
||||
def intersect(self, vector: VectorLike) -> Vector | None:
|
||||
"""Find intersection of vector and plane"""
|
||||
"""Find intersection of plane and vector"""
|
||||
|
||||
@overload
|
||||
def intersect(self, location: Location) -> Location | None:
|
||||
"""Find intersection of location and plane"""
|
||||
def intersect(self, location: Location) -> Vector | Location | None:
|
||||
"""Find intersection of plane and location"""
|
||||
|
||||
@overload
|
||||
def intersect(self, axis: Axis) -> Axis | Vector | None:
|
||||
"""Find intersection of axis and plane"""
|
||||
def intersect(self, axis: Axis) -> Vector | Axis | None:
|
||||
"""Find intersection of plane and axis"""
|
||||
|
||||
@overload
|
||||
def intersect(self, plane: Plane) -> Axis | None:
|
||||
def intersect(self, plane: Plane) -> Axis | Plane | None:
|
||||
"""Find intersection of plane and plane"""
|
||||
|
||||
@overload
|
||||
|
|
@ -3147,7 +3162,7 @@ class Plane(metaclass=PlaneMeta):
|
|||
"""Find intersection of plane and shape"""
|
||||
|
||||
def intersect(self, *args, **kwargs):
|
||||
"""Find intersection of geometric object and shape"""
|
||||
"""Find intersection of plane and geometric object or shape"""
|
||||
|
||||
axis, plane, vector, location, shape = _parse_intersect_args(*args, **kwargs)
|
||||
|
||||
|
|
@ -3172,6 +3187,9 @@ class Plane(metaclass=PlaneMeta):
|
|||
return intersection_point
|
||||
|
||||
if plane is not None:
|
||||
if self.contains(plane.origin) and self.z_dir == plane.z_dir:
|
||||
return self
|
||||
|
||||
surface1 = Geom_Plane(self.wrapped)
|
||||
surface2 = Geom_Plane(plane.wrapped)
|
||||
intersector = GeomAPI_IntSS(surface1, surface2, TOLERANCE)
|
||||
|
|
@ -3187,8 +3205,11 @@ class Plane(metaclass=PlaneMeta):
|
|||
|
||||
if location is not None:
|
||||
pln = Plane(location)
|
||||
if pln.origin == self.origin and pln.z_dir == self.z_dir:
|
||||
if self.contains(pln.origin):
|
||||
if self.z_dir == pln.z_dir:
|
||||
return location
|
||||
else:
|
||||
return pln.origin
|
||||
|
||||
if shape is not None:
|
||||
return shape.intersect(self)
|
||||
|
|
|
|||
|
|
@ -38,8 +38,10 @@ from pathlib import Path
|
|||
from typing import Literal, Optional, TextIO, Union
|
||||
import warnings
|
||||
|
||||
from OCP.Bnd import Bnd_Box
|
||||
from OCP.BRep import BRep_Builder
|
||||
from OCP.BRepGProp import BRepGProp
|
||||
from OCP.BRepBndLib import BRepBndLib
|
||||
from OCP.BRepGProp import BRepGProp, BRepGProp_Face
|
||||
from OCP.BRepTools import BRepTools
|
||||
from OCP.GProp import GProp_GProps
|
||||
from OCP.Quantity import Quantity_ColorRGBA
|
||||
|
|
@ -145,38 +147,43 @@ def import_step(filename: PathLike | str | bytes) -> Compound:
|
|||
clean_name = "".join(ch for ch in name if unicodedata.category(ch)[0] != "C")
|
||||
return clean_name.translate(str.maketrans(" .()", "____"))
|
||||
|
||||
def get_color(shape: TopoDS_Shape) -> Quantity_ColorRGBA:
|
||||
"""Get the color - take that of the largest Face if multiple"""
|
||||
def get_shape_color_from_cache(obj: TopoDS_Shape) -> Quantity_ColorRGBA | None:
|
||||
"""Get the color of a shape from a cache"""
|
||||
key = obj.TShape().__hash__()
|
||||
if key in _color_cache:
|
||||
return _color_cache[key]
|
||||
|
||||
def get_col(obj: TopoDS_Shape) -> Quantity_ColorRGBA:
|
||||
col = Quantity_ColorRGBA()
|
||||
if (
|
||||
has_color = (
|
||||
color_tool.GetColor(obj, XCAFDoc_ColorCurv, col)
|
||||
or color_tool.GetColor(obj, XCAFDoc_ColorGen, col)
|
||||
or color_tool.GetColor(obj, XCAFDoc_ColorSurf, col)
|
||||
):
|
||||
return col
|
||||
|
||||
shape_color = get_col(shape)
|
||||
|
||||
colors = {}
|
||||
face_explorer = TopExp_Explorer(shape, TopAbs_FACE)
|
||||
while face_explorer.More():
|
||||
current_face = face_explorer.Current()
|
||||
properties = GProp_GProps()
|
||||
BRepGProp.SurfaceProperties_s(current_face, properties)
|
||||
area = properties.Mass()
|
||||
color = get_col(current_face)
|
||||
if color is not None:
|
||||
colors[area] = color
|
||||
face_explorer.Next()
|
||||
|
||||
# If there are multiple colors, return the one from the largest face
|
||||
if colors:
|
||||
shape_color = sorted(colors.items())[-1][1]
|
||||
)
|
||||
_color_cache[key] = col if has_color else None
|
||||
return _color_cache[key]
|
||||
|
||||
def get_color(shape: TopoDS_Shape) -> Quantity_ColorRGBA | None:
|
||||
"""Get the color - take that of the largest Face if multiple"""
|
||||
shape_color = get_shape_color_from_cache(shape)
|
||||
if shape_color is not None:
|
||||
return shape_color
|
||||
|
||||
max_extent = -1.0
|
||||
winner = None
|
||||
exp = TopExp_Explorer(shape, TopAbs_FACE)
|
||||
while exp.More():
|
||||
face = exp.Current()
|
||||
col = get_shape_color_from_cache(face)
|
||||
if col is not None:
|
||||
box = Bnd_Box()
|
||||
BRepBndLib.Add_s(face, box)
|
||||
extent = box.SquareExtent()
|
||||
if extent > max_extent:
|
||||
max_extent = extent
|
||||
winner = col
|
||||
exp.Next()
|
||||
return winner
|
||||
|
||||
def build_assembly(parent_tdf_label: TDF_Label | None = None) -> list[Shape]:
|
||||
"""Recursively extract object into an assembly"""
|
||||
sub_tdf_labels = TDF_LabelSequence()
|
||||
|
|
@ -211,6 +218,9 @@ def import_step(filename: PathLike | str | bytes) -> Compound:
|
|||
if not os.path.exists(filename):
|
||||
raise FileNotFoundError(filename)
|
||||
|
||||
# Retrieving color info is expensive so cache the lookups
|
||||
_color_cache: dict[int, Quantity_ColorRGBA | None] = {}
|
||||
|
||||
fmt = TCollection_ExtendedString("XCAF")
|
||||
doc = TDocStd_Document(fmt)
|
||||
shape_tool = XCAFDoc_DocumentTool.ShapeTool_s(doc.Main())
|
||||
|
|
|
|||
|
|
@ -29,11 +29,13 @@ license:
|
|||
from __future__ import annotations
|
||||
|
||||
import copy as copy_module
|
||||
import numpy as np
|
||||
import sympy # type: ignore
|
||||
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
|
||||
from typing import overload, Literal
|
||||
|
||||
from build123d.build_common import WorkplaneList, flatten_sequence, validate_inputs
|
||||
from build123d.build_enums import (
|
||||
|
|
@ -100,6 +102,129 @@ class BaseEdgeObject(Edge):
|
|||
super().__init__(curve.wrapped)
|
||||
|
||||
|
||||
class Airfoil(BaseLineObject):
|
||||
"""
|
||||
Create an airfoil described by a 4-digit (or fractional) NACA airfoil
|
||||
(e.g. '2412' or '2213.323').
|
||||
|
||||
The NACA four-digit wing sections define the airfoil_code by:
|
||||
- First digit describing maximum camber as percentage of the chord.
|
||||
- Second digit describing the distance of maximum camber from the airfoil leading edge
|
||||
in tenths of the chord.
|
||||
- Last two digits describing maximum thickness of the airfoil as percent of the chord.
|
||||
|
||||
Args:
|
||||
airfoil_code : str
|
||||
The NACA 4-digit (or fractional) airfoil code (e.g. '2213.323').
|
||||
n_points : int
|
||||
Number of points per upper/lower surface.
|
||||
finite_te : bool
|
||||
If True, enforces a finite trailing edge (default False).
|
||||
mode (Mode, optional): combination mode. Defaults to Mode.ADD
|
||||
|
||||
"""
|
||||
|
||||
_applies_to = [BuildLine._tag]
|
||||
|
||||
@staticmethod
|
||||
def parse_naca4(value: str | float) -> tuple[float, float, float]:
|
||||
"""
|
||||
Parse NACA 4-digit (or fractional) airfoil code into parameters.
|
||||
"""
|
||||
s = str(value).replace("NACA", "").strip()
|
||||
if "." in s:
|
||||
int_part, frac_part = s.split(".", 1)
|
||||
m = int(int_part[0]) / 100
|
||||
p = int(int_part[1]) / 10
|
||||
t = float(f"{int(int_part[2:]):02}.{frac_part}") / 100
|
||||
else:
|
||||
m = int(s[0]) / 100
|
||||
p = int(s[1]) / 10
|
||||
t = int(s[2:]) / 100
|
||||
return m, p, t
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
airfoil_code: str,
|
||||
n_points: int = 50,
|
||||
finite_te: bool = False,
|
||||
mode: Mode = Mode.ADD,
|
||||
):
|
||||
|
||||
# Airfoil thickness distribution equation:
|
||||
#
|
||||
# yₜ=5t[0.2969√x-0.1260x-0.3516x²+0.2843x³-0.1015x⁴]
|
||||
#
|
||||
# where:
|
||||
# - x is the distance along the chord (0 at the leading edge, 1 at the trailing edge),
|
||||
# - t is the maximum thickness as a fraction of the chord (e.g. 0.12 for a NACA 2412),
|
||||
# - yₜ gives the half-thickness at each chordwise location.
|
||||
|
||||
context: BuildLine | None = BuildLine._get_context(self)
|
||||
validate_inputs(context, self)
|
||||
|
||||
m, p, t = Airfoil.parse_naca4(airfoil_code)
|
||||
|
||||
# Cosine-spaced x values for better nose resolution
|
||||
beta = np.linspace(0.0, np.pi, n_points)
|
||||
x = (1 - np.cos(beta)) / 2
|
||||
|
||||
# Thickness distribution
|
||||
a0, a1, a2, a3 = 0.2969, -0.1260, -0.3516, 0.2843
|
||||
a4 = -0.1015 if finite_te else -0.1036
|
||||
yt = 5 * t * (a0 * np.sqrt(x) + a1 * x + a2 * x**2 + a3 * x**3 + a4 * x**4)
|
||||
|
||||
# Camber line and slope
|
||||
if m == 0 or p == 0 or p == 1:
|
||||
yc = np.zeros_like(x)
|
||||
dyc_dx = np.zeros_like(x)
|
||||
else:
|
||||
yc = np.empty_like(x)
|
||||
dyc_dx = np.empty_like(x)
|
||||
mask = x < p
|
||||
yc[mask] = m / p**2 * (2 * p * x[mask] - x[mask] ** 2)
|
||||
yc[~mask] = (
|
||||
m / (1 - p) ** 2 * ((1 - 2 * p) + 2 * p * x[~mask] - x[~mask] ** 2)
|
||||
)
|
||||
dyc_dx[mask] = 2 * m / p**2 * (p - x[mask])
|
||||
dyc_dx[~mask] = 2 * m / (1 - p) ** 2 * (p - x[~mask])
|
||||
|
||||
theta = np.arctan(dyc_dx)
|
||||
self._camber_points = [Vector(xi, yi) for xi, yi in zip(x, yc)]
|
||||
|
||||
# Upper and lower surfaces
|
||||
xu = x - yt * np.sin(theta)
|
||||
yu = yc + yt * np.cos(theta)
|
||||
xl = x + yt * np.sin(theta)
|
||||
yl = yc - yt * np.cos(theta)
|
||||
|
||||
upper_pnts = [Vector(x, y) for x, y in zip(xu, yu)]
|
||||
lower_pnts = [Vector(x, y) for x, y in zip(xl, yl)]
|
||||
unique_points: list[
|
||||
Vector | tuple[float, float] | tuple[float, float, float]
|
||||
] = list(dict.fromkeys(upper_pnts[::-1] + lower_pnts))
|
||||
surface = Edge.make_spline(unique_points, periodic=not finite_te) # type: ignore[arg-type]
|
||||
if finite_te:
|
||||
trailing_edge = Edge.make_line(surface @ 0, surface @ 1)
|
||||
airfoil_profile = Wire([surface, trailing_edge])
|
||||
else:
|
||||
airfoil_profile = Wire([surface])
|
||||
|
||||
super().__init__(airfoil_profile, mode=mode)
|
||||
|
||||
# Store metadata
|
||||
self.code: str = airfoil_code #: NACA code string (e.g. "2412")
|
||||
self.max_camber: float = m #: Maximum camber as fraction of chord
|
||||
self.camber_pos: float = p #: Chordwise position of max camber (0–1)
|
||||
self.thickness: float = t #: Maximum thickness as fraction of chord
|
||||
self.finite_te: bool = finite_te #: If True, trailing edge is finite
|
||||
|
||||
@property
|
||||
def camber_line(self) -> Edge:
|
||||
"""Camber line of the airfoil as an Edge."""
|
||||
return Edge.make_spline(self._camber_points) # type: ignore[arg-type]
|
||||
|
||||
|
||||
class Bezier(BaseEdgeObject):
|
||||
"""Line Object: Bezier Curve
|
||||
|
||||
|
|
|
|||
|
|
@ -448,7 +448,7 @@ class Compound(Mixin3D, Shape[TopoDS_Compound]):
|
|||
|
||||
# ---- Instance Methods ----
|
||||
|
||||
def __add__(self, other: None | Shape | Iterable[Shape]) -> Compound:
|
||||
def __add__(self, other: None | Shape | Iterable[Shape]) -> Compound | Wire:
|
||||
"""Combine other to self `+` operator
|
||||
|
||||
Note that if all of the objects are connected Edges/Wires the result
|
||||
|
|
@ -456,8 +456,15 @@ class Compound(Mixin3D, Shape[TopoDS_Compound]):
|
|||
"""
|
||||
if self._dim == 1:
|
||||
curve = Curve() if self.wrapped is None else Curve(self.wrapped)
|
||||
self.copy_attributes_to(curve, ["wrapped", "_NodeMixin__children"])
|
||||
return curve + other
|
||||
sum1d: Edge | Wire | ShapeList[Edge] = curve + other
|
||||
if isinstance(sum1d, ShapeList):
|
||||
result1d: Curve | Wire = Curve(sum1d)
|
||||
elif isinstance(sum1d, Edge):
|
||||
result1d = Curve([sum1d])
|
||||
else: # Wire
|
||||
result1d = sum1d
|
||||
self.copy_attributes_to(result1d, ["wrapped", "_NodeMixin__children"])
|
||||
return result1d
|
||||
|
||||
summands: ShapeList[Shape]
|
||||
if other is None:
|
||||
|
|
|
|||
822
src/build123d/topology/constrained_lines.py
Normal file
|
|
@ -0,0 +1,822 @@
|
|||
"""
|
||||
build123d topology
|
||||
|
||||
name: constrained_lines.py
|
||||
by: Gumyr
|
||||
date: September 07, 2025
|
||||
|
||||
desc:
|
||||
|
||||
This module generates lines and arcs that are constrained against other objects.
|
||||
|
||||
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 under the License is 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.
|
||||
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from math import atan2, cos, isnan, sin
|
||||
from typing import overload, TYPE_CHECKING, Callable, TypeVar
|
||||
from typing import cast as tcast
|
||||
|
||||
from OCP.BRep import BRep_Tool
|
||||
from OCP.BRepAdaptor import BRepAdaptor_Curve
|
||||
from OCP.BRepBuilderAPI import BRepBuilderAPI_MakeEdge, BRepBuilderAPI_MakeVertex
|
||||
from OCP.GCPnts import GCPnts_AbscissaPoint
|
||||
from OCP.Geom import Geom_Curve, Geom_Plane
|
||||
from OCP.Geom2d import (
|
||||
Geom2d_CartesianPoint,
|
||||
Geom2d_Circle,
|
||||
Geom2d_Curve,
|
||||
Geom2d_Line,
|
||||
Geom2d_Point,
|
||||
Geom2d_TrimmedCurve,
|
||||
)
|
||||
from OCP.Geom2dAdaptor import Geom2dAdaptor_Curve
|
||||
from OCP.Geom2dAPI import Geom2dAPI_ProjectPointOnCurve, Geom2dAPI_InterCurveCurve
|
||||
from OCP.Geom2dGcc import (
|
||||
Geom2dGcc_Circ2d2TanOn,
|
||||
Geom2dGcc_Circ2d2TanRad,
|
||||
Geom2dGcc_Circ2d3Tan,
|
||||
Geom2dGcc_Circ2dTanCen,
|
||||
Geom2dGcc_Circ2dTanOnRad,
|
||||
Geom2dGcc_Lin2dTanObl,
|
||||
Geom2dGcc_Lin2d2Tan,
|
||||
Geom2dGcc_QualifiedCurve,
|
||||
)
|
||||
from OCP.GeomAPI import GeomAPI
|
||||
from OCP.gp import (
|
||||
gp_Ax2d,
|
||||
gp_Ax3,
|
||||
gp_Circ2d,
|
||||
gp_Dir,
|
||||
gp_Dir2d,
|
||||
gp_Lin2d,
|
||||
gp_Pln,
|
||||
gp_Pnt,
|
||||
gp_Pnt2d,
|
||||
)
|
||||
from OCP.IntAna2d import IntAna2d_AnaIntersection
|
||||
from OCP.Standard import Standard_ConstructionError, Standard_Failure
|
||||
from OCP.TopoDS import TopoDS_Edge, TopoDS_Vertex
|
||||
|
||||
from build123d.build_enums import Sagitta, Tangency
|
||||
from build123d.geometry import Axis, TOLERANCE, Vector, VectorLike
|
||||
from .zero_d import Vertex
|
||||
from .shape_core import ShapeList
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from build123d.topology.one_d import Edge # pragma: no cover
|
||||
|
||||
TWrap = TypeVar("TWrap") # whatever the factory returns (Edge or a subclass)
|
||||
|
||||
# Reuse a single XY plane for 3D->2D projection and for 2D-edge building
|
||||
_pln_xy = gp_Pln(gp_Ax3(gp_Pnt(0.0, 0.0, 0.0), gp_Dir(0.0, 0.0, 1.0)))
|
||||
_surf_xy = Geom_Plane(_pln_xy)
|
||||
|
||||
|
||||
# ---------------------------
|
||||
# Normalization utilities
|
||||
# ---------------------------
|
||||
def _norm_on_period(u: float, first: float, period: float) -> float:
|
||||
"""Map parameter u into [first, first+per)."""
|
||||
return (u - first) % period + first
|
||||
|
||||
|
||||
def _forward_delta(u1: float, u2: float, first: float, period: float) -> float:
|
||||
"""
|
||||
Forward (positive) delta from u1 to u2 on a periodic domain anchored at
|
||||
'first'.
|
||||
"""
|
||||
u1n = _norm_on_period(u1, first, period)
|
||||
u2n = _norm_on_period(u2, first, period)
|
||||
delta = u2n - u1n
|
||||
if delta < 0.0:
|
||||
delta += period
|
||||
return delta
|
||||
|
||||
|
||||
# ---------------------------
|
||||
# Core helpers
|
||||
# ---------------------------
|
||||
def _edge_to_qualified_2d(
|
||||
edge: TopoDS_Edge, position_constaint: Tangency
|
||||
) -> tuple[Geom2dGcc_QualifiedCurve, Geom2d_Curve, float, float, Geom2dAdaptor_Curve]:
|
||||
"""Convert a TopoDS_Edge into 2d curve & extract properties"""
|
||||
|
||||
# 1) Underlying curve + range (also retrieve location to be safe)
|
||||
hcurve3d = BRep_Tool.Curve_s(edge, float(), float())
|
||||
first, last = BRep_Tool.Range_s(edge)
|
||||
|
||||
# 2) Convert to 2D on Plane.XY (Z-up frame at origin)
|
||||
hcurve2d = GeomAPI.To2d_s(hcurve3d, _pln_xy) # -> Handle_Geom2d_Curve
|
||||
|
||||
# 3) Wrap in an adaptor using the same parametric range
|
||||
adapt2d = Geom2dAdaptor_Curve(hcurve2d, first, last)
|
||||
|
||||
# 4) Create the qualified curve (unqualified is fine here)
|
||||
qcurve = Geom2dGcc_QualifiedCurve(adapt2d, position_constaint.value)
|
||||
return qcurve, hcurve2d, first, last, adapt2d
|
||||
|
||||
|
||||
def _edge_from_circle(h2d_circle: Geom2d_Circle, u1: float, u2: float) -> TopoDS_Edge:
|
||||
"""Build a 3D edge on XY from a trimmed 2D circle segment [u1, u2]."""
|
||||
arc2d = Geom2d_TrimmedCurve(h2d_circle, u1, u2, True) # sense=True
|
||||
return BRepBuilderAPI_MakeEdge(arc2d, _surf_xy).Edge()
|
||||
|
||||
|
||||
def _param_in_trim(
|
||||
u: float | None, first: float | None, last: float | None, h2d: Geom2d_Curve | None
|
||||
) -> bool:
|
||||
"""Normalize (if periodic) then test [first, last] with tolerance."""
|
||||
if u is None or first is None or last is None or h2d is None: # for typing
|
||||
raise TypeError("Invalid parameters to _param_in_trim")
|
||||
u = _norm_on_period(u, first, h2d.Period()) if h2d.IsPeriodic() else u
|
||||
return (u >= first - TOLERANCE) and (u <= last + TOLERANCE)
|
||||
|
||||
|
||||
@overload
|
||||
def _as_gcc_arg(
|
||||
obj: Edge, constaint: Tangency
|
||||
) -> tuple[
|
||||
Geom2dGcc_QualifiedCurve, Geom2d_Curve | None, float | None, float | None, bool
|
||||
]: ...
|
||||
@overload
|
||||
def _as_gcc_arg(
|
||||
obj: Vector, constaint: Tangency
|
||||
) -> tuple[Geom2d_CartesianPoint, None, None, None, bool]: ...
|
||||
|
||||
|
||||
def _as_gcc_arg(obj: Edge | Vector, constaint: Tangency) -> tuple[
|
||||
Geom2dGcc_QualifiedCurve | Geom2d_CartesianPoint,
|
||||
Geom2d_Curve | None,
|
||||
float | None,
|
||||
float | None,
|
||||
bool,
|
||||
]:
|
||||
"""
|
||||
Normalize input to a GCC argument.
|
||||
Returns: (q_obj, h2d, first, last, is_edge)
|
||||
- Edge -> (QualifiedCurve, h2d, first, last, True)
|
||||
- Vector -> (CartesianPoint, None, None, None, False)
|
||||
"""
|
||||
if obj.wrapped is None:
|
||||
raise TypeError("Can't create a qualified curve from empty edge")
|
||||
|
||||
if isinstance(obj.wrapped, TopoDS_Edge):
|
||||
return _edge_to_qualified_2d(obj.wrapped, constaint)[0:4] + (True,)
|
||||
|
||||
gp_pnt = gp_Pnt2d(obj.X, obj.Y)
|
||||
return Geom2d_CartesianPoint(gp_pnt), None, None, None, False
|
||||
|
||||
|
||||
def _two_arc_edges_from_params(
|
||||
circ: gp_Circ2d, u1: float, u2: float
|
||||
) -> list[TopoDS_Edge]:
|
||||
"""
|
||||
Given two parameters on a circle, return both the forward (minor)
|
||||
and complementary (major) arcs as TopoDS_Edge(s).
|
||||
Uses centralized normalization utilities.
|
||||
"""
|
||||
h2d_circle = Geom2d_Circle(circ)
|
||||
period = h2d_circle.Period() # usually 2*pi
|
||||
|
||||
# Minor (forward) span
|
||||
d = _forward_delta(u1, u2, 0.0, period) # anchor at 0 for circle convenience
|
||||
u1n = _norm_on_period(u1, 0.0, period)
|
||||
u2n = _norm_on_period(u2, 0.0, period)
|
||||
|
||||
# Guard degeneracy
|
||||
if d <= TOLERANCE or abs(period - d) <= TOLERANCE:
|
||||
return ShapeList()
|
||||
|
||||
minor = _edge_from_circle(h2d_circle, u1n, u1n + d)
|
||||
major = _edge_from_circle(h2d_circle, u2n, u2n + (period - d))
|
||||
return [minor, major]
|
||||
|
||||
|
||||
def _edge_from_line(
|
||||
p1: gp_Pnt2d,
|
||||
p2: gp_Pnt2d,
|
||||
) -> TopoDS_Edge:
|
||||
"""
|
||||
Build a finite Edge from two 2D contact points.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
p1, p2 : gp_Pnt2d
|
||||
Endpoints of the line segment (in 2D).
|
||||
edge_factory : type[Edge], optional
|
||||
Factory for building the Edge subtype (defaults to Edge).
|
||||
|
||||
Returns
|
||||
-------
|
||||
TopoDS_Edge
|
||||
Finite line segment between the two points.
|
||||
"""
|
||||
v1 = BRepBuilderAPI_MakeVertex(gp_Pnt(p1.X(), p1.Y(), 0)).Vertex()
|
||||
v2 = BRepBuilderAPI_MakeVertex(gp_Pnt(p2.X(), p2.Y(), 0)).Vertex()
|
||||
|
||||
mk_edge = BRepBuilderAPI_MakeEdge(v1, v2)
|
||||
if not mk_edge.IsDone():
|
||||
raise RuntimeError("Failed to build edge from line contacts")
|
||||
return mk_edge.Edge()
|
||||
|
||||
|
||||
def _gp_lin2d_from_axis(ax: Axis) -> gp_Lin2d:
|
||||
"""Build a 2D reference line from an Axis (XY plane)."""
|
||||
p = gp_Pnt2d(ax.position.X, ax.position.Y)
|
||||
d = gp_Dir2d(ax.direction.X, ax.direction.Y)
|
||||
return gp_Lin2d(gp_Ax2d(p, d))
|
||||
|
||||
|
||||
def _qstr(q) -> str: # pragma: no cover
|
||||
"""Debugging facility that works with OCP's GccEnt enum values"""
|
||||
try:
|
||||
from OCP.GccEnt import GccEnt_enclosed, GccEnt_enclosing, GccEnt_outside
|
||||
|
||||
try:
|
||||
from OCP.GccEnt import GccEnt_unqualified
|
||||
except ImportError:
|
||||
# Some OCCT versions name this 'noqualifier'
|
||||
from OCP.GccEnt import GccEnt_noqualifier as GccEnt_unqualified
|
||||
mapping = {
|
||||
GccEnt_enclosed: "enclosed",
|
||||
GccEnt_enclosing: "enclosing",
|
||||
GccEnt_outside: "outside",
|
||||
GccEnt_unqualified: "unqualified",
|
||||
}
|
||||
return mapping.get(q, f"unknown({int(q)})")
|
||||
except Exception:
|
||||
# Fallback if enums aren't importable for any reason
|
||||
return str(int(q))
|
||||
|
||||
|
||||
def _make_2tan_rad_arcs(
|
||||
*tangencies: tuple[Edge, Tangency] | Edge | Vector, # 2
|
||||
radius: float,
|
||||
sagitta: Sagitta = Sagitta.SHORT,
|
||||
edge_factory: Callable[[TopoDS_Edge], Edge],
|
||||
) -> ShapeList[Edge]:
|
||||
"""
|
||||
Create all planar circular arcs of a given radius that are tangent/contacting
|
||||
the two provided objects on the XY plane.
|
||||
|
||||
Inputs must be coplanar with ``Plane.XY``. Non-coplanar edges are not supported.
|
||||
|
||||
Args:
|
||||
tangencies (tuple[Edge, PositionConstraint] | Edge | Vertex | VectorLike:
|
||||
Geometric entity to be contacted/touched by the circle(s)
|
||||
radius (float): Circle radius for all candidate solutions.
|
||||
|
||||
Raises:
|
||||
ValueError: Invalid input
|
||||
ValueError: Invalid curve
|
||||
RuntimeError: no valid circle solutions found
|
||||
|
||||
Returns:
|
||||
ShapeList[Edge]: A list of planar circular edges (on XY) representing both
|
||||
the minor and major arcs between the two tangency points for every valid
|
||||
circle solution.
|
||||
|
||||
"""
|
||||
|
||||
# Unpack optional per-edge qualifiers (default UNQUALIFIED)
|
||||
tangent_tuples = [
|
||||
t if isinstance(t, tuple) else (t, Tangency.UNQUALIFIED) for t in tangencies
|
||||
]
|
||||
|
||||
# Build inputs for GCC
|
||||
results = [_as_gcc_arg(*t) for t in tangent_tuples]
|
||||
q_o: tuple[Geom2dGcc_QualifiedCurve, Geom2dGcc_QualifiedCurve]
|
||||
q_o, h_e, e_first, e_last, is_edge = map(tuple, zip(*results))
|
||||
|
||||
gcc = Geom2dGcc_Circ2d2TanRad(*q_o, radius, TOLERANCE)
|
||||
if not gcc.IsDone() or gcc.NbSolutions() == 0:
|
||||
raise RuntimeError("Unable to find a tangent arc")
|
||||
|
||||
def _ok(i: int, u: float) -> bool:
|
||||
"""Does the given parameter value lie within the edge range?"""
|
||||
return (
|
||||
True if not is_edge[i] else _param_in_trim(u, e_first[i], e_last[i], h_e[i])
|
||||
)
|
||||
|
||||
# ---------------------------
|
||||
# Solutions
|
||||
# ---------------------------
|
||||
solutions: list[TopoDS_Edge] = []
|
||||
for i in range(1, gcc.NbSolutions() + 1):
|
||||
circ: gp_Circ2d = gcc.ThisSolution(i)
|
||||
|
||||
# Tangency on curve 1
|
||||
p1 = gp_Pnt2d()
|
||||
u_circ1, u_arg1 = gcc.Tangency1(i, p1)
|
||||
if not _ok(0, u_arg1):
|
||||
continue
|
||||
|
||||
# Tangency on curve 2
|
||||
p2 = gp_Pnt2d()
|
||||
u_circ2, u_arg2 = gcc.Tangency2(i, p2)
|
||||
if not _ok(1, u_arg2):
|
||||
continue
|
||||
|
||||
# qual1 = GccEnt_Position(int())
|
||||
# qual2 = GccEnt_Position(int())
|
||||
# gcc.WhichQualifier(i, qual1, qual2) # returns two GccEnt_Position values
|
||||
# print(
|
||||
# f"Solution {i}: "
|
||||
# f"arg1={_qstr(qual1)}, arg2={_qstr(qual2)} | "
|
||||
# f"u_circ=({u_circ1:.6g}, {u_circ2:.6g}) "
|
||||
# f"u_arg=({u_arg1:.6g}, {u_arg2:.6g})"
|
||||
# )
|
||||
|
||||
# Build BOTH sagitta arcs and select by LengthConstraint
|
||||
if sagitta == Sagitta.BOTH:
|
||||
solutions.extend(_two_arc_edges_from_params(circ, u_circ1, u_circ2))
|
||||
else:
|
||||
arcs = _two_arc_edges_from_params(circ, u_circ1, u_circ2)
|
||||
arcs = sorted(
|
||||
arcs, key=lambda e: GCPnts_AbscissaPoint.Length_s(BRepAdaptor_Curve(e))
|
||||
)
|
||||
solutions.append(arcs[sagitta.value])
|
||||
return ShapeList([edge_factory(e) for e in solutions])
|
||||
|
||||
|
||||
def _make_2tan_on_arcs(
|
||||
*tangencies: tuple[Edge, Tangency] | Edge | Vector, # 2
|
||||
center_on: Edge,
|
||||
sagitta: Sagitta = Sagitta.SHORT,
|
||||
edge_factory: Callable[[TopoDS_Edge], Edge],
|
||||
) -> ShapeList[Edge]:
|
||||
"""
|
||||
Create all planar circular arcs whose circle is tangent to two objects and whose
|
||||
CENTER lies on a given locus (line/circle/curve) on the XY plane.
|
||||
|
||||
Notes
|
||||
-----
|
||||
- `center_on` is treated as a **center locus** (not a tangency target).
|
||||
"""
|
||||
|
||||
# Unpack optional per-edge qualifiers (default UNQUALIFIED)
|
||||
tangent_tuples = [
|
||||
t if isinstance(t, tuple) else (t, Tangency.UNQUALIFIED)
|
||||
for t in list(tangencies) + [center_on]
|
||||
]
|
||||
|
||||
# Build inputs for GCC
|
||||
results = [_as_gcc_arg(*t) for t in tangent_tuples]
|
||||
q_o: tuple[
|
||||
Geom2dGcc_QualifiedCurve, Geom2dGcc_QualifiedCurve, Geom2dGcc_QualifiedCurve
|
||||
]
|
||||
q_o, h_e, e_first, e_last, is_edge = map(tuple, zip(*results))
|
||||
adapt_on = Geom2dAdaptor_Curve(h_e[2], e_first[2], e_last[2])
|
||||
|
||||
# Provide initial middle guess parameters for all of the edges
|
||||
guesses: list[float] = [
|
||||
(e_last[i] - e_first[i]) / 2 + e_first[i]
|
||||
for i in range(len(tangent_tuples))
|
||||
if is_edge[i]
|
||||
]
|
||||
|
||||
if sum(is_edge) > 1:
|
||||
gcc = Geom2dGcc_Circ2d2TanOn(q_o[0], q_o[1], adapt_on, TOLERANCE, *guesses)
|
||||
else:
|
||||
assert isinstance(q_o[0], Geom2d_Point)
|
||||
assert isinstance(q_o[1], Geom2d_Point)
|
||||
gcc = Geom2dGcc_Circ2d2TanOn(q_o[0], q_o[1], adapt_on, TOLERANCE)
|
||||
|
||||
if not gcc.IsDone() or gcc.NbSolutions() == 0:
|
||||
raise RuntimeError("Unable to find a tangent arc with center_on constraint")
|
||||
|
||||
def _ok(i: int, u: float) -> bool:
|
||||
"""Does the given parameter value lie within the edge range?"""
|
||||
return (
|
||||
True if not is_edge[i] else _param_in_trim(u, e_first[i], e_last[i], h_e[i])
|
||||
)
|
||||
|
||||
# ---------------------------
|
||||
# Solutions
|
||||
# ---------------------------
|
||||
solutions: list[TopoDS_Edge] = []
|
||||
for i in range(1, gcc.NbSolutions() + 1):
|
||||
circ: gp_Circ2d = gcc.ThisSolution(i)
|
||||
|
||||
# Tangency on curve 1
|
||||
p1 = gp_Pnt2d()
|
||||
u_circ1, u_arg1 = gcc.Tangency1(i, p1)
|
||||
if not _ok(0, u_arg1):
|
||||
continue
|
||||
|
||||
# Tangency on curve 2
|
||||
p2 = gp_Pnt2d()
|
||||
u_circ2, u_arg2 = gcc.Tangency2(i, p2)
|
||||
if not _ok(1, u_arg2):
|
||||
continue
|
||||
|
||||
# Build sagitta arc(s) and select by LengthConstraint
|
||||
if sagitta == Sagitta.BOTH:
|
||||
solutions.extend(_two_arc_edges_from_params(circ, u_circ1, u_circ2))
|
||||
else:
|
||||
arcs = _two_arc_edges_from_params(circ, u_circ1, u_circ2)
|
||||
arcs = sorted(
|
||||
arcs, key=lambda e: GCPnts_AbscissaPoint.Length_s(BRepAdaptor_Curve(e))
|
||||
)
|
||||
solutions.append(arcs[sagitta.value])
|
||||
|
||||
return ShapeList([edge_factory(e) for e in solutions])
|
||||
|
||||
|
||||
def _make_3tan_arcs(
|
||||
*tangencies: tuple[Edge, Tangency] | Edge | Vector, # 3
|
||||
sagitta: Sagitta = Sagitta.SHORT,
|
||||
edge_factory: Callable[[TopoDS_Edge], Edge],
|
||||
) -> ShapeList[Edge]:
|
||||
"""
|
||||
Create planar circular arc(s) on XY tangent to three provided objects.
|
||||
|
||||
The circle is determined by the three tangency constraints; the returned arc(s)
|
||||
are trimmed between the two tangency points corresponding to `tangencies[0]` and
|
||||
`tangencies[1]`. Use `sagitta` to select the shorter/longer (or both) arc.
|
||||
Inputs must be representable on Plane.XY.
|
||||
"""
|
||||
|
||||
# Unpack optional per-edge qualifiers (default UNQUALIFIED)
|
||||
tangent_tuples = [
|
||||
t if isinstance(t, tuple) else (t, Tangency.UNQUALIFIED) for t in tangencies
|
||||
]
|
||||
|
||||
# Build inputs for GCC
|
||||
results = [_as_gcc_arg(*t) for t in tangent_tuples]
|
||||
q_o: tuple[
|
||||
Geom2dGcc_QualifiedCurve, Geom2dGcc_QualifiedCurve, Geom2dGcc_QualifiedCurve
|
||||
]
|
||||
q_o, h_e, e_first, e_last, is_edge = map(tuple, zip(*results))
|
||||
|
||||
# Provide initial middle guess parameters for all of the edges
|
||||
guesses: tuple[float, float, float] = tuple(
|
||||
[(e_last[i] - e_first[i]) / 2 + e_first[i] for i in range(3)]
|
||||
)
|
||||
|
||||
# Generate all valid circles tangent to the 3 inputs
|
||||
msg = "Unable to find a circle tangent to all three objects"
|
||||
try:
|
||||
gcc = Geom2dGcc_Circ2d3Tan(*q_o, TOLERANCE, *guesses)
|
||||
except (Standard_ConstructionError, Standard_Failure) as con_err:
|
||||
raise RuntimeError(msg) from con_err
|
||||
if not gcc.IsDone() or gcc.NbSolutions() == 0:
|
||||
raise RuntimeError(msg)
|
||||
|
||||
def _ok(i: int, u: float) -> bool:
|
||||
"""Does the given parameter value lie within the edge range?"""
|
||||
return (
|
||||
True if not is_edge[i] else _param_in_trim(u, e_first[i], e_last[i], h_e[i])
|
||||
)
|
||||
|
||||
# ---------------------------
|
||||
# Enumerate solutions
|
||||
# ---------------------------
|
||||
out_topos: list[TopoDS_Edge] = []
|
||||
for i in range(1, gcc.NbSolutions() + 1):
|
||||
circ: gp_Circ2d = gcc.ThisSolution(i)
|
||||
|
||||
# Look at all of the solutions
|
||||
# h2d_circle = Geom2d_Circle(circ)
|
||||
# arc2d = Geom2d_TrimmedCurve(h2d_circle, 0, 2 * pi, True)
|
||||
# out_topos.append(BRepBuilderAPI_MakeEdge(arc2d, _surf_xy).Edge())
|
||||
# continue
|
||||
|
||||
# Tangency on curve 1 (arc endpoint A)
|
||||
p1 = gp_Pnt2d()
|
||||
u_circ1, u_arg1 = gcc.Tangency1(i, p1)
|
||||
if not _ok(0, u_arg1):
|
||||
continue
|
||||
|
||||
# Tangency on curve 2 (arc endpoint B)
|
||||
p2 = gp_Pnt2d()
|
||||
u_circ2, u_arg2 = gcc.Tangency2(i, p2)
|
||||
if not _ok(1, u_arg2):
|
||||
continue
|
||||
|
||||
# Tangency on curve 3 (validates circle; does not define arc endpoints)
|
||||
p3 = gp_Pnt2d()
|
||||
_u_circ3, u_arg3 = gcc.Tangency3(i, p3)
|
||||
if not _ok(2, u_arg3):
|
||||
continue
|
||||
|
||||
# Build arc(s) between u_circ1 and u_circ2 per LengthConstraint
|
||||
if sagitta == Sagitta.BOTH:
|
||||
out_topos.extend(_two_arc_edges_from_params(circ, u_circ1, u_circ2))
|
||||
else:
|
||||
arcs = _two_arc_edges_from_params(circ, u_circ1, u_circ2)
|
||||
arcs = sorted(
|
||||
arcs,
|
||||
key=lambda e: GCPnts_AbscissaPoint.Length_s(BRepAdaptor_Curve(e)),
|
||||
)
|
||||
out_topos.append(arcs[sagitta.value])
|
||||
|
||||
return ShapeList([edge_factory(e) for e in out_topos])
|
||||
|
||||
|
||||
def _make_tan_cen_arcs(
|
||||
tangency: tuple[Edge, Tangency] | Edge | Vector,
|
||||
*,
|
||||
center: VectorLike | Vertex,
|
||||
edge_factory: Callable[[TopoDS_Edge], Edge],
|
||||
) -> ShapeList[Edge]:
|
||||
"""
|
||||
Create planar circle(s) on XY whose center is fixed and that are tangent/contacting
|
||||
a single object.
|
||||
|
||||
Notes
|
||||
-----
|
||||
- With a **fixed center** and a single tangency constraint, the natural geometric
|
||||
result is a full circle; there are no second endpoints to define an arc span.
|
||||
This routine therefore returns closed circular edges (full 2π trims).
|
||||
- If the tangency target is a point (Vertex/VectorLike), the circle is the one
|
||||
centered at `center` and passing through that point (built directly).
|
||||
"""
|
||||
|
||||
# Unpack optional qualifier on the tangency arg (edges only)
|
||||
if isinstance(tangency, tuple):
|
||||
object_one, obj1_qual = tangency
|
||||
else:
|
||||
object_one, obj1_qual = tangency, Tangency.UNQUALIFIED
|
||||
|
||||
# ---------------------------
|
||||
# Build fixed center (gp_Pnt2d)
|
||||
# ---------------------------
|
||||
if isinstance(center, Vertex):
|
||||
loc_xyz = center.position if center.position is not None else Vector(0, 0)
|
||||
base = Vector(center)
|
||||
c2d = gp_Pnt2d(base.X + loc_xyz.X, base.Y + loc_xyz.Y)
|
||||
else:
|
||||
v = Vector(center)
|
||||
c2d = gp_Pnt2d(v.X, v.Y)
|
||||
|
||||
# ---------------------------
|
||||
# Tangency input
|
||||
# ---------------------------
|
||||
q_o1, h_e1, e1_first, e1_last, is_edge1 = _as_gcc_arg(object_one, obj1_qual)
|
||||
|
||||
solutions_topo: list[TopoDS_Edge] = []
|
||||
|
||||
# Case A: tangency target is a point -> circle passes through that point
|
||||
if not is_edge1 and isinstance(q_o1, Geom2d_CartesianPoint):
|
||||
p = q_o1.Pnt2d()
|
||||
# radius = distance(center, point)
|
||||
dx, dy = p.X() - c2d.X(), p.Y() - c2d.Y()
|
||||
r = (dx * dx + dy * dy) ** 0.5
|
||||
if r <= TOLERANCE:
|
||||
# Center coincides with point: no valid circle
|
||||
return ShapeList([])
|
||||
# Build full circle
|
||||
circ = gp_Circ2d(gp_Ax2d(c2d, gp_Dir2d(1.0, 0.0)), r)
|
||||
h2d = Geom2d_Circle(circ)
|
||||
per = h2d.Period()
|
||||
solutions_topo.append(_edge_from_circle(h2d, 0.0, per))
|
||||
|
||||
else:
|
||||
assert isinstance(q_o1, Geom2dGcc_QualifiedCurve)
|
||||
# Case B: tangency target is a curve/edge (qualified curve)
|
||||
gcc = Geom2dGcc_Circ2dTanCen(q_o1, Geom2d_CartesianPoint(c2d), TOLERANCE)
|
||||
assert (
|
||||
gcc.IsDone() and gcc.NbSolutions() > 0
|
||||
), "Unexpected: GCC failed to return a tangent circle"
|
||||
|
||||
for i in range(1, gcc.NbSolutions() + 1):
|
||||
circ = gcc.ThisSolution(i) # gp_Circ2d
|
||||
|
||||
# Validate tangency lies on trimmed span if the target is an Edge
|
||||
p1 = gp_Pnt2d()
|
||||
_u_on_circ, u_on_arg = gcc.Tangency1(i, p1)
|
||||
if is_edge1 and not _param_in_trim(u_on_arg, e1_first, e1_last, h_e1):
|
||||
continue
|
||||
|
||||
# Emit full circle (2π trim)
|
||||
h2d = Geom2d_Circle(circ)
|
||||
per = h2d.Period()
|
||||
solutions_topo.append(_edge_from_circle(h2d, 0.0, per))
|
||||
|
||||
return ShapeList([edge_factory(e) for e in solutions_topo])
|
||||
|
||||
|
||||
def _make_tan_on_rad_arcs(
|
||||
tangency: tuple[Edge, Tangency] | Edge | Vector,
|
||||
*,
|
||||
center_on: Edge,
|
||||
radius: float,
|
||||
edge_factory: Callable[[TopoDS_Edge], Edge],
|
||||
) -> ShapeList[Edge]:
|
||||
"""
|
||||
Create planar circle(s) on XY that:
|
||||
- are tangent/contacting a single object, and
|
||||
- have a fixed radius, and
|
||||
- have their CENTER constrained to lie on a given locus curve.
|
||||
|
||||
Notes
|
||||
-----
|
||||
- The center locus must be a 2D curve (line/circle/any Geom2d curve) — i.e. an Edge
|
||||
after projection to XY.
|
||||
- With only one tangency, the natural geometric result is a full circle; arc cropping
|
||||
would require an additional endpoint constraint. This routine therefore returns
|
||||
closed circular edges (2π trims) for each valid solution.
|
||||
"""
|
||||
|
||||
# --- unpack optional qualifier on the tangency arg (edges only) ---
|
||||
if isinstance(tangency, tuple):
|
||||
object_one, obj1_qual = tangency
|
||||
else:
|
||||
object_one, obj1_qual = tangency, Tangency.UNQUALIFIED
|
||||
|
||||
# --- build tangency input (point/edge) ---
|
||||
q_o1, h_e1, e1_first, e1_last, is_edge1 = _as_gcc_arg(object_one, obj1_qual)
|
||||
|
||||
# --- center locus ('center_on') must be a curve; ignore any qualifier there ---
|
||||
on_obj = center_on[0] if isinstance(center_on, tuple) else center_on
|
||||
if not isinstance(on_obj.wrapped, TopoDS_Edge):
|
||||
raise TypeError("center_on must be an Edge (line/circle/curve) for TanOnRad.")
|
||||
|
||||
# Project the center locus Edge to 2D (XY)
|
||||
_, h_on2d, on_first, on_last, adapt_on = _edge_to_qualified_2d(
|
||||
on_obj.wrapped, Tangency.UNQUALIFIED
|
||||
)
|
||||
gcc = Geom2dGcc_Circ2dTanOnRad(q_o1, adapt_on, radius, TOLERANCE)
|
||||
|
||||
if not gcc.IsDone() or gcc.NbSolutions() == 0:
|
||||
raise RuntimeError("Unable to find circle(s) for TanOnRad constraints")
|
||||
|
||||
def _ok1(u: float) -> bool:
|
||||
return True if not is_edge1 else _param_in_trim(u, e1_first, e1_last, h_e1)
|
||||
|
||||
# --- enumerate solutions; emit full circles (2π trims) ---
|
||||
out_topos: list[TopoDS_Edge] = []
|
||||
for i in range(1, gcc.NbSolutions() + 1):
|
||||
circ: gp_Circ2d = gcc.ThisSolution(i)
|
||||
|
||||
# Validate tangency lies on trimmed span when the target is an Edge
|
||||
p = gp_Pnt2d()
|
||||
_u_on_circ, u_on_arg = gcc.Tangency1(i, p)
|
||||
if not _ok1(u_on_arg):
|
||||
continue
|
||||
|
||||
# Center must lie on the trimmed center_on curve segment
|
||||
center2d = circ.Location() # gp_Pnt2d
|
||||
|
||||
# Project center onto the (trimmed) 2D locus
|
||||
proj = Geom2dAPI_ProjectPointOnCurve(center2d, h_on2d)
|
||||
u_on = proj.Parameter(1)
|
||||
|
||||
# Respect the trimmed interval (handles periodic curves too)
|
||||
if not _param_in_trim(u_on, on_first, on_last, h_on2d):
|
||||
continue
|
||||
|
||||
h2d = Geom2d_Circle(circ)
|
||||
per = h2d.Period()
|
||||
out_topos.append(_edge_from_circle(h2d, 0.0, per))
|
||||
|
||||
return ShapeList([edge_factory(e) for e in out_topos])
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Line solvers (siblings of constrained arcs)
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_2tan_lines(
|
||||
tangency1: tuple[Edge, Tangency] | Edge,
|
||||
tangency2: tuple[Edge, Tangency] | Edge | Vector,
|
||||
*,
|
||||
edge_factory: Callable[[TopoDS_Edge], Edge],
|
||||
) -> ShapeList[Edge]:
|
||||
"""
|
||||
Construct line(s) tangent to two curves.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
curve1, curve2 : Edge
|
||||
Target curves.
|
||||
|
||||
Returns
|
||||
-------
|
||||
ShapeList[Edge]
|
||||
Finite tangent line(s).
|
||||
"""
|
||||
if isinstance(tangency1, tuple):
|
||||
object_one, obj1_qual = tangency1
|
||||
else:
|
||||
object_one, obj1_qual = tangency1, Tangency.UNQUALIFIED
|
||||
q1, c1, _, _, _ = _as_gcc_arg(object_one, obj1_qual)
|
||||
|
||||
if isinstance(tangency2, Vector):
|
||||
pnt_2d = gp_Pnt2d(tangency2.X, tangency2.Y)
|
||||
gcc = Geom2dGcc_Lin2d2Tan(q1, pnt_2d, TOLERANCE)
|
||||
else:
|
||||
if isinstance(tangency2, tuple):
|
||||
object_two, obj2_qual = tangency2
|
||||
else:
|
||||
object_two, obj2_qual = tangency2, Tangency.UNQUALIFIED
|
||||
q2, c2, _, _, _ = _as_gcc_arg(object_two, obj2_qual)
|
||||
gcc = Geom2dGcc_Lin2d2Tan(q1, q2, TOLERANCE)
|
||||
|
||||
if not gcc.IsDone() or gcc.NbSolutions() == 0:
|
||||
raise RuntimeError("Unable to find common tangent line(s)")
|
||||
|
||||
out_edges: list[TopoDS_Edge] = []
|
||||
for i in range(1, gcc.NbSolutions() + 1):
|
||||
lin2d = Geom2d_Line(gcc.ThisSolution(i))
|
||||
|
||||
# Two tangency points - Note Tangency1/Tangency2 can use different
|
||||
# indices for the same line
|
||||
inter_cc = Geom2dAPI_InterCurveCurve(lin2d, c1)
|
||||
pt1 = inter_cc.Point(1) # There will always be one tangent intersection
|
||||
|
||||
if isinstance(tangency2, Vector):
|
||||
pt2 = gp_Pnt2d(tangency2.X, tangency2.Y)
|
||||
else:
|
||||
inter_cc = Geom2dAPI_InterCurveCurve(lin2d, c2)
|
||||
pt2 = inter_cc.Point(1)
|
||||
|
||||
# Skip degenerate lines
|
||||
separation = pt1.Distance(pt2)
|
||||
if isnan(separation) or separation < TOLERANCE:
|
||||
continue
|
||||
|
||||
out_edges.append(_edge_from_line(pt1, pt2))
|
||||
return ShapeList([edge_factory(e) for e in out_edges])
|
||||
|
||||
|
||||
def _make_tan_oriented_lines(
|
||||
tangency: tuple[Edge, Tangency] | Edge,
|
||||
reference: Axis,
|
||||
angle: float, # radians; absolute angle offset from `reference`
|
||||
*,
|
||||
edge_factory: Callable[[TopoDS_Edge], Edge],
|
||||
) -> ShapeList[Edge]:
|
||||
"""
|
||||
Construct line(s) tangent to a curve and forming a given angle with a
|
||||
reference line (Axis) per Geom2dGcc_Lin2dTanObl. Trimmed between:
|
||||
- the tangency point on the curve, and
|
||||
- the intersection with the reference line.
|
||||
"""
|
||||
if isinstance(tangency, tuple):
|
||||
object_one, obj1_qual = tangency
|
||||
else:
|
||||
object_one, obj1_qual = tangency, Tangency.UNQUALIFIED
|
||||
|
||||
if abs(abs(reference.direction.Z) - 1) < TOLERANCE:
|
||||
raise ValueError("reference Axis can't be perpendicular to Plane.XY")
|
||||
|
||||
q_curve, _, _, _, _ = _as_gcc_arg(object_one, obj1_qual)
|
||||
|
||||
# reference axis direction (2D angle in radians)
|
||||
ref_dir = reference.direction
|
||||
theta_ref = atan2(ref_dir.Y, ref_dir.X)
|
||||
|
||||
# total absolute angle
|
||||
theta_abs = theta_ref + angle
|
||||
|
||||
dir2d = gp_Dir2d(cos(theta_abs), sin(theta_abs))
|
||||
|
||||
# Reference axis as gp_Lin2d
|
||||
ref_lin = _gp_lin2d_from_axis(reference)
|
||||
|
||||
# Note that is seems impossible for Geom2dGcc_Lin2dTanObl to provide no solutions
|
||||
gcc = Geom2dGcc_Lin2dTanObl(q_curve, ref_lin, TOLERANCE, angle)
|
||||
|
||||
out: list[TopoDS_Edge] = []
|
||||
for i in range(1, gcc.NbSolutions() + 1):
|
||||
# Tangency on the curve
|
||||
p_tan = gp_Pnt2d()
|
||||
gcc.Tangency1(i, p_tan)
|
||||
|
||||
tan_line = gp_Lin2d(p_tan, dir2d)
|
||||
|
||||
# Intersect with reference axis
|
||||
# Note: Intersection2 doesn't seem reliable
|
||||
inter = IntAna2d_AnaIntersection(tan_line, ref_lin)
|
||||
if not inter.IsDone() or inter.NbPoints() == 0:
|
||||
continue
|
||||
p_isect = inter.Point(1).Value()
|
||||
|
||||
# Skip degenerate lines
|
||||
separation = p_tan.Distance(p_isect)
|
||||
if isnan(separation) or separation < TOLERANCE:
|
||||
continue
|
||||
|
||||
out.append(_edge_from_line(p_tan, p_isect))
|
||||
|
||||
return ShapeList([edge_factory(e) for e in out])
|
||||
|
|
@ -52,18 +52,15 @@ license:
|
|||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import itertools
|
||||
import numpy as np
|
||||
import warnings
|
||||
from collections.abc import Iterable
|
||||
from itertools import combinations
|
||||
from math import radians, inf, pi, cos, copysign, ceil, floor, isclose
|
||||
from math import atan2, ceil, copysign, cos, floor, inf, isclose, pi, radians
|
||||
from typing import TYPE_CHECKING, Literal, TypeAlias, overload
|
||||
from typing import cast as tcast
|
||||
from typing import Literal, overload, TYPE_CHECKING
|
||||
from typing_extensions import Self
|
||||
from scipy.optimize import minimize_scalar
|
||||
from scipy.spatial import ConvexHull
|
||||
|
||||
import numpy as np
|
||||
import OCP.TopAbs as ta
|
||||
from OCP.BRep import BRep_Tool
|
||||
from OCP.BRepAdaptor import BRepAdaptor_CompCurve, BRepAdaptor_Curve
|
||||
|
|
@ -76,6 +73,7 @@ from OCP.BRepBuilderAPI import (
|
|||
BRepBuilderAPI_DisconnectedWire,
|
||||
BRepBuilderAPI_EmptyWire,
|
||||
BRepBuilderAPI_MakeEdge,
|
||||
BRepBuilderAPI_MakeEdge2d,
|
||||
BRepBuilderAPI_MakeFace,
|
||||
BRepBuilderAPI_MakePolygon,
|
||||
BRepBuilderAPI_MakeWire,
|
||||
|
|
@ -92,29 +90,45 @@ from OCP.BRepPrimAPI import BRepPrimAPI_MakeHalfSpace
|
|||
from OCP.BRepProj import BRepProj_Projection
|
||||
from OCP.BRepTools import BRepTools, BRepTools_WireExplorer
|
||||
from OCP.GC import GC_MakeArcOfCircle, GC_MakeArcOfEllipse
|
||||
from OCP.GccEnt import GccEnt_unqualified, GccEnt_Position
|
||||
from OCP.GCPnts import GCPnts_AbscissaPoint
|
||||
from OCP.GProp import GProp_GProps
|
||||
from OCP.Geom import (
|
||||
Geom_BezierCurve,
|
||||
Geom_BSplineCurve,
|
||||
Geom_ConicalSurface,
|
||||
Geom_CylindricalSurface,
|
||||
Geom_Line,
|
||||
Geom_Plane,
|
||||
Geom_Surface,
|
||||
Geom_TrimmedCurve,
|
||||
Geom_Line,
|
||||
)
|
||||
from OCP.Geom2d import Geom2d_Curve, Geom2d_Line, Geom2d_TrimmedCurve
|
||||
from OCP.Geom2d import (
|
||||
Geom2d_CartesianPoint,
|
||||
Geom2d_Circle,
|
||||
Geom2d_Curve,
|
||||
Geom2d_Line,
|
||||
Geom2d_Point,
|
||||
Geom2d_TrimmedCurve,
|
||||
)
|
||||
from OCP.Geom2dAdaptor import Geom2dAdaptor_Curve
|
||||
from OCP.Geom2dAPI import Geom2dAPI_InterCurveCurve
|
||||
from OCP.GeomAbs import GeomAbs_C0, GeomAbs_G1, GeomAbs_C1, GeomAbs_G2, GeomAbs_C2
|
||||
from OCP.Geom2dGcc import Geom2dGcc_Circ2d2TanRad, Geom2dGcc_QualifiedCurve
|
||||
from OCP.GeomAbs import (
|
||||
GeomAbs_C0,
|
||||
GeomAbs_C1,
|
||||
GeomAbs_C2,
|
||||
GeomAbs_G1,
|
||||
GeomAbs_G2,
|
||||
GeomAbs_JoinType,
|
||||
)
|
||||
from OCP.GeomAdaptor import GeomAdaptor_Curve
|
||||
from OCP.GeomAPI import (
|
||||
GeomAPI,
|
||||
GeomAPI_IntCS,
|
||||
GeomAPI_Interpolate,
|
||||
GeomAPI_PointsToBSpline,
|
||||
GeomAPI_ProjectPointOnCurve,
|
||||
)
|
||||
from OCP.GeomAbs import GeomAbs_JoinType
|
||||
from OCP.GeomAdaptor import GeomAdaptor_Curve
|
||||
from OCP.GeomConvert import GeomConvert_CompCurveToBSplineCurve
|
||||
from OCP.GeomFill import (
|
||||
GeomFill_CorrectedFrenet,
|
||||
|
|
@ -122,30 +136,40 @@ from OCP.GeomFill import (
|
|||
GeomFill_TrihedronLaw,
|
||||
)
|
||||
from OCP.GeomProjLib import GeomProjLib
|
||||
from OCP.gp import (
|
||||
gp_Ax1,
|
||||
gp_Ax2,
|
||||
gp_Ax3,
|
||||
gp_Circ,
|
||||
gp_Circ2d,
|
||||
gp_Dir,
|
||||
gp_Dir2d,
|
||||
gp_Elips,
|
||||
gp_Pln,
|
||||
gp_Pnt,
|
||||
gp_Pnt2d,
|
||||
gp_Trsf,
|
||||
gp_Vec,
|
||||
)
|
||||
from OCP.GProp import GProp_GProps
|
||||
from OCP.HLRAlgo import HLRAlgo_Projector
|
||||
from OCP.HLRBRep import HLRBRep_Algo, HLRBRep_HLRToShape
|
||||
from OCP.ShapeAnalysis import ShapeAnalysis_FreeBounds
|
||||
from OCP.ShapeFix import ShapeFix_Shape, ShapeFix_Wireframe
|
||||
from OCP.Standard import (
|
||||
Standard_ConstructionError,
|
||||
Standard_Failure,
|
||||
Standard_NoSuchObject,
|
||||
Standard_ConstructionError,
|
||||
)
|
||||
from OCP.TColgp import TColgp_Array1OfPnt, TColgp_Array1OfVec, TColgp_HArray1OfPnt
|
||||
from OCP.TColStd import (
|
||||
TColStd_Array1OfReal,
|
||||
TColStd_HArray1OfBoolean,
|
||||
TColStd_HArray1OfReal,
|
||||
)
|
||||
from OCP.TColgp import TColgp_Array1OfPnt, TColgp_Array1OfVec, TColgp_HArray1OfPnt
|
||||
from OCP.TopAbs import TopAbs_Orientation, TopAbs_ShapeEnum
|
||||
from OCP.TopExp import TopExp, TopExp_Explorer
|
||||
from OCP.TopLoc import TopLoc_Location
|
||||
from OCP.TopTools import (
|
||||
TopTools_HSequenceOfShape,
|
||||
TopTools_IndexedDataMapOfShapeListOfShape,
|
||||
TopTools_IndexedMapOfShape,
|
||||
TopTools_ListOfShape,
|
||||
)
|
||||
from OCP.TopoDS import (
|
||||
TopoDS,
|
||||
TopoDS_Compound,
|
||||
|
|
@ -156,34 +180,33 @@ from OCP.TopoDS import (
|
|||
TopoDS_Vertex,
|
||||
TopoDS_Wire,
|
||||
)
|
||||
from OCP.gp import (
|
||||
gp_Ax1,
|
||||
gp_Ax2,
|
||||
gp_Ax3,
|
||||
gp_Circ,
|
||||
gp_Dir,
|
||||
gp_Dir2d,
|
||||
gp_Elips,
|
||||
gp_Pnt,
|
||||
gp_Pnt2d,
|
||||
gp_Trsf,
|
||||
gp_Vec,
|
||||
from OCP.TopTools import (
|
||||
TopTools_HSequenceOfShape,
|
||||
TopTools_IndexedDataMapOfShapeListOfShape,
|
||||
TopTools_IndexedMapOfShape,
|
||||
TopTools_ListOfShape,
|
||||
)
|
||||
from scipy.optimize import minimize_scalar
|
||||
from scipy.spatial import ConvexHull
|
||||
from typing_extensions import Self
|
||||
|
||||
from build123d.build_enums import (
|
||||
AngularDirection,
|
||||
ContinuityLevel,
|
||||
CenterOf,
|
||||
ContinuityLevel,
|
||||
FrameMethod,
|
||||
GeomType,
|
||||
Keep,
|
||||
Kind,
|
||||
Sagitta,
|
||||
Tangency,
|
||||
PositionMode,
|
||||
Side,
|
||||
)
|
||||
from build123d.geometry import (
|
||||
DEG2RAD,
|
||||
TOLERANCE,
|
||||
TOL_DIGITS,
|
||||
TOLERANCE,
|
||||
Axis,
|
||||
Color,
|
||||
Location,
|
||||
|
|
@ -206,17 +229,25 @@ from .shape_core import (
|
|||
)
|
||||
from .utils import (
|
||||
_extrude_topods_shape,
|
||||
isclose_b,
|
||||
_make_topods_face_from_wires,
|
||||
_topods_bool_op,
|
||||
isclose_b,
|
||||
)
|
||||
from .zero_d import Vertex, topo_explore_common_vertex
|
||||
from .constrained_lines import (
|
||||
_make_2tan_rad_arcs,
|
||||
_make_2tan_on_arcs,
|
||||
_make_3tan_arcs,
|
||||
_make_tan_cen_arcs,
|
||||
_make_tan_on_rad_arcs,
|
||||
_make_tan_oriented_lines,
|
||||
_make_2tan_lines,
|
||||
)
|
||||
from .zero_d import topo_explore_common_vertex, Vertex
|
||||
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
from .two_d import Face, Shell # pylint: disable=R0801
|
||||
from .composite import Compound, Curve, Part, Sketch # pylint: disable=R0801
|
||||
from .three_d import Solid # pylint: disable=R0801
|
||||
from .composite import Compound, Curve, Sketch, Part # pylint: disable=R0801
|
||||
from .two_d import Face, Shell # pylint: disable=R0801
|
||||
|
||||
|
||||
class Mixin1D(Shape):
|
||||
|
|
@ -327,6 +358,21 @@ class Mixin1D(Shape):
|
|||
"""Unused - only here because Mixin1D is a subclass of Shape"""
|
||||
return NotImplemented
|
||||
|
||||
# ---- Static Methods ----
|
||||
|
||||
@staticmethod
|
||||
def _to_param(edge_wire: Mixin1D, value: float | VectorLike, name: str) -> float:
|
||||
"""Convert a float or VectorLike into a curve parameter."""
|
||||
if isinstance(value, (int, float)):
|
||||
return float(value)
|
||||
try:
|
||||
point = Vector(value)
|
||||
except TypeError as exc:
|
||||
raise TypeError(
|
||||
f"{name} must be a float or VectorLike, not {value!r}"
|
||||
) from exc
|
||||
return edge_wire.param_at_point(point)
|
||||
|
||||
# ---- Instance Methods ----
|
||||
|
||||
def __add__(
|
||||
|
|
@ -665,6 +711,145 @@ class Mixin1D(Shape):
|
|||
|
||||
return Vector(curve.Value(umax))
|
||||
|
||||
def intersect(
|
||||
self, *to_intersect: Shape | Vector | Location | Axis | Plane
|
||||
) -> None | ShapeList[Vertex | Edge]:
|
||||
"""Intersect Edge with Shape or geometry object
|
||||
|
||||
Args:
|
||||
to_intersect (Shape | Vector | Location | Axis | Plane): objects to intersect
|
||||
|
||||
Returns:
|
||||
ShapeList[Vertex | Edge] | None: ShapeList of vertices and/or edges
|
||||
"""
|
||||
|
||||
def to_vector(objs: Iterable) -> ShapeList:
|
||||
return ShapeList([Vector(v) if isinstance(v, Vertex) else v for v in objs])
|
||||
|
||||
def to_vertex(objs: Iterable) -> ShapeList:
|
||||
return ShapeList([Vertex(v) if isinstance(v, Vector) else v for v in objs])
|
||||
|
||||
common_set: ShapeList[Vertex | Edge] = ShapeList(self.edges())
|
||||
target: ShapeList | Shape | Plane
|
||||
for other in to_intersect:
|
||||
# Conform target type
|
||||
# Vertices need to be Vector for set()
|
||||
match other:
|
||||
case Axis():
|
||||
target = ShapeList([Edge(other)])
|
||||
case Plane():
|
||||
target = other
|
||||
case Vector():
|
||||
target = Vertex(other)
|
||||
case Location():
|
||||
target = Vertex(other.position)
|
||||
case Edge():
|
||||
target = ShapeList([other])
|
||||
case Wire():
|
||||
target = ShapeList(other.edges())
|
||||
case _ if issubclass(type(other), Shape):
|
||||
target = other
|
||||
case _:
|
||||
raise ValueError(f"Unsupported type to_intersect: {type(other)}")
|
||||
|
||||
# Find common matches
|
||||
common: list[Vector | Edge] = []
|
||||
result: ShapeList | Shape | None
|
||||
for obj in common_set:
|
||||
match (obj, target):
|
||||
case obj, Shape() as target:
|
||||
# Find Shape with Edge/Wire
|
||||
if isinstance(target, Vertex):
|
||||
result = Shape.intersect(obj, target)
|
||||
else:
|
||||
result = target.intersect(obj)
|
||||
|
||||
if result:
|
||||
if not isinstance(result, list):
|
||||
result = ShapeList([result])
|
||||
common.extend(to_vector(result))
|
||||
|
||||
case Vertex() as obj, target:
|
||||
if not isinstance(target, ShapeList):
|
||||
target = ShapeList([target])
|
||||
|
||||
for tar in target:
|
||||
if isinstance(tar, Edge):
|
||||
result = Shape.intersect(obj, tar)
|
||||
else:
|
||||
result = obj.intersect(tar)
|
||||
|
||||
if result:
|
||||
if not isinstance(result, list):
|
||||
result = ShapeList([result])
|
||||
common.extend(to_vector(result))
|
||||
|
||||
case Edge() as obj, ShapeList() as targets:
|
||||
# Find any edge / edge intersection points
|
||||
for tar in targets:
|
||||
# Find crossing points
|
||||
try:
|
||||
intersection_points = obj.find_intersection_points(tar)
|
||||
common.extend(intersection_points)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# Find common end points
|
||||
obj_end_points = set(Vector(v) for v in obj.vertices())
|
||||
tar_end_points = set(Vector(v) for v in tar.vertices())
|
||||
points = set.intersection(obj_end_points, tar_end_points)
|
||||
common.extend(points)
|
||||
|
||||
# Find Edge/Edge overlaps
|
||||
result = obj._bool_op(
|
||||
(obj,), targets, BRepAlgoAPI_Common()
|
||||
).edges()
|
||||
common.extend(result if isinstance(result, list) else [result])
|
||||
|
||||
case Edge() as obj, Plane() as plane:
|
||||
# Find any edge / plane intersection points & edges
|
||||
# Find point intersections
|
||||
if obj.wrapped is None:
|
||||
continue
|
||||
geom_line = BRep_Tool.Curve_s(
|
||||
obj.wrapped, obj.param_at(0), obj.param_at(1)
|
||||
)
|
||||
geom_plane = Geom_Plane(plane.local_coord_system)
|
||||
intersection_calculator = GeomAPI_IntCS(geom_line, geom_plane)
|
||||
plane_intersection_points: list[Vector] = []
|
||||
if intersection_calculator.IsDone():
|
||||
plane_intersection_points = [
|
||||
Vector(intersection_calculator.Point(i + 1))
|
||||
for i in range(intersection_calculator.NbPoints())
|
||||
]
|
||||
common.extend(plane_intersection_points)
|
||||
|
||||
# Find edge intersections
|
||||
if all(
|
||||
plane.contains(v)
|
||||
for v in obj.positions(i / 7 for i in range(8))
|
||||
): # is a 2D edge
|
||||
common.append(obj)
|
||||
|
||||
if common:
|
||||
common_set = to_vertex(set(common))
|
||||
# Remove Vertex intersections coincident to Edge intersections
|
||||
vts = common_set.vertices()
|
||||
eds = common_set.edges()
|
||||
if vts and eds:
|
||||
filtered_vts = ShapeList(
|
||||
[
|
||||
v
|
||||
for v in vts
|
||||
if all(v.distance_to(e) > TOLERANCE for e in eds)
|
||||
]
|
||||
)
|
||||
common_set = filtered_vts + eds
|
||||
else:
|
||||
return None
|
||||
|
||||
return ShapeList(common_set)
|
||||
|
||||
def location_at(
|
||||
self,
|
||||
distance: float,
|
||||
|
|
@ -1555,6 +1740,397 @@ class Edge(Mixin1D, Shape[TopoDS_Edge]):
|
|||
return_value = cls(BRepBuilderAPI_MakeEdge(circle_geom).Edge())
|
||||
return return_value
|
||||
|
||||
@overload
|
||||
@classmethod
|
||||
def make_constrained_arcs(
|
||||
cls,
|
||||
tangency_one: tuple[Axis | Edge, Tangency] | Axis | Edge | Vertex | VectorLike,
|
||||
tangency_two: tuple[Axis | Edge, Tangency] | Axis | Edge | Vertex | VectorLike,
|
||||
*,
|
||||
radius: float,
|
||||
sagitta: Sagitta = Sagitta.SHORT,
|
||||
) -> ShapeList[Edge]:
|
||||
"""
|
||||
Create all planar circular arcs of a given radius that are tangent/contacting
|
||||
the two provided objects on the XY plane.
|
||||
Args:
|
||||
tangency_one, tangency_two
|
||||
(tuple[Axis | Edge, PositionConstraint] | Axis | Edge | Vertex | VectorLike):
|
||||
Geometric entities to be contacted/touched by the circle(s)
|
||||
radius (float): arc radius
|
||||
sagitta (LengthConstraint, optional): returned arc selector
|
||||
(i.e. either the short, long or both arcs). Defaults to
|
||||
LengthConstraint.SHORT.
|
||||
|
||||
Returns:
|
||||
ShapeList[Edge]: tangent arcs
|
||||
"""
|
||||
|
||||
@overload
|
||||
@classmethod
|
||||
def make_constrained_arcs(
|
||||
cls,
|
||||
tangency_one: tuple[Axis | Edge, Tangency] | Axis | Edge | Vertex | VectorLike,
|
||||
tangency_two: tuple[Axis | Edge, Tangency] | Axis | Edge | Vertex | VectorLike,
|
||||
*,
|
||||
center_on: Axis | Edge,
|
||||
sagitta: Sagitta = Sagitta.SHORT,
|
||||
) -> ShapeList[Edge]:
|
||||
"""
|
||||
Create all planar circular arcs whose circle is tangent to two objects and whose
|
||||
CENTER lies on a given locus (line/circle/curve) on the XY plane.
|
||||
|
||||
Args:
|
||||
tangency_one, tangency_two
|
||||
(tuple[Axus | Edge, PositionConstraint] | Axis | Edge | Vertex | VectorLike):
|
||||
Geometric entities to be contacted/touched by the circle(s)
|
||||
center_on (Axis | Edge): center must lie on this object
|
||||
sagitta (LengthConstraint, optional): returned arc selector
|
||||
(i.e. either the short, long or both arcs). Defaults to
|
||||
LengthConstraint.SHORT.
|
||||
|
||||
Returns:
|
||||
ShapeList[Edge]: tangent arcs
|
||||
"""
|
||||
|
||||
@overload
|
||||
@classmethod
|
||||
def make_constrained_arcs(
|
||||
cls,
|
||||
tangency_one: tuple[Axis | Edge, Tangency] | Axis | Edge | Vertex | VectorLike,
|
||||
tangency_two: tuple[Axis | Edge, Tangency] | Axis | Edge | Vertex | VectorLike,
|
||||
tangency_three: (
|
||||
tuple[Axis | Edge, Tangency] | Axis | Edge | Vertex | VectorLike
|
||||
),
|
||||
*,
|
||||
sagitta: Sagitta = Sagitta.SHORT,
|
||||
) -> ShapeList[Edge]:
|
||||
"""
|
||||
Create planar circular arc(s) on XY tangent to three provided objects.
|
||||
|
||||
Args:
|
||||
tangency_one, tangency_two, tangency_three
|
||||
(tuple[Axis | Edge, PositionConstraint] | Axis | Edge | Vertex | VectorLike):
|
||||
Geometric entities to be contacted/touched by the circle(s)
|
||||
sagitta (LengthConstraint, optional): returned arc selector
|
||||
(i.e. either the short, long or both arcs). Defaults to
|
||||
LengthConstraint.SHORT.
|
||||
|
||||
Returns:
|
||||
ShapeList[Edge]: tangent arcs
|
||||
"""
|
||||
|
||||
@overload
|
||||
@classmethod
|
||||
def make_constrained_arcs(
|
||||
cls,
|
||||
tangency_one: tuple[Axis | Edge, Tangency] | Axis | Edge | Vertex | VectorLike,
|
||||
*,
|
||||
center: VectorLike,
|
||||
) -> ShapeList[Edge]:
|
||||
"""make_constrained_arcs
|
||||
|
||||
Create planar circle(s) on XY whose center is fixed and that are tangent/contacting
|
||||
a single object.
|
||||
|
||||
Args:
|
||||
tangency_one
|
||||
(tuple[Axis | Edge, PositionConstraint] | Axis | Edge | Vertex | VectorLike):
|
||||
Geometric entity to be contacted/touched by the circle(s)
|
||||
center (VectorLike): center position
|
||||
|
||||
Returns:
|
||||
ShapeList[Edge]: tangent arcs
|
||||
"""
|
||||
|
||||
@overload
|
||||
@classmethod
|
||||
def make_constrained_arcs(
|
||||
cls,
|
||||
tangency_one: tuple[Axis | Edge, Tangency] | Axis | Edge | Vertex | VectorLike,
|
||||
*,
|
||||
radius: float,
|
||||
center_on: Edge,
|
||||
) -> ShapeList[Edge]:
|
||||
"""make_constrained_arcs
|
||||
|
||||
Create planar circle(s) on XY that:
|
||||
- are tangent/contacting a single object, and
|
||||
- have a fixed radius, and
|
||||
- have their CENTER constrained to lie on a given locus curve.
|
||||
|
||||
Args:
|
||||
tangency_one
|
||||
(tuple[Axis | Edge, PositionConstraint] | Axis | Edge | Vertex | VectorLike):
|
||||
Geometric entity to be contacted/touched by the circle(s)
|
||||
radius (float): arc radius
|
||||
center_on (Axis | Edge): center must lie on this object
|
||||
sagitta (LengthConstraint, optional): returned arc selector
|
||||
(i.e. either the short, long or both arcs). Defaults to
|
||||
LengthConstraint.SHORT.
|
||||
|
||||
Returns:
|
||||
ShapeList[Edge]: tangent arcs
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def make_constrained_arcs(
|
||||
cls,
|
||||
*args,
|
||||
sagitta: Sagitta = Sagitta.SHORT,
|
||||
**kwargs,
|
||||
) -> ShapeList[Edge]:
|
||||
|
||||
tangency_one = args[0] if len(args) > 0 else None
|
||||
tangency_two = args[1] if len(args) > 1 else None
|
||||
tangency_three = args[2] if len(args) > 2 else None
|
||||
|
||||
tangency_one = kwargs.pop("tangency_one", tangency_one)
|
||||
tangency_two = kwargs.pop("tangency_two", tangency_two)
|
||||
tangency_three = kwargs.pop("tangency_three", tangency_three)
|
||||
|
||||
radius = kwargs.pop("radius", None)
|
||||
center = kwargs.pop("center", None)
|
||||
center_on = kwargs.pop("center_on", None)
|
||||
|
||||
# Handle unexpected kwargs
|
||||
if kwargs:
|
||||
raise TypeError(f"Unexpected argument(s): {', '.join(kwargs.keys())}")
|
||||
|
||||
tangency_args = [
|
||||
t for t in (tangency_one, tangency_two, tangency_three) if t is not None
|
||||
]
|
||||
tangencies: list[tuple[Edge, Tangency] | Edge | Vector] = []
|
||||
for tangency_arg in tangency_args:
|
||||
if isinstance(tangency_arg, Axis):
|
||||
tangencies.append(Edge(tangency_arg))
|
||||
continue
|
||||
elif isinstance(tangency_arg, Edge):
|
||||
tangencies.append(tangency_arg)
|
||||
continue
|
||||
if isinstance(tangency_arg, tuple):
|
||||
if isinstance(tangency_arg[0], Axis):
|
||||
tangencies.append(tuple(Edge(tangency_arg[0], tangency_arg[1])))
|
||||
continue
|
||||
elif isinstance(tangency_arg[0], Edge):
|
||||
tangencies.append(tangency_arg)
|
||||
continue
|
||||
if isinstance(tangency_arg, Vertex):
|
||||
tangencies.append(Vector(tangency_arg) + tangency_arg.position)
|
||||
continue
|
||||
|
||||
# if not Axes, Edges, constrained Edges or Vertex convert to Vectors
|
||||
try:
|
||||
tangencies.append(Vector(tangency_arg))
|
||||
except Exception as exc:
|
||||
raise TypeError(f"Invalid tangency: {tangency_arg!r}") from exc
|
||||
|
||||
# # Sort the tangency inputs so points are always last
|
||||
tangencies = sorted(tangencies, key=lambda x: isinstance(x, Vector))
|
||||
|
||||
tan_count = len(tangencies)
|
||||
if not (1 <= tan_count <= 3):
|
||||
raise TypeError("Provide 1 to 3 tangency targets.")
|
||||
|
||||
# Radius sanity
|
||||
if radius is not None and radius <= 0:
|
||||
raise ValueError("radius must be > 0.0")
|
||||
|
||||
if center_on is not None and isinstance(center_on, Axis):
|
||||
center_on = Edge(center_on)
|
||||
|
||||
# --- decide problem kind ---
|
||||
if (
|
||||
tan_count == 2
|
||||
and radius is not None
|
||||
and center is None
|
||||
and center_on is None
|
||||
):
|
||||
return _make_2tan_rad_arcs(
|
||||
*tangencies,
|
||||
radius=radius,
|
||||
sagitta=sagitta,
|
||||
edge_factory=cls,
|
||||
)
|
||||
if (
|
||||
tan_count == 2
|
||||
and center_on is not None
|
||||
and radius is None
|
||||
and center is None
|
||||
):
|
||||
return _make_2tan_on_arcs(
|
||||
*tangencies,
|
||||
center_on=center_on,
|
||||
sagitta=sagitta,
|
||||
edge_factory=cls,
|
||||
)
|
||||
if tan_count == 3 and radius is None and center is None and center_on is None:
|
||||
return _make_3tan_arcs(*tangencies, sagitta=sagitta, edge_factory=cls)
|
||||
if (
|
||||
tan_count == 1
|
||||
and center is not None
|
||||
and radius is None
|
||||
and center_on is None
|
||||
):
|
||||
return _make_tan_cen_arcs(*tangencies, center=center, edge_factory=cls)
|
||||
if tan_count == 1 and center_on is not None and radius is not None:
|
||||
return _make_tan_on_rad_arcs(
|
||||
*tangencies, center_on=center_on, radius=radius, edge_factory=cls
|
||||
)
|
||||
|
||||
raise ValueError("Unsupported or ambiguous combination of constraints.")
|
||||
|
||||
@overload
|
||||
@classmethod
|
||||
def make_constrained_lines(
|
||||
cls,
|
||||
tangency_one: tuple[Edge, Tangency] | Axis | Edge,
|
||||
tangency_two: tuple[Edge, Tangency] | Axis | Edge,
|
||||
) -> ShapeList[Edge]:
|
||||
"""
|
||||
Create all planar line(s) on the XY plane tangent to two provided curves.
|
||||
|
||||
Args:
|
||||
tangency_one, tangency_two
|
||||
(tuple[Edge, Tangency] | Axis | Edge):
|
||||
Geometric entities to be contacted/touched by the line(s).
|
||||
|
||||
Returns:
|
||||
ShapeList[Edge]: tangent lines
|
||||
"""
|
||||
|
||||
@overload
|
||||
@classmethod
|
||||
def make_constrained_lines(
|
||||
cls,
|
||||
tangency_one: tuple[Edge, Tangency] | Edge,
|
||||
tangency_two: Vector,
|
||||
) -> ShapeList[Edge]:
|
||||
"""
|
||||
Create all planar line(s) on the XY plane tangent to one curve and passing
|
||||
through a fixed point.
|
||||
|
||||
Args:
|
||||
tangency_one
|
||||
(tuple[Edge, Tangency] | Edge):
|
||||
Geometric entity to be contacted/touched by the line(s).
|
||||
tangency_two (Vector):
|
||||
Fixed point through which the line(s) must pass.
|
||||
|
||||
Returns:
|
||||
ShapeList[Edge]: tangent lines
|
||||
"""
|
||||
|
||||
@overload
|
||||
@classmethod
|
||||
def make_constrained_lines(
|
||||
cls,
|
||||
tangency_one: tuple[Edge, Tangency] | Edge,
|
||||
tangency_two: Axis,
|
||||
*,
|
||||
angle: float | None = None,
|
||||
direction: VectorLike | None = None,
|
||||
) -> ShapeList[Edge]:
|
||||
"""
|
||||
Create all planar line(s) on the XY plane tangent to one curve and passing
|
||||
through a fixed point.
|
||||
|
||||
Args:
|
||||
tangency_one (Edge): edge that line will be tangent to
|
||||
tangency_two (Axis): axis that angle will be measured against
|
||||
angle : float, optional
|
||||
Line orientation in degrees (measured CCW from the X-axis).
|
||||
direction : VectorLike, optional
|
||||
Direction vector for the line (only X and Y components are used).
|
||||
Note: one of angle or direction must be provided
|
||||
|
||||
Returns:
|
||||
ShapeList[Edge]: tangent lines
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def make_constrained_lines(cls, *args, **kwargs) -> ShapeList[Edge]:
|
||||
"""
|
||||
Create planar line(s) on XY subject to tangency/contact constraints.
|
||||
|
||||
Supported cases
|
||||
---------------
|
||||
1. Tangent to two curves
|
||||
2. Tangent to one curve and passing through a given point
|
||||
"""
|
||||
tangency_one = args[0] if len(args) > 0 else None
|
||||
tangency_two = args[1] if len(args) > 1 else None
|
||||
|
||||
tangency_one = kwargs.pop("tangency_one", tangency_one)
|
||||
tangency_two = kwargs.pop("tangency_two", tangency_two)
|
||||
|
||||
angle = kwargs.pop("angle", None)
|
||||
direction = kwargs.pop("direction", None)
|
||||
direction = Vector(direction) if direction is not None else None
|
||||
|
||||
is_ref = angle is not None or direction is not None
|
||||
# Handle unexpected kwargs
|
||||
if kwargs:
|
||||
raise TypeError(f"Unexpected argument(s): {', '.join(kwargs.keys())}")
|
||||
|
||||
tangency_args = [t for t in (tangency_one, tangency_two) if t is not None]
|
||||
if len(tangency_args) != 2:
|
||||
raise TypeError("Provide exactly 2 tangency targets.")
|
||||
|
||||
tangencies: list[tuple[Edge, Tangency] | Axis | Edge | Vector] = []
|
||||
for i, tangency_arg in enumerate(tangency_args):
|
||||
if isinstance(tangency_arg, Axis):
|
||||
if i == 1 and is_ref:
|
||||
tangencies.append(tangency_arg)
|
||||
else:
|
||||
tangencies.append(Edge(tangency_arg))
|
||||
continue
|
||||
elif isinstance(tangency_arg, Edge):
|
||||
tangencies.append(tangency_arg)
|
||||
continue
|
||||
if isinstance(tangency_arg, tuple) and isinstance(tangency_arg[0], Edge):
|
||||
tangencies.append(tangency_arg)
|
||||
continue
|
||||
# Fallback: treat as a point
|
||||
try:
|
||||
tangencies.append(Vector(tangency_arg))
|
||||
except Exception as exc:
|
||||
raise TypeError(f"Invalid tangency: {tangency_arg!r}") from exc
|
||||
|
||||
# Sort so Vector (point) | Axis is always last
|
||||
tangencies = sorted(tangencies, key=lambda x: isinstance(x, (Axis, Vector)))
|
||||
|
||||
# --- decide problem kind ---
|
||||
if angle is not None or direction is not None:
|
||||
if isinstance(tangencies[0], tuple):
|
||||
assert isinstance(
|
||||
tangencies[0][0], Edge
|
||||
), "Internal error - 1st tangency must be Edge"
|
||||
else:
|
||||
assert isinstance(
|
||||
tangencies[0], Edge
|
||||
), "Internal error - 1st tangency must be Edge"
|
||||
if angle is not None:
|
||||
ang_rad = radians(angle)
|
||||
else:
|
||||
assert direction is not None
|
||||
ang_rad = atan2(direction.Y, direction.X)
|
||||
assert isinstance(
|
||||
tangencies[1], Axis
|
||||
), "Internal error - 2nd tangency must be an Axis"
|
||||
return _make_tan_oriented_lines(
|
||||
tangencies[0], tangencies[1], ang_rad, edge_factory=cls
|
||||
)
|
||||
else:
|
||||
assert not isinstance(
|
||||
tangencies[0], (Axis, Vector)
|
||||
), "Internal error - 1st tangency can't be an Axis | Vector"
|
||||
assert not isinstance(
|
||||
tangencies[1], Axis
|
||||
), "Internal error - 2nd tangency can't be an Axis"
|
||||
|
||||
return _make_2tan_lines(tangencies[0], tangencies[1], edge_factory=cls)
|
||||
|
||||
@classmethod
|
||||
def make_ellipse(
|
||||
cls,
|
||||
|
|
@ -2151,90 +2727,6 @@ class Edge(Mixin1D, Shape[TopoDS_Edge]):
|
|||
raise ValueError("Can't find adaptor for empty edge")
|
||||
return BRepAdaptor_Curve(self.wrapped)
|
||||
|
||||
def intersect(
|
||||
self, *to_intersect: Edge | Axis | Plane
|
||||
) -> None | Vertex | Edge | ShapeList[Vertex | Edge]:
|
||||
"""intersect Edge with Edge or Axis
|
||||
|
||||
Args:
|
||||
other (Edge | Axis): other object
|
||||
|
||||
Returns:
|
||||
Shape | None: Compound of vertices and/or edges
|
||||
"""
|
||||
edges: list[Edge] = []
|
||||
planes: list[Plane] = []
|
||||
edges_common_to_planes: list[Edge] = []
|
||||
|
||||
for obj in to_intersect:
|
||||
match obj:
|
||||
case Axis():
|
||||
edges.append(Edge(obj))
|
||||
case Edge():
|
||||
edges.append(obj)
|
||||
case Plane():
|
||||
planes.append(obj)
|
||||
case _:
|
||||
raise ValueError(f"Unknown object type: {type(obj)}")
|
||||
|
||||
# Find any edge / edge intersection points
|
||||
points_sets: list[set[Vector]] = []
|
||||
# Find crossing points
|
||||
for edge_pair in combinations([self] + edges, 2):
|
||||
intersection_points = edge_pair[0].find_intersection_points(edge_pair[1])
|
||||
points_sets.append(set(intersection_points))
|
||||
|
||||
# Find common end points
|
||||
self_end_points = set(Vector(v) for v in self.vertices())
|
||||
edge_end_points = set(Vector(v) for edge in edges for v in edge.vertices())
|
||||
common_end_points = set.intersection(self_end_points, edge_end_points)
|
||||
|
||||
# Find any edge / plane intersection points & edges
|
||||
for edge, plane in itertools.product([self] + edges, planes):
|
||||
if edge.wrapped is None:
|
||||
continue
|
||||
# Find point intersections
|
||||
geom_line = BRep_Tool.Curve_s(
|
||||
edge.wrapped, edge.param_at(0), edge.param_at(1)
|
||||
)
|
||||
geom_plane = Geom_Plane(plane.local_coord_system)
|
||||
intersection_calculator = GeomAPI_IntCS(geom_line, geom_plane)
|
||||
plane_intersection_points: list[Vector] = []
|
||||
if intersection_calculator.IsDone():
|
||||
plane_intersection_points = [
|
||||
Vector(intersection_calculator.Point(i + 1))
|
||||
for i in range(intersection_calculator.NbPoints())
|
||||
]
|
||||
points_sets.append(set(plane_intersection_points))
|
||||
|
||||
# Find edge intersections
|
||||
if all(
|
||||
plane.contains(v) for v in edge.positions(i / 7 for i in range(8))
|
||||
): # is a 2D edge
|
||||
edges_common_to_planes.append(edge)
|
||||
|
||||
edges.extend(edges_common_to_planes)
|
||||
|
||||
# Find the intersection of all sets
|
||||
common_points = set.intersection(*points_sets)
|
||||
common_vertices = [
|
||||
Vertex(pnt) for pnt in common_points.union(common_end_points)
|
||||
]
|
||||
|
||||
# Find Edge/Edge overlaps
|
||||
common_edges: list[Edge] = []
|
||||
if edges:
|
||||
common_edges = self._bool_op((self,), edges, BRepAlgoAPI_Common()).edges()
|
||||
|
||||
if common_vertices or common_edges:
|
||||
# If there is just one vertex or edge return it
|
||||
if len(common_vertices) == 1 and len(common_edges) == 0:
|
||||
return common_vertices[0]
|
||||
if len(common_vertices) == 0 and len(common_edges) == 1:
|
||||
return common_edges[0]
|
||||
return ShapeList(common_vertices + common_edges)
|
||||
return None
|
||||
|
||||
def _occt_param_at(
|
||||
self, position: float, position_mode: PositionMode = PositionMode.PARAMETER
|
||||
) -> tuple[BRepAdaptor_Curve, float, bool]:
|
||||
|
|
@ -2495,24 +2987,43 @@ class Edge(Mixin1D, Shape[TopoDS_Edge]):
|
|||
)
|
||||
return Wire([self])
|
||||
|
||||
def trim(self, start: float, end: float) -> Edge:
|
||||
def trim(self, start: float | VectorLike, end: float | VectorLike) -> Edge:
|
||||
"""_summary_
|
||||
|
||||
Args:
|
||||
start (float | VectorLike): _description_
|
||||
end (float | VectorLike): _description_
|
||||
|
||||
Raises:
|
||||
TypeError: _description_
|
||||
ValueError: _description_
|
||||
|
||||
Returns:
|
||||
Edge: _description_
|
||||
"""
|
||||
"""trim
|
||||
|
||||
Create a new edge by keeping only the section between start and end.
|
||||
|
||||
Args:
|
||||
start (float): 0.0 <= start < 1.0
|
||||
end (float): 0.0 < end <= 1.0
|
||||
start (float | VectorLike): 0.0 <= start < 1.0 or point on edge
|
||||
end (float | VectorLike): 0.0 < end <= 1.0 or point on edge
|
||||
|
||||
Raises:
|
||||
ValueError: start >= end
|
||||
TypeError: invalid input, must be float or VectorLike
|
||||
ValueError: can't trim empty edge
|
||||
|
||||
Returns:
|
||||
Edge: trimmed edge
|
||||
"""
|
||||
if start >= end:
|
||||
raise ValueError(f"start ({start}) must be less than end ({end})")
|
||||
|
||||
start_u = Mixin1D._to_param(self, start, "start")
|
||||
end_u = Mixin1D._to_param(self, end, "end")
|
||||
|
||||
start_u, end_u = sorted([start_u, end_u])
|
||||
|
||||
# if start_u >= end_u:
|
||||
# raise ValueError(f"start ({start_u}) must be less than end ({end_u})")
|
||||
|
||||
if self.wrapped is None:
|
||||
raise ValueError("Can't trim empty edge")
|
||||
|
|
@ -2523,8 +3034,8 @@ class Edge(Mixin1D, Shape[TopoDS_Edge]):
|
|||
new_curve = BRep_Tool.Curve_s(
|
||||
self_copy.wrapped, self.param_at(0), self.param_at(1)
|
||||
)
|
||||
parm_start = self.param_at(start)
|
||||
parm_end = self.param_at(end)
|
||||
parm_start = self.param_at(start_u)
|
||||
parm_end = self.param_at(end_u)
|
||||
trimmed_curve = Geom_TrimmedCurve(
|
||||
new_curve,
|
||||
parm_start,
|
||||
|
|
@ -2533,14 +3044,14 @@ class Edge(Mixin1D, Shape[TopoDS_Edge]):
|
|||
new_edge = BRepBuilderAPI_MakeEdge(trimmed_curve).Edge()
|
||||
return Edge(new_edge)
|
||||
|
||||
def trim_to_length(self, start: float, length: float) -> Edge:
|
||||
def trim_to_length(self, start: float | VectorLike, length: float) -> Edge:
|
||||
"""trim_to_length
|
||||
|
||||
Create a new edge starting at the given normalized parameter of a
|
||||
given length.
|
||||
|
||||
Args:
|
||||
start (float): 0.0 <= start < 1.0
|
||||
start (float | VectorLike): 0.0 <= start < 1.0 or point on edge
|
||||
length (float): target length
|
||||
|
||||
Raise:
|
||||
|
|
@ -2552,6 +3063,8 @@ class Edge(Mixin1D, Shape[TopoDS_Edge]):
|
|||
if self.wrapped is None:
|
||||
raise ValueError("Can't trim empty edge")
|
||||
|
||||
start_u = Mixin1D._to_param(self, start, "start")
|
||||
|
||||
self_copy = copy.deepcopy(self)
|
||||
assert self_copy.wrapped is not None
|
||||
|
||||
|
|
@ -2563,7 +3076,7 @@ class Edge(Mixin1D, Shape[TopoDS_Edge]):
|
|||
adaptor_curve = GeomAdaptor_Curve(new_curve)
|
||||
|
||||
# Find the parameter corresponding to the desired length
|
||||
parm_start = self.param_at(start)
|
||||
parm_start = self.param_at(start_u)
|
||||
abscissa_point = GCPnts_AbscissaPoint(adaptor_curve, length, parm_start)
|
||||
|
||||
# Get the parameter at the desired length
|
||||
|
|
@ -3073,7 +3586,6 @@ class Wire(Mixin1D, Shape[TopoDS_Wire]):
|
|||
return Wire.make_polygon(corners_world, close=True)
|
||||
|
||||
# ---- Static Methods ----
|
||||
|
||||
@staticmethod
|
||||
def order_chamfer_edges(
|
||||
reference_edge: Edge | None, edges: tuple[Edge, Edge]
|
||||
|
|
@ -3589,29 +4101,31 @@ class Wire(Mixin1D, Shape[TopoDS_Wire]):
|
|||
)
|
||||
return self
|
||||
|
||||
def trim(self: Wire, start: float, end: float) -> Wire:
|
||||
def trim(self: Wire, start: float | VectorLike, end: float | VectorLike) -> Wire:
|
||||
"""Trim a wire between [start, end] normalized over total length.
|
||||
|
||||
Args:
|
||||
start (float): normalized start position (0.0 to <1.0)
|
||||
end (float): normalized end position (>0.0 to 1.0)
|
||||
start (float | VectorLike): normalized start position (0.0 to <1.0) or point
|
||||
end (float | VectorLike): normalized end position (>0.0 to 1.0) or point
|
||||
|
||||
Returns:
|
||||
Wire: trimmed Wire
|
||||
"""
|
||||
if start >= end:
|
||||
raise ValueError("start must be less than end")
|
||||
start_u = Mixin1D._to_param(self, start, "start")
|
||||
end_u = Mixin1D._to_param(self, end, "end")
|
||||
|
||||
start_u, end_u = sorted([start_u, end_u])
|
||||
|
||||
# Extract the edges in order
|
||||
ordered_edges = self.edges().sort_by(self)
|
||||
|
||||
# If this is really just an edge, skip the complexity of a Wire
|
||||
if len(ordered_edges) == 1:
|
||||
return Wire([ordered_edges[0].trim(start, end)])
|
||||
return Wire([ordered_edges[0].trim(start_u, end_u)])
|
||||
|
||||
total_length = self.length
|
||||
start_len = start * total_length
|
||||
end_len = end * total_length
|
||||
start_len = start_u * total_length
|
||||
end_len = end_u * total_length
|
||||
|
||||
trimmed_edges = []
|
||||
cur_length = 0.0
|
||||
|
|
|
|||
|
|
@ -472,10 +472,10 @@ class Shape(NodeMixin, Generic[TOPODS]):
|
|||
return reduce(lambda loc, n: loc * n.location, self.path, Location())
|
||||
|
||||
@property
|
||||
def location(self) -> Location | None:
|
||||
def location(self) -> Location:
|
||||
"""Get this Shape's Location"""
|
||||
if self.wrapped is None:
|
||||
return None
|
||||
raise ValueError("Can't find the location of an empty shape")
|
||||
return Location(self.wrapped.Location())
|
||||
|
||||
@location.setter
|
||||
|
|
@ -529,10 +529,10 @@ class Shape(NodeMixin, Generic[TOPODS]):
|
|||
return matrix
|
||||
|
||||
@property
|
||||
def orientation(self) -> Vector | None:
|
||||
def orientation(self) -> Vector:
|
||||
"""Get the orientation component of this Shape's Location"""
|
||||
if self.location is None:
|
||||
return None
|
||||
raise ValueError("Can't find the orientation of an empty shape")
|
||||
return self.location.orientation
|
||||
|
||||
@orientation.setter
|
||||
|
|
@ -544,10 +544,10 @@ class Shape(NodeMixin, Generic[TOPODS]):
|
|||
self.location = loc
|
||||
|
||||
@property
|
||||
def position(self) -> Vector | None:
|
||||
def position(self) -> Vector:
|
||||
"""Get the position component of this Shape's Location"""
|
||||
if self.wrapped is None or self.location is None:
|
||||
return None
|
||||
raise ValueError("Can't find the position of an empty shape")
|
||||
return self.location.position
|
||||
|
||||
@position.setter
|
||||
|
|
@ -1326,7 +1326,7 @@ class Shape(NodeMixin, Generic[TOPODS]):
|
|||
)
|
||||
|
||||
def intersect(
|
||||
self, *to_intersect: Shape | Axis | Plane
|
||||
self, *to_intersect: Shape | Vector | Location | Axis | Plane
|
||||
) -> None | Self | ShapeList[Self]:
|
||||
"""Intersection of the arguments and this shape
|
||||
|
||||
|
|
|
|||
|
|
@ -64,7 +64,7 @@ from typing import TYPE_CHECKING, Any, TypeVar, overload
|
|||
|
||||
import OCP.TopAbs as ta
|
||||
from OCP.BRep import BRep_Builder, BRep_Tool
|
||||
from OCP.BRepAdaptor import BRepAdaptor_Surface
|
||||
from OCP.BRepAdaptor import BRepAdaptor_Curve, BRepAdaptor_Surface
|
||||
from OCP.BRepAlgo import BRepAlgo
|
||||
from OCP.BRepAlgoAPI import BRepAlgoAPI_Common
|
||||
from OCP.BRepBuilderAPI import (
|
||||
|
|
@ -81,8 +81,14 @@ from OCP.BRepOffsetAPI import BRepOffsetAPI_MakeFilling, BRepOffsetAPI_MakePipeS
|
|||
from OCP.BRepPrimAPI import BRepPrimAPI_MakeRevol
|
||||
from OCP.BRepTools import BRepTools, BRepTools_ReShape
|
||||
from OCP.gce import gce_MakeLin
|
||||
from OCP.Geom import Geom_BezierSurface, Geom_RectangularTrimmedSurface, Geom_Surface
|
||||
from OCP.GeomAbs import GeomAbs_C0, GeomAbs_G1, GeomAbs_G2
|
||||
from OCP.Geom import (
|
||||
Geom_BezierSurface,
|
||||
Geom_BSplineCurve,
|
||||
Geom_RectangularTrimmedSurface,
|
||||
Geom_Surface,
|
||||
Geom_TrimmedCurve,
|
||||
)
|
||||
from OCP.GeomAbs import GeomAbs_C0, GeomAbs_CurveType, GeomAbs_G1, GeomAbs_G2
|
||||
from OCP.GeomAPI import (
|
||||
GeomAPI_ExtremaCurveCurve,
|
||||
GeomAPI_PointsToBSplineSurface,
|
||||
|
|
@ -99,11 +105,16 @@ from OCP.Standard import (
|
|||
Standard_NoSuchObject,
|
||||
)
|
||||
from OCP.StdFail import StdFail_NotDone
|
||||
from OCP.TColgp import TColgp_HArray2OfPnt
|
||||
from OCP.TColStd import TColStd_HArray2OfReal
|
||||
from OCP.TColgp import TColgp_Array1OfPnt, TColgp_HArray2OfPnt
|
||||
from OCP.TColStd import (
|
||||
TColStd_Array1OfInteger,
|
||||
TColStd_Array1OfReal,
|
||||
TColStd_HArray2OfReal,
|
||||
)
|
||||
from OCP.TopExp import TopExp
|
||||
from OCP.TopoDS import TopoDS, TopoDS_Face, TopoDS_Shape, TopoDS_Shell, TopoDS_Solid
|
||||
from OCP.TopTools import TopTools_IndexedDataMapOfShapeListOfShape, TopTools_ListOfShape
|
||||
from ocp_gordon import interpolate_curve_network
|
||||
from typing_extensions import Self
|
||||
|
||||
from build123d.build_enums import (
|
||||
|
|
@ -649,7 +660,7 @@ class Face(Mixin2D, Shape[TopoDS_Face]):
|
|||
continue
|
||||
|
||||
top_list = ShapeList(top if isinstance(top, list) else [top])
|
||||
bottom_list = ShapeList(bottom if isinstance(top, list) else [bottom])
|
||||
bottom_list = ShapeList(bottom if isinstance(bottom, list) else [bottom])
|
||||
|
||||
if len(top_list) != len(bottom_list): # exit early unequal length
|
||||
continue
|
||||
|
|
@ -913,6 +924,91 @@ class Face(Mixin2D, Shape[TopoDS_Face]):
|
|||
|
||||
return cls(BRepBuilderAPI_MakeFace(bezier, Precision.Confusion_s()).Face())
|
||||
|
||||
@classmethod
|
||||
def make_gordon_surface(
|
||||
cls,
|
||||
profiles: Iterable[VectorLike | Edge],
|
||||
guides: Iterable[VectorLike | Edge],
|
||||
tolerance: float = 3e-4,
|
||||
) -> Face:
|
||||
"""
|
||||
Constructs a Gordon surface from a network of profile and guide curves.
|
||||
|
||||
Requirements:
|
||||
1. Profiles and guides may be defined as points or curves.
|
||||
2. Only the first or last profile or guide may be a point.
|
||||
3. At least one profile and one guide must be a non-point curve.
|
||||
4. Each profile must intersect with every guide.
|
||||
5. Both ends of every profile must lie on a guide.
|
||||
6. Both ends of every guide must lie on a profile.
|
||||
|
||||
Args:
|
||||
profiles (Iterable[VectorLike | Edge]): Profiles defined as points or edges.
|
||||
guides (Iterable[VectorLike | Edge]): Guides defined as points or edges.
|
||||
tolerance (float, optional): Tolerance used for surface construction and
|
||||
intersection calculations.
|
||||
|
||||
Raises:
|
||||
ValueError: input Edge cannot be empty.
|
||||
|
||||
Returns:
|
||||
Face: the interpolated Gordon surface
|
||||
"""
|
||||
|
||||
def create_zero_length_bspline_curve(
|
||||
point: gp_Pnt, degree: int = 1
|
||||
) -> Geom_BSplineCurve:
|
||||
control_points = TColgp_Array1OfPnt(1, 2)
|
||||
control_points.SetValue(1, point)
|
||||
control_points.SetValue(2, point)
|
||||
|
||||
knots = TColStd_Array1OfReal(1, 2)
|
||||
knots.SetValue(1, 0.0)
|
||||
knots.SetValue(2, 1.0)
|
||||
|
||||
multiplicities = TColStd_Array1OfInteger(1, 2)
|
||||
multiplicities.SetValue(1, degree + 1)
|
||||
multiplicities.SetValue(2, degree + 1)
|
||||
|
||||
curve = Geom_BSplineCurve(control_points, knots, multiplicities, degree)
|
||||
return curve
|
||||
|
||||
def to_geom_curve(shape: VectorLike | Edge):
|
||||
if isinstance(shape, (Vector, tuple, Sequence)):
|
||||
_shape = Vector(shape)
|
||||
single_point_curve = create_zero_length_bspline_curve(
|
||||
gp_Pnt(_shape.wrapped.XYZ())
|
||||
)
|
||||
return single_point_curve
|
||||
|
||||
if shape.wrapped is None:
|
||||
raise ValueError("input Edge cannot be empty")
|
||||
|
||||
adaptor = BRepAdaptor_Curve(shape.wrapped)
|
||||
curve = BRep_Tool.Curve_s(shape.wrapped, 0, 1)
|
||||
if not (
|
||||
(adaptor.IsPeriodic() and adaptor.IsClosed())
|
||||
or adaptor.GetType() == GeomAbs_CurveType.GeomAbs_BSplineCurve
|
||||
or adaptor.GetType() == GeomAbs_CurveType.GeomAbs_BezierCurve
|
||||
):
|
||||
curve = Geom_TrimmedCurve(
|
||||
curve, adaptor.FirstParameter(), adaptor.LastParameter()
|
||||
)
|
||||
return curve
|
||||
|
||||
ocp_profiles = [to_geom_curve(shape) for shape in profiles]
|
||||
ocp_guides = [to_geom_curve(shape) for shape in guides]
|
||||
|
||||
gordon_bspline_surface = interpolate_curve_network(
|
||||
ocp_profiles, ocp_guides, tolerance=tolerance
|
||||
)
|
||||
|
||||
return cls(
|
||||
BRepBuilderAPI_MakeFace(
|
||||
gordon_bspline_surface, Precision.Confusion_s()
|
||||
).Face()
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def make_plane(
|
||||
cls,
|
||||
|
|
|
|||
|
|
@ -59,6 +59,7 @@ import warnings
|
|||
from typing import overload, TYPE_CHECKING
|
||||
|
||||
from collections.abc import Iterable
|
||||
from typing_extensions import Self
|
||||
|
||||
import OCP.TopAbs as ta
|
||||
from OCP.BRep import BRep_Tool
|
||||
|
|
@ -66,8 +67,7 @@ from OCP.BRepBuilderAPI import BRepBuilderAPI_MakeVertex
|
|||
from OCP.TopExp import TopExp_Explorer
|
||||
from OCP.TopoDS import TopoDS, TopoDS_Shape, TopoDS_Vertex, TopoDS_Edge
|
||||
from OCP.gp import gp_Pnt
|
||||
from build123d.geometry import Matrix, Vector, VectorLike
|
||||
from typing_extensions import Self
|
||||
from build123d.geometry import Matrix, Vector, VectorLike, Location, Axis, Plane
|
||||
|
||||
from .shape_core import Shape, ShapeList, downcast, shapetype
|
||||
|
||||
|
|
@ -168,6 +168,45 @@ class Vertex(Shape[TopoDS_Vertex]):
|
|||
"""extrude - invalid operation for Vertex"""
|
||||
raise NotImplementedError("Vertices can't be created by extrusion")
|
||||
|
||||
def intersect(
|
||||
self, *to_intersect: Shape | Vector | Location | Axis | Plane
|
||||
) -> ShapeList[Vertex] | None:
|
||||
"""Intersection of vertex and geometric objects or shapes.
|
||||
|
||||
Args:
|
||||
to_intersect (sequence of [Shape | Vector | Location | Axis | Plane]):
|
||||
Objects(s) to intersect with
|
||||
|
||||
Returns:
|
||||
ShapeList[Vertex] | None: Vertex intersection in a ShapeList or None
|
||||
"""
|
||||
common = Vector(self)
|
||||
result: Shape | ShapeList[Shape] | Vector | None
|
||||
for obj in to_intersect:
|
||||
# Treat as Vector, otherwise call intersection from Shape
|
||||
match obj:
|
||||
case Vertex():
|
||||
result = common.intersect(Vector(obj))
|
||||
case Vector() | Location() | Axis() | Plane():
|
||||
result = obj.intersect(common)
|
||||
case _ if issubclass(type(obj), Shape):
|
||||
result = obj.intersect(self)
|
||||
case _:
|
||||
raise ValueError(f"Unsupported type to_intersect:: {type(obj)}")
|
||||
|
||||
if isinstance(result, Vector) and result == common:
|
||||
pass
|
||||
elif (
|
||||
isinstance(result, list)
|
||||
and len(result) == 1
|
||||
and Vector(result[0]) == common
|
||||
):
|
||||
pass
|
||||
else:
|
||||
return None
|
||||
|
||||
return ShapeList([self])
|
||||
|
||||
# ---- Instance Methods ----
|
||||
|
||||
def __add__( # type: ignore
|
||||
|
|
|
|||
106
tests/test_airfoil.py
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
import pytest
|
||||
import numpy as np
|
||||
from build123d import Airfoil, Vector, Edge, Wire
|
||||
|
||||
|
||||
# --- parse_naca4 tests ------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"code, expected",
|
||||
[
|
||||
("2412", (0.02, 0.4, 0.12)), # standard NACA 2412
|
||||
("0012", (0.0, 0.0, 0.12)), # symmetric section
|
||||
("2213.323", (0.02, 0.2, 0.13323)), # fractional thickness
|
||||
("NACA2412", (0.02, 0.4, 0.12)), # with prefix
|
||||
],
|
||||
)
|
||||
def test_parse_naca4_variants(code, expected):
|
||||
m, p, t = Airfoil.parse_naca4(code)
|
||||
np.testing.assert_allclose([m, p, t], expected, rtol=1e-6)
|
||||
|
||||
|
||||
# --- basic construction tests -----------------------------------------------
|
||||
|
||||
|
||||
def test_airfoil_basic_construction():
|
||||
airfoil = Airfoil("2412", n_points=40)
|
||||
assert isinstance(airfoil, Airfoil)
|
||||
assert isinstance(airfoil.camber_line, Edge)
|
||||
assert isinstance(airfoil._camber_points, list)
|
||||
assert all(isinstance(p, Vector) for p in airfoil._camber_points)
|
||||
|
||||
# Check metadata
|
||||
assert airfoil.code == "2412"
|
||||
assert pytest.approx(airfoil.max_camber, rel=1e-6) == 0.02
|
||||
assert pytest.approx(airfoil.camber_pos, rel=1e-6) == 0.4
|
||||
assert pytest.approx(airfoil.thickness, rel=1e-6) == 0.12
|
||||
assert airfoil.finite_te is False
|
||||
|
||||
|
||||
def test_airfoil_finite_te_profile():
|
||||
"""Finite trailing edge version should have a line closing the profile."""
|
||||
airfoil = Airfoil("2412", finite_te=True, n_points=40)
|
||||
assert isinstance(airfoil, Wire)
|
||||
assert airfoil.finite_te
|
||||
assert len(list(airfoil.edges())) == 2
|
||||
|
||||
|
||||
def test_airfoil_infinite_te_profile():
|
||||
"""Infinite trailing edge (periodic spline)."""
|
||||
airfoil = Airfoil("2412", finite_te=False, n_points=40)
|
||||
assert isinstance(airfoil, Wire)
|
||||
# Should contain a single closed Edge
|
||||
assert len(airfoil.edges()) == 1
|
||||
assert airfoil.edges()[0].is_closed
|
||||
|
||||
|
||||
# --- geometric / numerical validity -----------------------------------------
|
||||
|
||||
|
||||
def test_camber_line_geometry_monotonic():
|
||||
"""Camber x coordinates should increase monotonically along the chord."""
|
||||
af = Airfoil("2412", n_points=80)
|
||||
x_coords = [p.X for p in af._camber_points]
|
||||
assert np.all(np.diff(x_coords) >= 0)
|
||||
|
||||
|
||||
def test_airfoil_chord_limits():
|
||||
"""Airfoil should be bounded between x=0 and x=1."""
|
||||
af = Airfoil("2412", n_points=100)
|
||||
all_points = af._camber_points
|
||||
xs = np.array([p.X for p in all_points])
|
||||
assert xs.min() >= -1e-9
|
||||
assert xs.max() <= 1.0 + 1e-9
|
||||
|
||||
|
||||
def test_airfoil_thickness_scaling():
|
||||
"""Check that airfoil thickness scales linearly with NACA last two digits."""
|
||||
af1 = Airfoil("0010", n_points=120)
|
||||
af2 = Airfoil("0020", n_points=120)
|
||||
|
||||
# Extract main surface edge (for finite_te=False it's just one edge)
|
||||
edge1 = af1.edges()[0]
|
||||
edge2 = af2.edges()[0]
|
||||
|
||||
# Sample many points along each edge
|
||||
n = 500
|
||||
ys1 = [(edge1 @ u).Y for u in np.linspace(0.0, 1.0, n)]
|
||||
ys2 = [(edge2 @ u).Y for u in np.linspace(0.0, 1.0, n)]
|
||||
|
||||
# Total height (max - min)
|
||||
h1 = max(ys1) - min(ys1)
|
||||
h2 = max(ys2) - min(ys2)
|
||||
|
||||
# For symmetric NACA 00xx, thickness is proportional to 't'
|
||||
assert (h1 / h2) == pytest.approx(0.5, rel=0.05)
|
||||
|
||||
|
||||
def test_camber_line_is_centered():
|
||||
"""Mean of upper and lower surfaces should approximate camber line."""
|
||||
af = Airfoil("2412", n_points=50)
|
||||
# Extract central camber Y near mid-chord
|
||||
mid_index = len(af._camber_points) // 2
|
||||
mid_point = af._camber_points[mid_index]
|
||||
# Camber line should be roughly symmetric around y=0 for small m
|
||||
assert abs(mid_point.Y) < 0.05
|
||||
517
tests/test_direct_api/test_constrained_arcs.py
Normal file
|
|
@ -0,0 +1,517 @@
|
|||
"""
|
||||
build123d tests
|
||||
|
||||
name: test_constrained_arcs.py
|
||||
by: Gumyr
|
||||
date: September 12, 2025
|
||||
|
||||
desc:
|
||||
This python module contains tests for the build123d project.
|
||||
|
||||
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 (
|
||||
CenterArc,
|
||||
Line,
|
||||
PolarLine,
|
||||
JernArc,
|
||||
IntersectingLine,
|
||||
ThreePointArc,
|
||||
)
|
||||
from build123d.operations_generic import mirror
|
||||
from build123d.topology import (
|
||||
Edge,
|
||||
Face,
|
||||
Solid,
|
||||
Vertex,
|
||||
Wire,
|
||||
topo_explore_common_vertex,
|
||||
)
|
||||
from build123d.geometry import Axis, Plane, Vector, TOLERANCE
|
||||
from build123d.build_enums import Tangency, Sagitta, LengthMode
|
||||
from build123d.topology.constrained_lines import (
|
||||
_as_gcc_arg,
|
||||
_param_in_trim,
|
||||
_edge_to_qualified_2d,
|
||||
_two_arc_edges_from_params,
|
||||
)
|
||||
from OCP.gp import gp_Ax2d, gp_Dir2d, gp_Circ2d, gp_Pnt2d
|
||||
|
||||
|
||||
def test_edge_to_qualified_2d():
|
||||
e = Line((0, 0), (1, 0))
|
||||
e.position += (1, 1, 1)
|
||||
qc, curve_2d, first, last, adaptor = _edge_to_qualified_2d(
|
||||
e.wrapped, Tangency.UNQUALIFIED
|
||||
)
|
||||
assert first < last
|
||||
|
||||
|
||||
def test_two_arc_edges_from_params():
|
||||
circle = gp_Circ2d(gp_Ax2d(gp_Pnt2d(0, 0), gp_Dir2d(1.0, 0.0)), 1)
|
||||
arcs = _two_arc_edges_from_params(circle, 0, TOLERANCE / 10)
|
||||
assert len(arcs) == 0
|
||||
|
||||
|
||||
def test_param_in_trim():
|
||||
with pytest.raises(TypeError) as excinfo:
|
||||
_param_in_trim(None, 0.0, 1.0, None)
|
||||
assert "Invalid parameters to _param_in_trim" in str(excinfo.value)
|
||||
|
||||
|
||||
def test_as_gcc_arg():
|
||||
e = Line((0, 0), (1, 0))
|
||||
e.wrapped = None
|
||||
with pytest.raises(TypeError) as excinfo:
|
||||
_as_gcc_arg(e, Tangency.UNQUALIFIED)
|
||||
assert "Can't create a qualified curve from empty edge" in str(excinfo.value)
|
||||
|
||||
|
||||
def test_constrained_arcs_arg_processing():
|
||||
"""Test input error handling"""
|
||||
with pytest.raises(TypeError):
|
||||
Edge.make_constrained_arcs(Solid.make_box(1, 1, 1), (1, 0), radius=0.5)
|
||||
with pytest.raises(TypeError):
|
||||
Edge.make_constrained_arcs(
|
||||
(Vector(0, 0), Tangency.UNQUALIFIED), (1, 0), radius=0.5
|
||||
)
|
||||
with pytest.raises(TypeError):
|
||||
Edge.make_constrained_arcs(pnt1=(1, 1, 1), pnt2=(1, 0), radius=0.5)
|
||||
with pytest.raises(TypeError):
|
||||
Edge.make_constrained_arcs(radius=0.1)
|
||||
with pytest.raises(ValueError):
|
||||
Edge.make_constrained_arcs((0, 0), (0, 0.5), radius=0.5, center=(0, 0.25))
|
||||
with pytest.raises(ValueError):
|
||||
Edge.make_constrained_arcs((0, 0), (0, 0.5), radius=-0.5)
|
||||
|
||||
|
||||
def test_tan2_rad_arcs_1():
|
||||
"""2 edges & radius"""
|
||||
e1 = Line((-2, 0), (2, 0))
|
||||
e2 = Line((0, -2), (0, 2))
|
||||
|
||||
tan2_rad_edges = Edge.make_constrained_arcs(
|
||||
e1, e2, radius=0.5, sagitta=Sagitta.BOTH
|
||||
)
|
||||
assert len(tan2_rad_edges) == 8
|
||||
|
||||
tan2_rad_edges = Edge.make_constrained_arcs(e1, e2, radius=0.5)
|
||||
assert len(tan2_rad_edges) == 4
|
||||
|
||||
tan2_rad_edges = Edge.make_constrained_arcs(
|
||||
(e1, Tangency.UNQUALIFIED), (e2, Tangency.UNQUALIFIED), radius=0.5
|
||||
)
|
||||
assert len(tan2_rad_edges) == 4
|
||||
|
||||
|
||||
def test_tan2_rad_arcs_2():
|
||||
"""2 edges & radius"""
|
||||
e1 = CenterArc((0, 0), 1, 0, 90)
|
||||
e2 = Line((1, 0), (2, 0))
|
||||
|
||||
tan2_rad_edges = Edge.make_constrained_arcs(e1, e2, radius=0.5)
|
||||
assert len(tan2_rad_edges) == 1
|
||||
|
||||
|
||||
def test_tan2_rad_arcs_3():
|
||||
"""2 points & radius"""
|
||||
tan2_rad_edges = Edge.make_constrained_arcs((0, 0), (0, 0.5), radius=0.5)
|
||||
assert len(tan2_rad_edges) == 2
|
||||
|
||||
tan2_rad_edges = Edge.make_constrained_arcs(
|
||||
Vertex(0, 0), Vertex(0, 0.5), radius=0.5
|
||||
)
|
||||
assert len(tan2_rad_edges) == 2
|
||||
|
||||
tan2_rad_edges = Edge.make_constrained_arcs(
|
||||
Vector(0, 0), Vector(0, 0.5), radius=0.5
|
||||
)
|
||||
assert len(tan2_rad_edges) == 2
|
||||
|
||||
|
||||
def test_tan2_rad_arcs_4():
|
||||
"""edge & 1 points & radius"""
|
||||
# the point should be automatically moved after the edge
|
||||
e1 = Line((0, 0), (1, 0))
|
||||
tan2_rad_edges = Edge.make_constrained_arcs((0, 0.5), e1, radius=0.5)
|
||||
assert len(tan2_rad_edges) == 1
|
||||
|
||||
|
||||
def test_tan2_rad_arcs_5():
|
||||
"""no solution"""
|
||||
with pytest.raises(RuntimeError) as excinfo:
|
||||
Edge.make_constrained_arcs((0, 0), (10, 0), radius=2)
|
||||
assert "Unable to find a tangent arc" in str(excinfo.value)
|
||||
|
||||
|
||||
def test_tan2_center_on_1():
|
||||
"""2 tangents & center on"""
|
||||
c1 = PolarLine((0, 0), 4, -20, length_mode=LengthMode.HORIZONTAL)
|
||||
c2 = Line((4, -2), (4, 2))
|
||||
c3_center_on = Line((3, -2), (3, 2))
|
||||
tan2_on_edge = Edge.make_constrained_arcs(
|
||||
(c1, Tangency.UNQUALIFIED),
|
||||
(c2, Tangency.UNQUALIFIED),
|
||||
center_on=c3_center_on,
|
||||
)
|
||||
assert len(tan2_on_edge) == 1
|
||||
|
||||
|
||||
def test_tan2_center_on_2():
|
||||
"""2 tangents & center on"""
|
||||
tan2_on_edge = Edge.make_constrained_arcs(
|
||||
(0, 3), (5, 0), center_on=Line((0, -5), (0, 5))
|
||||
)
|
||||
assert len(tan2_on_edge) == 1
|
||||
|
||||
|
||||
def test_tan2_center_on_3():
|
||||
"""2 tangents & center on"""
|
||||
tan2_on_edge = Edge.make_constrained_arcs(
|
||||
Line((-5, 3), (5, 3)), (5, 0), center_on=Line((0, -5), (0, 5))
|
||||
)
|
||||
assert len(tan2_on_edge) == 1
|
||||
|
||||
|
||||
def test_tan2_center_on_4():
|
||||
"""2 tangents & center on"""
|
||||
tan2_on_edge = Edge.make_constrained_arcs(
|
||||
Line((-5, 3), (5, 3)), (5, 0), center_on=Axis.Y
|
||||
)
|
||||
assert len(tan2_on_edge) == 1
|
||||
|
||||
|
||||
def test_tan2_center_on_5():
|
||||
"""2 tangents & center on"""
|
||||
with pytest.raises(RuntimeError) as excinfo:
|
||||
Edge.make_constrained_arcs(
|
||||
Line((-5, 3), (5, 3)),
|
||||
Line((-5, 0), (5, 0)),
|
||||
center_on=Line((-5, -1), (5, -1)),
|
||||
)
|
||||
assert "Unable to find a tangent arc with center_on constraint" in str(
|
||||
excinfo.value
|
||||
)
|
||||
|
||||
|
||||
def test_tan2_center_on_6():
|
||||
"""2 tangents & center on"""
|
||||
l1 = Line((0, 0), (5, 0))
|
||||
l2 = Line((0, 0), (0, 5))
|
||||
l3 = Line((20, 20), (22, 22))
|
||||
with pytest.raises(RuntimeError) as excinfo:
|
||||
Edge.make_constrained_arcs(l1, l2, center_on=l3)
|
||||
assert "Unable to find a tangent arc with center_on constraint" in str(
|
||||
excinfo.value
|
||||
)
|
||||
|
||||
|
||||
# --- Sagitta selection branches ---
|
||||
|
||||
|
||||
def test_tan2_center_on_sagitta_both_returns_two_arcs():
|
||||
"""
|
||||
TWO lines, center_on a line that crosses *both* angle bisectors → multiple
|
||||
circle solutions; with Sagitta.BOTH we should get 2 arcs per solution.
|
||||
Setup: x-axis & y-axis; center_on y=1.
|
||||
"""
|
||||
c1 = Line((-10, 0), (10, 0)) # y = 0
|
||||
c2 = Line((0, -10), (0, 10)) # x = 0
|
||||
center_on = Line((-10, 1), (10, 1)) # y = 1
|
||||
|
||||
arcs = Edge.make_constrained_arcs(
|
||||
(c1, Tangency.UNQUALIFIED),
|
||||
(c2, Tangency.UNQUALIFIED),
|
||||
center_on=center_on,
|
||||
sagitta=Sagitta.BOTH,
|
||||
)
|
||||
# Expect 2 solutions (centers at (1,1) and (-1,1)), each yielding 2 arcs → 4
|
||||
assert len(arcs) >= 2 # be permissive across kernels; typically 4
|
||||
# At least confirms BOTH path is covered and multiple solutions iterate
|
||||
|
||||
|
||||
def test_tan2_center_on_sagitta_long_is_longer_than_short():
|
||||
"""
|
||||
Verify LONG branch by comparing lengths against SHORT for the same geometry.
|
||||
"""
|
||||
c1 = Line((-10, 0), (10, 0)) # y = 0
|
||||
c2 = Line((0, -10), (0, 10)) # x = 0
|
||||
center_on = Line((3, -10), (3, 10)) # x = 3 (unique center)
|
||||
|
||||
short_arc = Edge.make_constrained_arcs(
|
||||
(c1, Tangency.UNQUALIFIED),
|
||||
(c2, Tangency.UNQUALIFIED),
|
||||
center_on=center_on,
|
||||
sagitta=Sagitta.SHORT,
|
||||
)
|
||||
long_arc = Edge.make_constrained_arcs(
|
||||
(c1, Tangency.UNQUALIFIED),
|
||||
(c2, Tangency.UNQUALIFIED),
|
||||
center_on=center_on,
|
||||
sagitta=Sagitta.LONG,
|
||||
)
|
||||
assert len(short_arc) == 2
|
||||
assert len(long_arc) == 2
|
||||
assert long_arc[0].length > short_arc[0].length
|
||||
|
||||
|
||||
# --- Filtering branches inside the Solutions loop ---
|
||||
|
||||
|
||||
def test_tan2_center_on_filters_outside_first_tangent_segment():
|
||||
"""
|
||||
Cause _ok(0, u_arg1) to fail:
|
||||
- First tangency is a *very short* horizontal segment near x∈[0, 0.01].
|
||||
- Second tangency is a vertical line far away.
|
||||
- Center_on is x=5 (vertical).
|
||||
The resulting tangency on the infinite horizontal line occurs near x≈center.x (≈5),
|
||||
which lies *outside* the trimmed first segment → filtered out, no arcs.
|
||||
"""
|
||||
tiny_first = Line((0.0, 0.0), (0.01, 0.0)) # very short horizontal
|
||||
c2 = Line((10.0, -10.0), (10.0, 10.0)) # vertical line
|
||||
center_on = Line((5.0, -10.0), (5.0, 10.0)) # x = 5
|
||||
|
||||
arcs = Edge.make_constrained_arcs(
|
||||
(tiny_first, Tangency.UNQUALIFIED),
|
||||
(c2, Tangency.UNQUALIFIED),
|
||||
center_on=center_on,
|
||||
sagitta=Sagitta.SHORT,
|
||||
)
|
||||
# GCC likely finds solutions, but they should be filtered out by _ok(0)
|
||||
assert len(arcs) == 0
|
||||
|
||||
|
||||
def test_tan2_center_on_filters_outside_second_tangent_segment():
|
||||
"""
|
||||
Cause _ok(1, u_arg2) to fail:
|
||||
- First tangency is a *point* (so _ok(0) is trivially True).
|
||||
- Second tangency is a *very short* vertical segment around y≈0 on x=10.
|
||||
- Center_on is y=2 (horizontal), and first point is at (0,2).
|
||||
For a circle through (0,2) and tangent to x=10 with center_on y=2,
|
||||
the center is at (5,2), radius=5, so tangency on x=10 occurs at y=2,
|
||||
which is *outside* the tiny segment around y≈0 → filtered by _ok(1).
|
||||
"""
|
||||
first_point = (0.0, 2.0) # acts as a "point object"
|
||||
tiny_second = Line((10.0, -0.005), (10.0, 0.005)) # very short vertical near y=0
|
||||
center_on = Line((-10.0, 2.0), (10.0, 2.0)) # y = 2
|
||||
|
||||
arcs = Edge.make_constrained_arcs(
|
||||
first_point,
|
||||
(tiny_second, Tangency.UNQUALIFIED),
|
||||
center_on=center_on,
|
||||
sagitta=Sagitta.SHORT,
|
||||
)
|
||||
assert len(arcs) == 0
|
||||
|
||||
|
||||
# --- Multiple-solution loop coverage with BOTH again (robust geometry) ---
|
||||
|
||||
|
||||
def test_tan2_center_on_multiple_solutions_both_counts():
|
||||
"""
|
||||
Another geometry with 2+ GCC solutions:
|
||||
c1: y=0, c2: y=4 (two non-intersecting parallels), center_on x=0.
|
||||
Any circle tangent to both has radius=2 and center on y=2; with center_on x=0,
|
||||
the center fixes at (0,2) — single center → two arcs (BOTH).
|
||||
Use intersecting lines instead to guarantee >1 solutions: c1: y=0, c2: x=0,
|
||||
center_on y=-2 (intersects both angle bisectors at (-2,-2) and (2,-2)).
|
||||
"""
|
||||
c1 = Line((-20, 0), (20, 0)) # y = 0
|
||||
c2 = Line((0, -20), (0, 20)) # x = 0
|
||||
center_on = Line((-20, -2), (20, -2)) # y = -2
|
||||
|
||||
arcs = Edge.make_constrained_arcs(
|
||||
(c1, Tangency.UNQUALIFIED),
|
||||
(c2, Tangency.UNQUALIFIED),
|
||||
center_on=center_on,
|
||||
sagitta=Sagitta.BOTH,
|
||||
)
|
||||
# Expect at least 2 arcs (often 4); asserts loop over multiple i values
|
||||
assert len(arcs) >= 2
|
||||
|
||||
|
||||
def test_tan_center_on_1():
|
||||
"""1 tangent & center on"""
|
||||
c5 = PolarLine((0, 0), 4, 60)
|
||||
tan_center = Edge.make_constrained_arcs((c5, Tangency.UNQUALIFIED), center=(2, 1))
|
||||
assert len(tan_center) == 1
|
||||
assert tan_center[0].is_closed
|
||||
|
||||
|
||||
def test_tan_center_on_2():
|
||||
"""1 tangent & center on"""
|
||||
tan_center = Edge.make_constrained_arcs(Axis.X, center=(2, 1, 5))
|
||||
assert len(tan_center) == 1
|
||||
assert tan_center[0].is_closed
|
||||
|
||||
|
||||
def test_tan_center_on_3():
|
||||
"""1 tangent & center on"""
|
||||
l1 = CenterArc((0, 0), 1, 180, 5)
|
||||
tan_center = Edge.make_constrained_arcs(l1, center=(2, 0))
|
||||
assert len(tan_center) == 1
|
||||
assert tan_center[0].is_closed
|
||||
|
||||
|
||||
def test_pnt_center_1():
|
||||
"""pnt & center"""
|
||||
pnt_center = Edge.make_constrained_arcs((-2.5, 1.5), center=(-2, 1))
|
||||
assert len(pnt_center) == 1
|
||||
assert pnt_center[0].is_closed
|
||||
|
||||
pnt_center = Edge.make_constrained_arcs((-2.5, 1.5), center=Vertex(-2, 1))
|
||||
assert len(pnt_center) == 1
|
||||
assert pnt_center[0].is_closed
|
||||
|
||||
|
||||
def test_tan_cen_arcs_center_equals_point_returns_empty():
|
||||
"""
|
||||
If the fixed center coincides with the tangency point,
|
||||
the computed radius is zero and no valid circle exists.
|
||||
Function should return an empty ShapeList.
|
||||
"""
|
||||
center = (0, 0)
|
||||
tangency_point = (0, 0) # same as center
|
||||
|
||||
arcs = Edge.make_constrained_arcs(tangency_point, center=center)
|
||||
|
||||
assert isinstance(arcs, list) # ShapeList subclass
|
||||
assert len(arcs) == 0
|
||||
|
||||
|
||||
def test_tan_rad_center_on_1():
|
||||
"""tangent, radius, center on"""
|
||||
c1 = PolarLine((0, 0), 4, -20, length_mode=LengthMode.HORIZONTAL)
|
||||
c3_center_on = Line((3, -2), (3, 2))
|
||||
tan_rad_on = Edge.make_constrained_arcs(
|
||||
(c1, Tangency.UNQUALIFIED), radius=1, center_on=c3_center_on
|
||||
)
|
||||
assert len(tan_rad_on) == 1
|
||||
assert tan_rad_on[0].is_closed
|
||||
|
||||
|
||||
def test_tan_rad_center_on_2():
|
||||
"""tangent, radius, center on"""
|
||||
c1 = PolarLine((0, 0), 4, -20, length_mode=LengthMode.HORIZONTAL)
|
||||
tan_rad_on = Edge.make_constrained_arcs(c1, radius=1, center_on=Axis.X)
|
||||
assert len(tan_rad_on) == 1
|
||||
assert tan_rad_on[0].is_closed
|
||||
|
||||
|
||||
def test_tan_rad_center_on_3():
|
||||
"""tangent, radius, center on"""
|
||||
c1 = PolarLine((0, 0), 4, -20, length_mode=LengthMode.HORIZONTAL)
|
||||
with pytest.raises(TypeError) as excinfo:
|
||||
Edge.make_constrained_arcs(c1, radius=1, center_on=Face.make_rect(1, 1))
|
||||
|
||||
|
||||
def test_tan_rad_center_on_4():
|
||||
"""tangent, radius, center on"""
|
||||
c1 = Line((0, 10), (10, 10))
|
||||
with pytest.raises(RuntimeError) as excinfo:
|
||||
Edge.make_constrained_arcs(c1, radius=1, center_on=Axis.X)
|
||||
|
||||
|
||||
def test_tan3_1():
|
||||
"""3 tangents"""
|
||||
c5 = PolarLine((0, 0), 4, 60)
|
||||
c6 = PolarLine((0, 0), 4, 40)
|
||||
c7 = CenterArc((0, 0), 4, 0, 90)
|
||||
tan3 = Edge.make_constrained_arcs(
|
||||
(c5, Tangency.UNQUALIFIED),
|
||||
(c6, Tangency.UNQUALIFIED),
|
||||
(c7, Tangency.UNQUALIFIED),
|
||||
)
|
||||
assert len(tan3) == 1
|
||||
assert not tan3[0].is_closed
|
||||
|
||||
tan3b = Edge.make_constrained_arcs(c5, c6, c7, sagitta=Sagitta.BOTH)
|
||||
assert len(tan3b) == 2
|
||||
|
||||
|
||||
def test_tan3_2():
|
||||
with pytest.raises(RuntimeError) as excinfo:
|
||||
Edge.make_constrained_arcs(
|
||||
Line((0, 0), (0, 1)),
|
||||
Line((0, 0), (1, 0)),
|
||||
Line((0, 0), (0, -1)),
|
||||
)
|
||||
assert "Unable to find a circle tangent to all three objects" in str(excinfo.value)
|
||||
|
||||
|
||||
def test_tan3_3():
|
||||
l1 = Line((0, 0), (10, 0))
|
||||
l2 = Line((0, 2), (10, 2))
|
||||
l3 = Line((0, 5), (10, 5))
|
||||
with pytest.raises(RuntimeError) as excinfo:
|
||||
Edge.make_constrained_arcs(l1, l2, l3)
|
||||
assert "Unable to find a circle tangent to all three objects" in str(excinfo.value)
|
||||
|
||||
|
||||
def test_tan3_4():
|
||||
l1 = Line((-1, 0), (-1, 2))
|
||||
l2 = Line((1, 0), (1, 2))
|
||||
l3 = Line((-1, 0), (-0.75, 0))
|
||||
tan3 = Edge.make_constrained_arcs(l1, l2, l3)
|
||||
assert len(tan3) == 0
|
||||
|
||||
|
||||
def test_eggplant():
|
||||
"""complex set of 4 arcs"""
|
||||
r_left, r_right = 0.75, 1.0
|
||||
r_bottom, r_top = 6, 8
|
||||
con_circle_left = CenterArc((-2, 0), r_left, 0, 360)
|
||||
con_circle_right = CenterArc((2, 0), r_right, 0, 360)
|
||||
egg_bottom = Edge.make_constrained_arcs(
|
||||
(con_circle_right, Tangency.OUTSIDE),
|
||||
(con_circle_left, Tangency.OUTSIDE),
|
||||
radius=r_bottom,
|
||||
).sort_by(Axis.Y)[0]
|
||||
egg_top = Edge.make_constrained_arcs(
|
||||
(con_circle_right, Tangency.ENCLOSING),
|
||||
(con_circle_left, Tangency.ENCLOSING),
|
||||
radius=r_top,
|
||||
).sort_by(Axis.Y)[-1]
|
||||
egg_right = ThreePointArc(
|
||||
egg_bottom.vertices().sort_by(Axis.X)[-1],
|
||||
con_circle_right @ 0,
|
||||
egg_top.vertices().sort_by(Axis.X)[-1],
|
||||
)
|
||||
egg_left = ThreePointArc(
|
||||
egg_bottom.vertices().sort_by(Axis.X)[0],
|
||||
con_circle_left @ 0.5,
|
||||
egg_top.vertices().sort_by(Axis.X)[0],
|
||||
)
|
||||
|
||||
egg_plant = Wire([egg_left, egg_top, egg_right, egg_bottom])
|
||||
assert egg_plant.is_closed
|
||||
egg_plant_edges = egg_plant.edges().sort_by(egg_plant)
|
||||
common_vertex_cnt = sum(
|
||||
topo_explore_common_vertex(egg_plant_edges[i], egg_plant_edges[(i + 1) % 4])
|
||||
is not None
|
||||
for i in range(4)
|
||||
)
|
||||
assert common_vertex_cnt == 4
|
||||
|
||||
# C1 continuity
|
||||
assert all(
|
||||
(egg_plant_edges[i] % 1 - egg_plant_edges[(i + 1) % 4] % 0).length < TOLERANCE
|
||||
for i in range(4)
|
||||
)
|
||||
267
tests/test_direct_api/test_constrained_lines.py
Normal file
|
|
@ -0,0 +1,267 @@
|
|||
"""
|
||||
build123d tests
|
||||
|
||||
name: test_constrained_lines.py
|
||||
by: Gumyr
|
||||
date: October 8, 2025
|
||||
|
||||
desc:
|
||||
This python module contains tests for the build123d project.
|
||||
|
||||
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 math
|
||||
import pytest
|
||||
from OCP.gp import gp_Pnt2d, gp_Dir2d, gp_Lin2d
|
||||
from build123d import Edge, Axis, Vector, Tangency, Plane
|
||||
from build123d.topology.constrained_lines import (
|
||||
_make_2tan_lines,
|
||||
_make_tan_oriented_lines,
|
||||
_edge_from_line,
|
||||
)
|
||||
from build123d.geometry import TOLERANCE
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def unit_circle() -> Edge:
|
||||
"""A simple unit circle centered at the origin on XY."""
|
||||
return Edge.make_circle(1.0, Plane.XY)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# utility tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_edge_from_line():
|
||||
line = _edge_from_line(gp_Pnt2d(0, 0), gp_Pnt2d(1, 0))
|
||||
assert Edge(line).length == 1
|
||||
|
||||
with pytest.raises(RuntimeError) as excinfo:
|
||||
_edge_from_line(gp_Pnt2d(0, 0), gp_Pnt2d(0, 0))
|
||||
assert "Failed to build edge from line contacts" in str(excinfo.value)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _make_2tan_lines tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_two_circles_tangents(unit_circle):
|
||||
"""Tangent lines between two separated circles should yield four results."""
|
||||
c1 = unit_circle
|
||||
c2 = unit_circle.translate((3, 0, 0)) # displaced along X
|
||||
lines = _make_2tan_lines(c1, c2, edge_factory=Edge)
|
||||
# There should be 4 external/internal tangents
|
||||
assert len(lines) in (4, 2)
|
||||
for ln in lines:
|
||||
assert isinstance(ln, Edge)
|
||||
# Tangent lines should not intersect the circle interior
|
||||
dmin = c1.distance_to(ln)
|
||||
assert dmin >= -1e-6
|
||||
|
||||
|
||||
def test_two_constrained_circles_tangents1(unit_circle):
|
||||
"""Tangent lines between two separated circles should yield four results."""
|
||||
c1 = unit_circle
|
||||
c2 = unit_circle.translate((3, 0, 0)) # displaced along X
|
||||
lines = _make_2tan_lines((c1, Tangency.ENCLOSING), c2, edge_factory=Edge)
|
||||
# There should be 2 external/internal tangents
|
||||
assert len(lines) == 2
|
||||
for ln in lines:
|
||||
assert isinstance(ln, Edge)
|
||||
# Tangent lines should not intersect the circle interior
|
||||
dmin = c1.distance_to(ln)
|
||||
assert dmin >= -1e-6
|
||||
|
||||
|
||||
def test_two_constrained_circles_tangents2(unit_circle):
|
||||
"""Tangent lines between two separated circles should yield four results."""
|
||||
c1 = unit_circle
|
||||
c2 = unit_circle.translate((3, 0, 0)) # displaced along X
|
||||
lines = _make_2tan_lines(
|
||||
(c1, Tangency.ENCLOSING), (c2, Tangency.ENCLOSING), edge_factory=Edge
|
||||
)
|
||||
# There should be 1 external/external tangents
|
||||
assert len(lines) == 1
|
||||
for ln in lines:
|
||||
assert isinstance(ln, Edge)
|
||||
# Tangent lines should not intersect the circle interior
|
||||
dmin = c1.distance_to(ln)
|
||||
assert dmin >= -1e-6
|
||||
|
||||
|
||||
def test_curve_and_point_tangent(unit_circle):
|
||||
"""A line tangent to a circle and passing through a point should exist."""
|
||||
pt = Vector(2.0, 0.0)
|
||||
lines = _make_2tan_lines(unit_circle, pt, edge_factory=Edge)
|
||||
assert len(lines) == 2
|
||||
for ln in lines:
|
||||
# The line must pass through the given point (approximately)
|
||||
dist_to_point = ln.distance_to(pt)
|
||||
assert math.isclose(dist_to_point, 0.0, abs_tol=1e-6)
|
||||
# It should also touch the circle at exactly one point
|
||||
dist_to_circle = unit_circle.distance_to(ln)
|
||||
assert math.isclose(dist_to_circle, 0.0, abs_tol=TOLERANCE)
|
||||
|
||||
|
||||
def test_invalid_tangent_raises(unit_circle):
|
||||
"""Non-intersecting degenerate input result in no output."""
|
||||
lines = _make_2tan_lines(unit_circle, unit_circle, edge_factory=Edge)
|
||||
assert len(lines) == 0
|
||||
|
||||
with pytest.raises(RuntimeError) as excinfo:
|
||||
_make_2tan_lines(unit_circle, Vector(0, 0), edge_factory=Edge)
|
||||
assert "Unable to find common tangent line(s)" in str(excinfo.value)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _make_tan_oriented_lines tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize("angle_deg", [math.radians(30), -math.radians(30)])
|
||||
def test_oriented_tangents_with_x_axis(unit_circle, angle_deg):
|
||||
"""Lines tangent to a circle at ±30° from the X-axis."""
|
||||
lines = _make_tan_oriented_lines(unit_circle, Axis.X, angle_deg, edge_factory=Edge)
|
||||
assert all(isinstance(e, Edge) for e in lines)
|
||||
# The tangent lines should all intersect the X axis (red line)
|
||||
for ln in lines:
|
||||
p = ln.position_at(0.5)
|
||||
assert abs(p.Z) < 1e-9
|
||||
|
||||
lines = _make_tan_oriented_lines(unit_circle, Axis.X, 0, edge_factory=Edge)
|
||||
assert len(lines) == 0
|
||||
|
||||
lines = _make_tan_oriented_lines(
|
||||
unit_circle, Axis((0, -2), (1, 0)), 0, edge_factory=Edge
|
||||
)
|
||||
assert len(lines) == 0
|
||||
|
||||
|
||||
def test_oriented_tangents_with_y_axis(unit_circle):
|
||||
"""Lines tangent to a circle and 30° from Y-axis should exist."""
|
||||
angle = math.radians(30)
|
||||
lines = _make_tan_oriented_lines(unit_circle, Axis.Y, angle, edge_factory=Edge)
|
||||
assert len(lines) >= 1
|
||||
# They should roughly touch the circle (tangent distance ≈ 0)
|
||||
for ln in lines:
|
||||
assert unit_circle.distance_to(ln) < 1e-6
|
||||
|
||||
|
||||
def test_oriented_constrained_tangents_with_y_axis(unit_circle):
|
||||
angle = math.radians(30)
|
||||
lines = _make_tan_oriented_lines(
|
||||
(unit_circle, Tangency.ENCLOSING), Axis.Y, angle, edge_factory=Edge
|
||||
)
|
||||
assert len(lines) == 1
|
||||
for ln in lines:
|
||||
assert unit_circle.distance_to(ln) < 1e-6
|
||||
|
||||
|
||||
def test_invalid_oriented_tangent_raises(unit_circle):
|
||||
"""Non-intersecting degenerate input result in no output."""
|
||||
|
||||
with pytest.raises(ValueError) as excinfo:
|
||||
_make_tan_oriented_lines(unit_circle, Axis.Z, 1, edge_factory=Edge)
|
||||
assert "reference Axis can't be perpendicular to Plane.XY" in str(excinfo.value)
|
||||
|
||||
with pytest.raises(ValueError) as excinfo:
|
||||
_make_tan_oriented_lines(
|
||||
unit_circle, Axis((1, 2, 3), (0, 0, -1)), 1, edge_factory=Edge
|
||||
)
|
||||
assert "reference Axis can't be perpendicular to Plane.XY" in str(excinfo.value)
|
||||
|
||||
|
||||
def test_invalid_oriented_tangent(unit_circle):
|
||||
lines = _make_tan_oriented_lines(
|
||||
unit_circle, Axis((1, 0), (0, 1)), 0, edge_factory=Edge
|
||||
)
|
||||
assert len(lines) == 0
|
||||
|
||||
lines = _make_tan_oriented_lines(
|
||||
unit_circle.translate((0, 1 + 1e-7)), Axis.X, 0, edge_factory=Edge
|
||||
)
|
||||
assert len(lines) == 0
|
||||
|
||||
|
||||
def test_make_constrained_lines0(unit_circle):
|
||||
lines = Edge.make_constrained_lines(unit_circle, unit_circle.translate((3, 0, 0)))
|
||||
assert len(lines) == 4
|
||||
for ln in lines:
|
||||
assert unit_circle.distance_to(ln) < 1e-6
|
||||
|
||||
|
||||
def test_make_constrained_lines1(unit_circle):
|
||||
lines = Edge.make_constrained_lines(unit_circle, (3, 0))
|
||||
assert len(lines) == 2
|
||||
for ln in lines:
|
||||
assert unit_circle.distance_to(ln) < 1e-6
|
||||
|
||||
|
||||
def test_make_constrained_lines3(unit_circle):
|
||||
lines = Edge.make_constrained_lines(unit_circle, Axis.X, angle=30)
|
||||
assert len(lines) == 2
|
||||
for ln in lines:
|
||||
assert unit_circle.distance_to(ln) < 1e-6
|
||||
assert abs((ln @ 1).Y) < 1e-6
|
||||
|
||||
|
||||
def test_make_constrained_lines4(unit_circle):
|
||||
lines = Edge.make_constrained_lines(unit_circle, Axis.Y, angle=30)
|
||||
assert len(lines) == 2
|
||||
for ln in lines:
|
||||
assert unit_circle.distance_to(ln) < 1e-6
|
||||
assert abs((ln @ 1).X) < 1e-6
|
||||
|
||||
|
||||
def test_make_constrained_lines5(unit_circle):
|
||||
lines = Edge.make_constrained_lines(
|
||||
(unit_circle, Tangency.ENCLOSING), Axis.Y, angle=30
|
||||
)
|
||||
assert len(lines) == 1
|
||||
for ln in lines:
|
||||
assert unit_circle.distance_to(ln) < 1e-6
|
||||
|
||||
|
||||
def test_make_constrained_lines6(unit_circle):
|
||||
lines = Edge.make_constrained_lines(
|
||||
(unit_circle, Tangency.ENCLOSING), Axis.Y, direction=(1, 1)
|
||||
)
|
||||
assert len(lines) == 1
|
||||
for ln in lines:
|
||||
assert unit_circle.distance_to(ln) < 1e-6
|
||||
|
||||
|
||||
def test_make_constrained_lines_raises(unit_circle):
|
||||
with pytest.raises(TypeError) as excinfo:
|
||||
Edge.make_constrained_lines(unit_circle, Axis.Z, ref_angle=1)
|
||||
assert "Unexpected argument(s): ref_angle" in str(excinfo.value)
|
||||
|
||||
with pytest.raises(TypeError) as excinfo:
|
||||
Edge.make_constrained_lines(unit_circle)
|
||||
assert "Provide exactly 2 tangency targets." in str(excinfo.value)
|
||||
|
||||
with pytest.raises(RuntimeError) as excinfo:
|
||||
Edge.make_constrained_lines(Axis.X, Axis.Y)
|
||||
assert "Unable to find common tangent line(s)" in str(excinfo.value)
|
||||
|
||||
with pytest.raises(TypeError) as excinfo:
|
||||
Edge.make_constrained_lines(unit_circle, ("three", 0))
|
||||
assert "Invalid tangency:" in str(excinfo.value)
|
||||
|
|
@ -37,7 +37,7 @@ from build123d.geometry import Axis, Plane, Vector
|
|||
from build123d.objects_curve import CenterArc, EllipticalCenterArc
|
||||
from build123d.objects_sketch import Circle, Rectangle, RegularPolygon
|
||||
from build123d.operations_generic import sweep
|
||||
from build123d.topology import Edge, Face, Wire
|
||||
from build123d.topology import Edge, Face, Wire, Vertex
|
||||
from OCP.GeomProjLib import GeomProjLib
|
||||
|
||||
|
||||
|
|
@ -183,8 +183,23 @@ class TestEdge(unittest.TestCase):
|
|||
line = Edge.make_line((-2, 0), (2, 0))
|
||||
self.assertAlmostEqual(line.trim(0.25, 0.75).position_at(0), (-1, 0, 0), 5)
|
||||
self.assertAlmostEqual(line.trim(0.25, 0.75).position_at(1), (1, 0, 0), 5)
|
||||
with self.assertRaises(ValueError):
|
||||
line.trim(0.75, 0.25)
|
||||
|
||||
l1 = CenterArc((0, 0), 1, 0, 180)
|
||||
l2 = l1.trim(0, l1 @ 0.5)
|
||||
self.assertAlmostEqual(l2 @ 0, (1, 0, 0), 5)
|
||||
self.assertAlmostEqual(l2 @ 1, (0, 1, 0), 5)
|
||||
|
||||
l3 = l1.trim((1, 0), (0, 1))
|
||||
self.assertAlmostEqual(l3 @ 0, (1, 0, 0), 5)
|
||||
self.assertAlmostEqual(l3 @ 1, (0, 1, 0), 5)
|
||||
|
||||
l4 = l1.trim(0.5, (-1, 0))
|
||||
self.assertAlmostEqual(l4 @ 0, (0, 1, 0), 5)
|
||||
self.assertAlmostEqual(l4 @ 1, (-1, 0, 0), 5)
|
||||
|
||||
l5 = l1.trim(0.5, Vertex(-1, 0))
|
||||
self.assertAlmostEqual(l5 @ 0, (0, 1, 0), 5)
|
||||
self.assertAlmostEqual(l5 @ 1, (-1, 0, 0), 5)
|
||||
|
||||
line.wrapped = None
|
||||
with self.assertRaises(ValueError):
|
||||
|
|
@ -213,6 +228,10 @@ class TestEdge(unittest.TestCase):
|
|||
e4_trim = Edge(a4).trim_to_length(0.5, 2)
|
||||
self.assertAlmostEqual(e4_trim.length, 2, 5)
|
||||
|
||||
e5 = e1.trim_to_length((5, 5), 1)
|
||||
self.assertAlmostEqual(e5 @ 0, (5, 5), 5)
|
||||
self.assertAlmostEqual(e5.length, 1, 5)
|
||||
|
||||
e1.wrapped = None
|
||||
with self.assertRaises(ValueError):
|
||||
e1.trim_to_length(0.1, 2)
|
||||
|
|
|
|||
|
|
@ -31,9 +31,11 @@ import os
|
|||
import platform
|
||||
import random
|
||||
import unittest
|
||||
from unittest.mock import PropertyMock, patch
|
||||
|
||||
from unittest.mock import patch, PropertyMock
|
||||
from OCP.Geom import Geom_RectangularTrimmedSurface
|
||||
from OCP.GeomAPI import GeomAPI_ExtremaCurveCurve
|
||||
|
||||
from build123d.build_common import Locations, PolarLocations
|
||||
from build123d.build_enums import Align, CenterOf, ContinuityLevel, GeomType
|
||||
from build123d.build_line import BuildLine
|
||||
|
|
@ -57,7 +59,6 @@ from build123d.operations_generic import fillet, offset
|
|||
from build123d.operations_part import extrude
|
||||
from build123d.operations_sketch import make_face
|
||||
from build123d.topology import Edge, Face, Shell, Solid, Wire
|
||||
from OCP.GeomAPI import GeomAPI_ExtremaCurveCurve
|
||||
|
||||
|
||||
class TestFace(unittest.TestCase):
|
||||
|
|
@ -359,6 +360,231 @@ class TestFace(unittest.TestCase):
|
|||
self.assertAlmostEqual(loc.position, (0.0, 1.0, 1.5), 5)
|
||||
self.assertAlmostEqual(loc.orientation, (0, -90, 0), 5)
|
||||
|
||||
def test_make_gordon_surface(self):
|
||||
def create_test_curves(
|
||||
num_profiles: int = 3,
|
||||
num_guides: int = 4,
|
||||
u_range: float = 1.0,
|
||||
v_range: float = 1.0,
|
||||
):
|
||||
profiles: list[Edge] = []
|
||||
guides: list[Edge] = []
|
||||
|
||||
intersection_points = [
|
||||
[(0.0, 0.0, 0.0) for _ in range(num_guides)]
|
||||
for _ in range(num_profiles)
|
||||
]
|
||||
|
||||
for i in range(num_profiles):
|
||||
for j in range(num_guides):
|
||||
u = i * u_range / (num_profiles - 1)
|
||||
v = j * v_range / (num_guides - 1)
|
||||
z = 0.2 * math.sin(u * math.pi) * math.cos(v * math.pi)
|
||||
intersection_points[i][j] = (u, v, z)
|
||||
|
||||
for i in range(num_profiles):
|
||||
points = [intersection_points[i][j] for j in range(num_guides)]
|
||||
profiles.append(Spline(points))
|
||||
|
||||
for j in range(num_guides):
|
||||
points = [intersection_points[i][j] for i in range(num_profiles)]
|
||||
guides.append(Spline(points))
|
||||
|
||||
return profiles, guides
|
||||
|
||||
profiles, guides = create_test_curves()
|
||||
|
||||
tolerance = 3e-4
|
||||
gordon_surface = Face.make_gordon_surface(profiles, guides, tolerance=tolerance)
|
||||
|
||||
self.assertIsInstance(
|
||||
gordon_surface, Face, "The returned object should be a Face."
|
||||
)
|
||||
|
||||
def point_at_uv_against_expected(u: float, v: float, expected_point: Vector):
|
||||
point_at_uv = gordon_surface.position_at(u, v)
|
||||
self.assertAlmostEqual(
|
||||
point_at_uv.X,
|
||||
expected_point.X,
|
||||
delta=tolerance,
|
||||
msg=f"X coordinate mismatch at ({u},{v})",
|
||||
)
|
||||
self.assertAlmostEqual(
|
||||
point_at_uv.Y,
|
||||
expected_point.Y,
|
||||
delta=tolerance,
|
||||
msg=f"Y coordinate mismatch at ({u},{v})",
|
||||
)
|
||||
self.assertAlmostEqual(
|
||||
point_at_uv.Z,
|
||||
expected_point.Z,
|
||||
delta=tolerance,
|
||||
msg=f"Z coordinate mismatch at ({u},{v})",
|
||||
)
|
||||
|
||||
point_at_uv_against_expected(
|
||||
u=0.0, v=0.0, expected_point=guides[0].position_at(0.0)
|
||||
)
|
||||
point_at_uv_against_expected(
|
||||
u=1.0, v=0.0, expected_point=profiles[0].position_at(1.0)
|
||||
)
|
||||
point_at_uv_against_expected(
|
||||
u=0.0, v=1.0, expected_point=guides[0].position_at(1.0)
|
||||
)
|
||||
point_at_uv_against_expected(
|
||||
u=1.0, v=1.0, expected_point=profiles[-1].position_at(1.0)
|
||||
)
|
||||
|
||||
temp_curve = profiles[0]
|
||||
profiles[0] = Edge()
|
||||
with self.assertRaises(ValueError):
|
||||
gordon_surface = Face.make_gordon_surface(
|
||||
profiles, guides, tolerance=tolerance
|
||||
)
|
||||
|
||||
profiles[0] = temp_curve
|
||||
guides[0] = Edge()
|
||||
with self.assertRaises(ValueError):
|
||||
gordon_surface = Face.make_gordon_surface(
|
||||
profiles, guides, tolerance=tolerance
|
||||
)
|
||||
|
||||
def test_make_gordon_surface_input_types(self):
|
||||
tolerance = 3e-4
|
||||
|
||||
def point_at_uv_against_expected(u: float, v: float, expected_point: Vector):
|
||||
point_at_uv = gordon_surface.position_at(u, v)
|
||||
self.assertAlmostEqual(
|
||||
point_at_uv.X,
|
||||
expected_point.X,
|
||||
delta=tolerance,
|
||||
msg=f"X coordinate mismatch at ({u},{v})",
|
||||
)
|
||||
self.assertAlmostEqual(
|
||||
point_at_uv.Y,
|
||||
expected_point.Y,
|
||||
delta=tolerance,
|
||||
msg=f"Y coordinate mismatch at ({u},{v})",
|
||||
)
|
||||
self.assertAlmostEqual(
|
||||
point_at_uv.Z,
|
||||
expected_point.Z,
|
||||
delta=tolerance,
|
||||
msg=f"Z coordinate mismatch at ({u},{v})",
|
||||
)
|
||||
|
||||
points = [
|
||||
Vector(0, 0, 0),
|
||||
Vector(10, 0, 0),
|
||||
Vector(12, 20, 1),
|
||||
Vector(4, 22, -1),
|
||||
]
|
||||
|
||||
profiles = [Line(points[0], points[1]), Line(points[3], points[2])]
|
||||
guides = [Line(points[0], points[3]), Line(points[1], points[2])]
|
||||
gordon_surface = Face.make_gordon_surface(profiles, guides, tolerance=tolerance)
|
||||
point_at_uv_against_expected(
|
||||
u=0.5,
|
||||
v=0.5,
|
||||
expected_point=(points[0] + points[1] + points[2] + points[3]) / 4,
|
||||
)
|
||||
|
||||
profiles = [
|
||||
ThreePointArc(
|
||||
points[0], (points[0] + points[1]) / 2 + Vector(0, 0, 2), points[1]
|
||||
),
|
||||
ThreePointArc(
|
||||
points[3], (points[3] + points[2]) / 2 + Vector(0, 0, 3), points[2]
|
||||
),
|
||||
]
|
||||
guides = [
|
||||
Line(profiles[0] @ 0, profiles[1] @ 0),
|
||||
Line(profiles[0] @ 1, profiles[1] @ 1),
|
||||
]
|
||||
gordon_surface = Face.make_gordon_surface(profiles, guides, tolerance=tolerance)
|
||||
point_at_uv_against_expected(u=0.0, v=0.5, expected_point=guides[0] @ 0.5)
|
||||
point_at_uv_against_expected(u=1.0, v=0.5, expected_point=guides[1] @ 0.5)
|
||||
|
||||
profiles = [
|
||||
Edge.make_bezier(
|
||||
points[0],
|
||||
points[0] + Vector(1, 0, 1),
|
||||
points[1] - Vector(1, 0, 1),
|
||||
points[1],
|
||||
),
|
||||
Edge.make_bezier(
|
||||
points[3],
|
||||
points[3] + Vector(1, 0, 1),
|
||||
points[2] - Vector(1, 0, 1),
|
||||
points[2],
|
||||
),
|
||||
]
|
||||
guides = [
|
||||
Line(profiles[0] @ 0, profiles[1] @ 0),
|
||||
Line(profiles[0] @ 1, profiles[1] @ 1),
|
||||
]
|
||||
gordon_surface = Face.make_gordon_surface(profiles, guides, tolerance=tolerance)
|
||||
point_at_uv_against_expected(u=0.0, v=0.5, expected_point=guides[0] @ 0.5)
|
||||
point_at_uv_against_expected(u=1.0, v=0.5, expected_point=guides[1] @ 0.5)
|
||||
|
||||
profiles = [
|
||||
Edge.make_ellipse(10, 6),
|
||||
Edge.make_ellipse(8, 7).translate((1, 2, 10)),
|
||||
]
|
||||
guides = [
|
||||
Line(profiles[0] @ 0, profiles[1] @ 0),
|
||||
Line(profiles[0] @ 0.5, profiles[1] @ 0.5),
|
||||
]
|
||||
gordon_surface = Face.make_gordon_surface(profiles, guides, tolerance=tolerance)
|
||||
point_at_uv_against_expected(u=0.0, v=0.5, expected_point=guides[0] @ 0.5)
|
||||
point_at_uv_against_expected(u=1.0, v=0.5, expected_point=guides[0] @ 0.5)
|
||||
|
||||
profiles = [
|
||||
points[0],
|
||||
ThreePointArc(
|
||||
points[1], (points[1] + points[3]) / 2 + Vector(0, 0, 2), points[3]
|
||||
),
|
||||
points[2],
|
||||
]
|
||||
guides = [
|
||||
Spline(
|
||||
points[0],
|
||||
profiles[1] @ 0,
|
||||
points[2],
|
||||
),
|
||||
Spline(
|
||||
points[0],
|
||||
profiles[1] @ 1,
|
||||
points[2],
|
||||
),
|
||||
]
|
||||
gordon_surface = Face.make_gordon_surface(profiles, guides, tolerance=tolerance)
|
||||
point_at_uv_against_expected(u=0.0, v=1.0, expected_point=guides[0] @ 1)
|
||||
point_at_uv_against_expected(u=1.0, v=1.0, expected_point=guides[1] @ 1)
|
||||
point_at_uv_against_expected(u=1.0, v=0.0, expected_point=points[0])
|
||||
|
||||
profiles = [
|
||||
Line(points[0], points[1]),
|
||||
(points[0] + points[2]) / 2,
|
||||
Line(points[3], points[2]),
|
||||
]
|
||||
guides = [
|
||||
Spline(
|
||||
profiles[0] @ 0,
|
||||
profiles[1],
|
||||
profiles[2] @ 0,
|
||||
),
|
||||
Spline(
|
||||
profiles[0] @ 1,
|
||||
profiles[1],
|
||||
profiles[2] @ 1,
|
||||
),
|
||||
]
|
||||
with self.assertRaises(ValueError):
|
||||
gordon_surface = Face.make_gordon_surface(
|
||||
profiles, guides, tolerance=tolerance
|
||||
)
|
||||
|
||||
def test_make_surface(self):
|
||||
corners = [Vector(x, y) for x in [-50.5, 50.5] for y in [-24.5, 24.5]]
|
||||
net_exterior = Wire(
|
||||
|
|
|
|||
299
tests/test_direct_api/test_intersection.py
Normal file
|
|
@ -0,0 +1,299 @@
|
|||
import pytest
|
||||
from collections import Counter
|
||||
from dataclasses import dataclass
|
||||
from build123d import *
|
||||
from build123d.topology.shape_core import Shape
|
||||
|
||||
INTERSECT_DEBUG = False
|
||||
if INTERSECT_DEBUG:
|
||||
from ocp_vscode import show
|
||||
|
||||
|
||||
@dataclass
|
||||
class Case:
|
||||
object: Shape | Vector | Location | Axis | Plane
|
||||
target: Shape | Vector | Location | Axis | Plane
|
||||
expected: list | Vector | Location | Axis | Plane
|
||||
name: str
|
||||
xfail: None | str = None
|
||||
|
||||
|
||||
@pytest.mark.skip
|
||||
def run_test(obj, target, expected):
|
||||
if isinstance(target, list):
|
||||
result = obj.intersect(*target)
|
||||
else:
|
||||
result = obj.intersect(target)
|
||||
if INTERSECT_DEBUG:
|
||||
show([obj, target, result])
|
||||
if expected is None:
|
||||
assert result == expected, f"Expected None, but got {result}"
|
||||
else:
|
||||
e_type = ShapeList if isinstance(expected, list) else expected
|
||||
assert isinstance(result, e_type), f"Expected {e_type}, but got {result}"
|
||||
if e_type == ShapeList:
|
||||
assert len(result) == len(expected), f"Expected {len(expected)} objects, but got {len(result)}"
|
||||
|
||||
actual_counts = Counter(type(obj) for obj in result)
|
||||
expected_counts = Counter(expected)
|
||||
assert all(actual_counts[t] >= count for t, count in expected_counts.items()), f"Expected {expected}, but got {[type(r) for r in result]}"
|
||||
|
||||
|
||||
@pytest.mark.skip
|
||||
def make_params(matrix):
|
||||
params = []
|
||||
for case in matrix:
|
||||
obj_type = type(case.object).__name__
|
||||
tar_type = type(case.target).__name__
|
||||
i = len(params)
|
||||
if case.xfail and not INTERSECT_DEBUG:
|
||||
marks = [pytest.mark.xfail(reason=case.xfail)]
|
||||
else:
|
||||
marks = []
|
||||
uid = f"{i} {obj_type}, {tar_type}, {case.name}"
|
||||
params.append(pytest.param(case.object, case.target, case.expected, marks=marks, id=uid))
|
||||
if tar_type != obj_type and not isinstance(case.target, list):
|
||||
uid = f"{i + 1} {tar_type}, {obj_type}, {case.name}"
|
||||
params.append(pytest.param(case.target, case.object, case.expected, marks=marks, id=uid))
|
||||
|
||||
return params
|
||||
|
||||
|
||||
# Geometric test objects
|
||||
ax1 = Axis.X
|
||||
ax2 = Axis.Y
|
||||
ax3 = Axis((0, 0, 5), (1, 0, 0))
|
||||
pl1 = Plane.YZ
|
||||
pl2 = Plane.XY
|
||||
pl3 = Plane.XY.offset(5)
|
||||
pl4 = Plane((0, 5, 0))
|
||||
pl5 = Plane.YZ.offset(1)
|
||||
vl1 = Vector(2, 0, 0)
|
||||
vl2 = Vector(2, 0, 5)
|
||||
lc1 = Location((2, 0, 0))
|
||||
lc2 = Location((2, 0, 5))
|
||||
lc3 = Location((0, 0, 0), (0, 90, 90))
|
||||
lc4 = Location((2, 0, 0), (0, 90, 90))
|
||||
|
||||
# Geometric test matrix
|
||||
geometry_matrix = [
|
||||
Case(ax1, ax3, None, "parallel/skew", None),
|
||||
Case(ax1, ax1, Axis, "collinear", None),
|
||||
Case(ax1, ax2, Vector, "intersecting", None),
|
||||
|
||||
Case(ax1, pl3, None, "parallel", None),
|
||||
Case(ax1, pl2, Axis, "coplanar", None),
|
||||
Case(ax1, pl1, Vector, "intersecting", None),
|
||||
|
||||
Case(ax1, vl2, None, "non-coincident", None),
|
||||
Case(ax1, vl1, Vector, "coincident", None),
|
||||
|
||||
Case(ax1, lc2, None, "non-coincident", None),
|
||||
Case(ax1, lc4, Location, "intersecting, co-z", None),
|
||||
Case(ax1, lc1, Vector, "intersecting", None),
|
||||
|
||||
Case(pl2, pl3, None, "parallel", None),
|
||||
Case(pl2, pl4, Plane, "coplanar", None),
|
||||
Case(pl1, pl2, Axis, "intersecting", None),
|
||||
|
||||
Case(pl3, ax1, None, "parallel", None),
|
||||
Case(pl2, ax1, Axis, "coplanar", None),
|
||||
Case(pl1, ax1, Vector, "intersecting", None),
|
||||
|
||||
Case(pl1, vl2, None, "non-coincident", None),
|
||||
Case(pl2, vl1, Vector, "coincident", None),
|
||||
|
||||
Case(pl1, lc2, None, "non-coincident", None),
|
||||
Case(pl1, lc3, Location, "intersecting, co-z", None),
|
||||
Case(pl2, lc4, Vector, "coincident", None),
|
||||
|
||||
Case(vl1, vl2, None, "non-coincident", None),
|
||||
Case(vl1, vl1, Vector, "coincident", None),
|
||||
|
||||
Case(vl1, lc2, None, "non-coincident", None),
|
||||
Case(vl1, lc1, Vector, "coincident", None),
|
||||
|
||||
Case(lc1, lc2, None, "non-coincident", None),
|
||||
Case(lc1, lc4, Vector, "coincident", None),
|
||||
Case(lc1, lc1, Location, "coincident, co-z", None),
|
||||
]
|
||||
|
||||
@pytest.mark.parametrize("obj, target, expected", make_params(geometry_matrix))
|
||||
def test_geometry(obj, target, expected):
|
||||
run_test(obj, target, expected)
|
||||
|
||||
|
||||
# Shape test matrices
|
||||
vt1 = Vertex(2, 0, 0)
|
||||
vt2 = Vertex(2, 0, 5)
|
||||
|
||||
shape_0d_matrix = [
|
||||
Case(vt1, vt2, None, "non-coincident", None),
|
||||
Case(vt1, vt1, [Vertex], "coincident", None),
|
||||
|
||||
Case(vt1, vl2, None, "non-coincident", None),
|
||||
Case(vt1, vl1, [Vertex], "coincident", None),
|
||||
|
||||
Case(vt1, lc2, None, "non-coincident", None),
|
||||
Case(vt1, lc1, [Vertex], "coincident", None),
|
||||
|
||||
Case(vt2, ax1, None, "non-coincident", None),
|
||||
Case(vt1, ax1, [Vertex], "coincident", None),
|
||||
|
||||
Case(vt2, pl1, None, "non-coincident", None),
|
||||
Case(vt1, pl2, [Vertex], "coincident", None),
|
||||
|
||||
Case(vt1, [vt2, lc1], None, "multi to_intersect, non-coincident", None),
|
||||
Case(vt1, [vt1, lc1], [Vertex], "multi to_intersect, coincident", None),
|
||||
]
|
||||
|
||||
@pytest.mark.parametrize("obj, target, expected", make_params(shape_0d_matrix))
|
||||
def test_shape_0d(obj, target, expected):
|
||||
run_test(obj, target, expected)
|
||||
|
||||
|
||||
ed1 = Line((0, 0), (5, 0)).edge()
|
||||
ed2 = Line((0, -1), (5, 1)).edge()
|
||||
ed3 = Line((0, 0, 5), (5, 0, 5)).edge()
|
||||
ed4 = CenterArc((3, 1), 2, 0, 360).edge()
|
||||
ed5 = CenterArc((3, 1), 5, 0, 360).edge()
|
||||
|
||||
ed6 = Edge.make_line((0, -1), (2, 1))
|
||||
ed7 = Edge.make_line((0, 1), (2, -1))
|
||||
ed8 = Edge.make_line((0, 0), (2, 0))
|
||||
|
||||
wi1 = Wire() + [Line((0, 0), (1, 0)), RadiusArc((1, 0), (3, 1.5), 2)]
|
||||
wi2 = wi1 + Line((3, 1.5), (3, -1))
|
||||
wi3 = Wire() + [Line((0, 0), (1, 0)), RadiusArc((1, 0), (3, 0), 2), Line((3, 0), (5, 0))]
|
||||
wi4 = Wire() + [Line((0, 1), (2, -1)) , Line((2, -1), (3, -1))]
|
||||
wi5 = wi4 + Line((3, -1), (4, 1))
|
||||
wi6 = Wire() + [Line((0, 1, 1), (2, -1, 1)), Line((2, -1, 1), (4, 1, 1))]
|
||||
|
||||
shape_1d_matrix = [
|
||||
Case(ed1, vl2, None, "non-coincident", None),
|
||||
Case(ed1, vl1, [Vertex], "coincident", None),
|
||||
|
||||
Case(ed1, lc2, None, "non-coincident", None),
|
||||
Case(ed1, lc1, [Vertex], "coincident", None),
|
||||
|
||||
Case(ed3, ax1, None, "parallel/skew", None),
|
||||
Case(ed2, ax1, [Vertex], "intersecting", None),
|
||||
Case(ed1, ax1, [Edge], "collinear", None),
|
||||
Case(ed4, ax1, [Vertex, Vertex], "multi intersect", None),
|
||||
|
||||
Case(ed1, pl3, None, "parallel/skew", None),
|
||||
Case(ed1, pl1, [Vertex], "intersecting", None),
|
||||
Case(ed1, pl2, [Edge], "collinear", None),
|
||||
Case(ed5, pl1, [Vertex, Vertex], "multi intersect", None),
|
||||
|
||||
Case(ed1, vt2, None, "non-coincident", None),
|
||||
Case(ed1, vt1, [Vertex], "coincident", None),
|
||||
|
||||
Case(ed3, ed1, None, "parallel/skew", None),
|
||||
Case(ed2, ed1, [Vertex], "intersecting", None),
|
||||
Case(ed1, ed1, [Edge], "collinear", None),
|
||||
Case(ed4, ed1, [Vertex, Vertex], "multi intersect", None),
|
||||
|
||||
Case(ed6, [ed7, ed8], [Vertex], "multi to_intersect, intersect", None),
|
||||
Case(ed6, [ed7, pl5], [Vertex], "multi to_intersect, intersect", None),
|
||||
Case(ed6, [ed7, Vector(1, 0)], [Vertex], "multi to_intersect, intersect", None),
|
||||
|
||||
Case(wi6, ax1, None, "parallel/skew", None),
|
||||
Case(wi4, ax1, [Vertex], "intersecting", None),
|
||||
Case(wi1, ax1, [Edge], "collinear", None),
|
||||
Case(wi5, ax1, [Vertex, Vertex], "multi intersect", None),
|
||||
Case(wi2, ax1, [Vertex, Edge], "intersect + collinear", None),
|
||||
Case(wi3, ax1, [Edge, Edge], "2 collinear", None),
|
||||
|
||||
Case(wi6, ed1, None, "parallel/skew", None),
|
||||
Case(wi4, ed1, [Vertex], "intersecting", None),
|
||||
Case(wi1, ed1, [Edge], "collinear", None),
|
||||
Case(wi5, ed1, [Vertex, Vertex], "multi intersect", None),
|
||||
Case(wi2, ed1, [Vertex, Edge], "intersect + collinear", None),
|
||||
Case(wi3, ed1, [Edge, Edge], "2 collinear", None),
|
||||
|
||||
Case(wi5, [ed1, Vector(1, 0)], [Vertex], "multi to_intersect, multi intersect", None),
|
||||
]
|
||||
|
||||
@pytest.mark.parametrize("obj, target, expected", make_params(shape_1d_matrix))
|
||||
def test_shape_1d(obj, target, expected):
|
||||
run_test(obj, target, expected)
|
||||
|
||||
|
||||
# FreeCAD issue example
|
||||
c1 = CenterArc((0, 0), 10, 0, 360).edge()
|
||||
c2 = CenterArc((19, 0), 10, 0, 360).edge()
|
||||
skew = Line((-12, 0), (30, 10)).edge()
|
||||
vert = Line((10, 0), (10, 20)).edge()
|
||||
horz = Line((0, 10), (30, 10)).edge()
|
||||
e1 = EllipticalCenterArc((5, 0), 5, 10, 0, 360).edge()
|
||||
|
||||
freecad_matrix = [
|
||||
Case(c1, skew, [Vertex, Vertex], "circle, skew, intersect", None),
|
||||
Case(c2, skew, [Vertex, Vertex], "circle, skew, intersect", None),
|
||||
Case(c1, e1, [Vertex, Vertex, Vertex], "circle, ellipse, intersect + tangent", None),
|
||||
Case(c2, e1, [Vertex, Vertex], "circle, ellipse, intersect", None),
|
||||
Case(skew, e1, [Vertex, Vertex], "skew, ellipse, intersect", None),
|
||||
Case(skew, horz, [Vertex], "skew, horizontal, coincident", None),
|
||||
Case(skew, vert, [Vertex], "skew, vertical, intersect", None),
|
||||
Case(horz, vert, [Vertex], "horizontal, vertical, intersect", None),
|
||||
Case(vert, e1, [Vertex], "vertical, ellipse, tangent", None),
|
||||
Case(horz, e1, [Vertex], "horizontal, ellipse, tangent", None),
|
||||
|
||||
Case(c1, c2, [Vertex, Vertex], "circle, skew, intersect", "Should return 2 Vertices"),
|
||||
Case(c1, horz, [Vertex], "circle, horiz, tangent", None),
|
||||
Case(c2, horz, [Vertex], "circle, horiz, tangent", None),
|
||||
Case(c1, vert, [Vertex], "circle, vert, tangent", None),
|
||||
Case(c2, vert, [Vertex], "circle, vert, intersect", None),
|
||||
]
|
||||
|
||||
@pytest.mark.parametrize("obj, target, expected", make_params(freecad_matrix))
|
||||
def test_freecad(obj, target, expected):
|
||||
run_test(obj, target, expected)
|
||||
|
||||
|
||||
# Issue tests
|
||||
t = Sketch() + GridLocations(5, 0, 2, 1) * Circle(2)
|
||||
s = Circle(10).face()
|
||||
l = Line(-20, 20).edge()
|
||||
a = Rectangle(10,10).face()
|
||||
b = (Plane.XZ * a).face()
|
||||
e1 = Edge.make_line((-1, 0), (1, 0))
|
||||
w1 = Wire.make_circle(0.5)
|
||||
f1 = Face(Wire.make_circle(0.5))
|
||||
|
||||
issues_matrix = [
|
||||
Case(t, t, [Face, Face], "issue #1015", "Returns Compound"),
|
||||
Case(l, s, [Edge], "issue #945", "Edge.intersect only takes 1D"),
|
||||
Case(a, b, [Edge], "issue #918", "Returns empty Compound"),
|
||||
Case(e1, w1, [Vertex, Vertex], "issue #697"),
|
||||
Case(e1, f1, [Edge], "issue #697", "Edge.intersect only takes 1D"),
|
||||
]
|
||||
|
||||
@pytest.mark.parametrize("obj, target, expected", make_params(issues_matrix))
|
||||
def test_issues(obj, target, expected):
|
||||
run_test(obj, target, expected)
|
||||
|
||||
|
||||
# Exceptions
|
||||
exception_matrix = [
|
||||
Case(vt1, Color(), None, "Unsupported type", None),
|
||||
Case(ed1, Color(), None, "Unsupported type", None),
|
||||
]
|
||||
|
||||
@pytest.mark.skip
|
||||
def make_exception_params(matrix):
|
||||
params = []
|
||||
for case in matrix:
|
||||
obj_type = type(case.object).__name__
|
||||
tar_type = type(case.target).__name__
|
||||
i = len(params)
|
||||
uid = f"{i} {obj_type}, {tar_type}, {case.name}"
|
||||
params.append(pytest.param(case.object, case.target, case.expected, id=uid))
|
||||
|
||||
return params
|
||||
|
||||
@pytest.mark.parametrize("obj, target, expected", make_exception_params(exception_matrix))
|
||||
def test_exceptions(obj, target, expected):
|
||||
with pytest.raises(Exception):
|
||||
obj.intersect(target)
|
||||
|
|
@ -388,8 +388,8 @@ class TestLocation(unittest.TestCase):
|
|||
e3 = Edge.make_line((0, 0), (2, 0))
|
||||
|
||||
i = e1.intersect(e2, e3)
|
||||
self.assertTrue(isinstance(i, Vertex))
|
||||
self.assertAlmostEqual(Vector(i), (1, 0, 0), 5)
|
||||
self.assertTrue(isinstance(i, list))
|
||||
self.assertAlmostEqual(Vector(i[0]), (1, 0, 0), 5)
|
||||
|
||||
e4 = Edge.make_line((1, -1), (1, 1))
|
||||
e5 = Edge.make_line((2, -1), (2, 1))
|
||||
|
|
|
|||
|
|
@ -531,9 +531,12 @@ class TestShape(unittest.TestCase):
|
|||
def test_empty_shape(self):
|
||||
empty = Solid()
|
||||
box = Solid.make_box(1, 1, 1)
|
||||
self.assertIsNone(empty.location)
|
||||
self.assertIsNone(empty.position)
|
||||
self.assertIsNone(empty.orientation)
|
||||
with self.assertRaises(ValueError):
|
||||
empty.location
|
||||
with self.assertRaises(ValueError):
|
||||
empty.position
|
||||
with self.assertRaises(ValueError):
|
||||
empty.orientation
|
||||
self.assertFalse(empty.is_manifold)
|
||||
with self.assertRaises(ValueError):
|
||||
empty.geom_type
|
||||
|
|
|
|||
|
|
@ -155,8 +155,10 @@ class TestWire(unittest.TestCase):
|
|||
t4 = o.trim(0.5, 0.75)
|
||||
self.assertAlmostEqual(t4.length, o.length * 0.25, 5)
|
||||
|
||||
with self.assertRaises(ValueError):
|
||||
o.trim(0.75, 0.25)
|
||||
w0 = Polyline((0, 0), (0, 1), (1, 1), (1, 0))
|
||||
w2 = w0.trim(0, (0.5, 1))
|
||||
self.assertAlmostEqual(w2 @ 1, (0.5, 1), 5)
|
||||
|
||||
spline = Spline(
|
||||
(0, 0, 0),
|
||||
(0, 10, 0),
|
||||
|
|
|
|||