Merge branch 'dev' into lexer

This commit is contained in:
Jonathan Wagenet 2025-10-21 13:59:45 -04:00
commit 8c32e3bed3
42 changed files with 3806 additions and 398 deletions

View file

@ -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:

View file

@ -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:

View file

@ -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

Binary file not shown.

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

View file

@ -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

View file

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 54 KiB

After

Width:  |  Height:  |  Size: 54 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Before After
Before After

View file

@ -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
View 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)

View file

@ -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

View 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,
# )

View 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 wellformed 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 wingedge *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 (halfspan), 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]

View file

@ -0,0 +1,125 @@
##################################
Tutorial: Heart Token (Basics)
##################################
This handson tutorial introduces the fundamentals of surface modeling by building
a heartshaped token from a small set of nonplanar faces. Well create
nonplanar 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.

View file

@ -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 surfacebuilding tools in build123d
- Handson 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 nonplanar 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 *watertight* 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.

View file

@ -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",

View file

@ -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",

View file

@ -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"""

View file

@ -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)

View file

@ -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
# Is the location on the axis with the same direction?
if (
self.intersect(location.position) is not None
and location_dir == self.direction
):
return location
if self.intersect(location.position) is not None:
# Is the location on the axis with the same 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:
return self
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:
return location
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)

View file

@ -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,37 +147,42 @@ 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:
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]
col = Quantity_ColorRGBA()
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)
)
_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
def get_col(obj: TopoDS_Shape) -> Quantity_ColorRGBA:
col = Quantity_ColorRGBA()
if (
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]
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"""
@ -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())

View file

@ -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 (01)
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

View file

@ -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:

View 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])

View file

@ -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

View file

@ -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

View file

@ -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,

View file

@ -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
View 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

View 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 xcenter.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 y0 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 y0 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)
)

View 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)

View file

@ -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)

View file

@ -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(

View 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)

View file

@ -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))

View file

@ -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

View file

@ -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),