Merge branch 'dev' into intersections-2d (fix import conflict)

This commit is contained in:
Jonathan Wagenet 2025-10-29 13:49:49 -04:00
commit b049e6a8ce
36 changed files with 1856 additions and 291 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

15
NOTICE Normal file
View file

@ -0,0 +1,15 @@
build123d
Copyright (c) 20222025 The build123d Contributors
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
-------------------------------------------------------------------------------
This project was originally derived from portions of the CadQuery codebase
(https://github.com/CadQuery/cadquery) but has since been extensively
refactored and restructured into an independent system.
CadQuery is licensed under the Apache License, Version 2.0.

View file

@ -19,9 +19,17 @@
[![DOI](https://zenodo.org/badge/510925389.svg)](https://doi.org/10.5281/zenodo.14872322)
Build123d is a python-based, parametric, [boundary representation (BREP)][BREP] modeling framework for 2D and 3D CAD. It's built on the [Open Cascade] geometric kernel and allows for the creation of complex models using a simple and intuitive python syntax. Build123d can be used to create models for 3D printing, CNC machining, laser cutting, and other manufacturing processes. Models can be exported to a wide variety of popular CAD tools such as [FreeCAD] and SolidWorks.
Build123d is a Python-based, parametric [boundary representation (BREP)][BREP] modeling framework for 2D and 3D CAD. Built on the [Open Cascade] geometric kernel, it provides a clean, fully Pythonic interface for creating precise models suitable for 3D printing, CNC machining, laser cutting, and other manufacturing processes. Models can be exported to popular CAD tools such as [FreeCAD] and SolidWorks.
Build123d could be considered as an evolution of [CadQuery] where the somewhat restrictive Fluent API (method chaining) is replaced with stateful context managers - e.g. `with` blocks - thus enabling the full python toolbox: for loops, references to objects, object sorting and filtering, etc.
Designed for modern, maintainable CAD-as-code, build123d combines clear architecture with expressive, algebraic modeling. It offers:
- Minimal or no internal state depending on mode,
- Explicit 1D, 2D, and 3D geometry classes with well-defined operations,
- Extensibility through subclassing and functional composition—no monkey patching,
- Standards-compliant code (PEP 8, mypy, pylint) with rich pylance type hints,
- Deep Python integration—selectors as lists, locations as iterables, and natural conversions (Solid(shell), tuple(Vector)),
- Operator-driven modeling (obj += sub_obj, Plane.XZ * Pos(X=5) * Rectangle(1, 1)) for algebraic, readable, and composable design logic.
The result is a framework that feels native to Python while providing the full power of OpenCascade geometry underneath.
The documentation for **build123d** can be found at [readthedocs](https://build123d.readthedocs.io/en/latest/index.html).
@ -62,6 +70,10 @@ python3 -m pip install -e .
Further installation instructions are available (e.g. Poetry) see the [installation section on readthedocs](https://build123d.readthedocs.io/en/latest/installation.html).
Attribution:
Build123d was originally derived from portions of the [CadQuery] codebase but has since been extensively refactored and restructured into an independent system.
[BREP]: https://en.wikipedia.org/wiki/Boundary_representation
[CadQuery]: https://cadquery.readthedocs.io/en/latest/index.html
[FreeCAD]: https://www.freecad.org/

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

@ -29,58 +29,36 @@
:align: center
:alt: build123d logo
Build123d is a python-based, parametric, boundary representation (BREP) modeling
framework for 2D and 3D CAD. It's built on the Open Cascade geometric kernel and
allows for the creation of complex models using a simple and intuitive python
syntax. Build123d can be used to create models for 3D printing, CNC machining,
laser cutting, and other manufacturing processes. Models can be exported to a
wide variety of popular CAD tools such as FreeCAD and SolidWorks.
Build123d could be considered as an evolution of
`CadQuery <https://cadquery.readthedocs.io/en/latest/index.html>`_ where the
somewhat restrictive Fluent API (method chaining) is replaced with stateful
context managers - i.e. `with` blocks - thus enabling the full python toolbox:
for loops, references to objects, object sorting and filtering, etc.
Note that this documentation is available in
`pdf <https://build123d.readthedocs.io/_/downloads/en/latest/pdf/>`_ and
`epub <https://build123d.readthedocs.io/_/downloads/en/latest/epub/>`_ formats
for reference while offline.
########
Overview
About
########
build123d uses the standard python context manager - i.e. the ``with`` statement often used when
working with files - as a builder of the object under construction. Once the object is complete
it can be extracted from the builders and used in other ways: for example exported as a STEP
file or used in an Assembly. There are three builders available:
Build123d is a Python-based, parametric (BREP) modeling framework for 2D and 3D CAD.
Built on the Open Cascade geometric kernel, it provides a clean, fully Pythonic interface
for creating precise models suitable for 3D printing, CNC machining, laser cutting, and
other manufacturing processes. Models can be exported to popular CAD tools such as FreeCAD
and SolidWorks.
* **BuildLine**: a builder of one dimensional objects - those with the property
of length but not of area or volume - typically used
to create complex lines used in sketches or paths.
* **BuildSketch**: a builder of planar two dimensional objects - those with the property
of area but not of volume - typically used to create 2D drawings that are extruded into 3D parts.
* **BuildPart**: a builder of three dimensional objects - those with the property of volume -
used to create individual parts.
Designed for modern, maintainable CAD-as-code, build123d combines clear architecture with
expressive, algebraic modeling. It offers:
The three builders work together in a hierarchy as follows:
* Minimal or no internal state depending on mode
* Explicit 1D, 2D, and 3D geometry classes with well-defined operations
* Extensibility through subclassing and functional composition—no monkey patching
* Standards-compliant code (PEP 8, mypy, pylint) with rich pylance type hints
* Deep Python integration—selectors as lists, locations as iterables, and natural
conversions (``Solid(shell)``, ``tuple(Vector)``)
* Operator-driven modeling (``obj += sub_obj``, ``Plane.XZ * Pos(X=5) * Rectangle(1, 1)``)
for algebraic, readable, and composable design logic
.. code-block:: python
The result is a framework that feels native to Python while providing the full power of
OpenCascade geometry underneath.
with BuildPart() as my_part:
...
with BuildSketch() as my_sketch:
...
with BuildLine() as my_line:
...
...
...
where ``my_line`` will be added to ``my_sketch`` once the line is complete and ``my_sketch`` will be
added to ``my_part`` once the sketch is complete.
With build123d, intricate parametric models can be created in just a few lines of readable
Python code—as demonstrated by the tea cup example below.
As an example, consider the design of a tea cup:
.. dropdown:: Teacup Example
.. literalinclude:: ../examples/tea_cup.py
:start-after: [Code]
@ -91,6 +69,14 @@ As an example, consider the design of a tea cup:
<script type="module" src="https://unpkg.com/@google/model-viewer/dist/model-viewer.min.js"></script>
<model-viewer poster="_images/tea_cup.png" src="_static/tea_cup.glb" alt="A tea cup modelled in build123d" auto-rotate camera-controls style="width: 100%; height: 50vh;"></model-viewer>
.. note::
This documentation is available in
`pdf <https://build123d.readthedocs.io/_/downloads/en/latest/pdf/>`_ and
`epub <https://build123d.readthedocs.io/_/downloads/en/latest/epub/>`_ formats
for reference while offline.
.. note::
There is a `Discord <https://discord.com/invite/Bj9AQPsCfx>`_ server (shared with CadQuery) where

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

@ -81,6 +81,7 @@ __all__ = [
"BuildSketch",
# 1D Curve Objects
"BaseLineObject",
"Airfoil",
"Bezier",
"BlendCurve",
"CenterArc",

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,38 +147,43 @@ def import_step(filename: PathLike | str | bytes) -> Compound:
clean_name = "".join(ch for ch in name if unicodedata.category(ch)[0] != "C")
return clean_name.translate(str.maketrans(" .()", "____"))
def get_color(shape: TopoDS_Shape) -> Quantity_ColorRGBA:
"""Get the color - take that of the largest Face if multiple"""
def get_shape_color_from_cache(obj: TopoDS_Shape) -> Quantity_ColorRGBA | None:
"""Get the color of a shape from a cache"""
key = obj.TShape().__hash__()
if key in _color_cache:
return _color_cache[key]
def get_col(obj: TopoDS_Shape) -> Quantity_ColorRGBA:
col = Quantity_ColorRGBA()
if (
has_color = (
color_tool.GetColor(obj, XCAFDoc_ColorCurv, col)
or color_tool.GetColor(obj, XCAFDoc_ColorGen, col)
or color_tool.GetColor(obj, XCAFDoc_ColorSurf, col)
):
return col
shape_color = get_col(shape)
colors = {}
face_explorer = TopExp_Explorer(shape, TopAbs_FACE)
while face_explorer.More():
current_face = face_explorer.Current()
properties = GProp_GProps()
BRepGProp.SurfaceProperties_s(current_face, properties)
area = properties.Mass()
color = get_col(current_face)
if color is not None:
colors[area] = color
face_explorer.Next()
# If there are multiple colors, return the one from the largest face
if colors:
shape_color = sorted(colors.items())[-1][1]
)
_color_cache[key] = col if has_color else None
return _color_cache[key]
def get_color(shape: TopoDS_Shape) -> Quantity_ColorRGBA | None:
"""Get the color - take that of the largest Face if multiple"""
shape_color = get_shape_color_from_cache(shape)
if shape_color is not None:
return shape_color
max_extent = -1.0
winner = None
exp = TopExp_Explorer(shape, TopAbs_FACE)
while exp.More():
face = exp.Current()
col = get_shape_color_from_cache(face)
if col is not None:
box = Bnd_Box()
BRepBndLib.Add_s(face, box)
extent = box.SquareExtent()
if extent > max_extent:
max_extent = extent
winner = col
exp.Next()
return winner
def build_assembly(parent_tdf_label: TDF_Label | None = None) -> list[Shape]:
"""Recursively extract object into an assembly"""
sub_tdf_labels = TDF_LabelSequence()
@ -211,6 +218,9 @@ def import_step(filename: PathLike | str | bytes) -> Compound:
if not os.path.exists(filename):
raise FileNotFoundError(filename)
# Retrieving color info is expensive so cache the lookups
_color_cache: dict[int, Quantity_ColorRGBA | None] = {}
fmt = TCollection_ExtendedString("XCAF")
doc = TDocStd_Document(fmt)
shape_tool = XCAFDoc_DocumentTool.ShapeTool_s(doc.Main())

View file

@ -29,11 +29,14 @@ license:
from __future__ import annotations
import copy as copy_module
import warnings
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 +103,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
@ -1237,6 +1363,12 @@ class PointArcTangentLine(BaseEdgeObject):
mode (Mode, optional): combination mode. Defaults to Mode.ADD
"""
warnings.warn(
"The 'PointArcTangentLine' object is deprecated and will be removed in a future version.",
DeprecationWarning,
stacklevel=2,
)
_applies_to = [BuildLine._tag]
def __init__(
@ -1316,6 +1448,12 @@ class PointArcTangentArc(BaseEdgeObject):
RuntimeError: No tangent arc found
"""
warnings.warn(
"The 'PointArcTangentArc' object is deprecated and will be removed in a future version.",
DeprecationWarning,
stacklevel=2,
)
_applies_to = [BuildLine._tag]
def __init__(
@ -1459,6 +1597,11 @@ class ArcArcTangentLine(BaseEdgeObject):
Defaults to Keep.INSIDE
mode (Mode, optional): combination mode. Defaults to Mode.ADD
"""
warnings.warn(
"The 'ArcArcTangentLine' object is deprecated and will be removed in a future version.",
DeprecationWarning,
stacklevel=2,
)
_applies_to = [BuildLine._tag]
@ -1560,6 +1703,12 @@ class ArcArcTangentArc(BaseEdgeObject):
mode (Mode, optional): combination mode. Defaults to Mode.ADD
"""
warnings.warn(
"The 'ArcArcTangentArc' object is deprecated and will be removed in a future version.",
DeprecationWarning,
stacklevel=2,
)
_applies_to = [BuildLine._tag]
def __init__(

View file

@ -29,53 +29,55 @@ license:
from __future__ import annotations
from math import floor, pi
from typing import TYPE_CHECKING, Callable, TypeVar
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
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
from OCP.Geom2dAPI import Geom2dAPI_ProjectPointOnCurve, Geom2dAPI_InterCurveCurve
from OCP.Geom2dGcc import (
Geom2dGcc_Circ2d2TanOn,
Geom2dGcc_Circ2d2TanOnGeo,
Geom2dGcc_Circ2d2TanRad,
Geom2dGcc_Circ2d3Tan,
Geom2dGcc_Circ2dTanCen,
Geom2dGcc_Circ2dTanOnRad,
Geom2dGcc_Circ2dTanOnRadGeo,
Geom2dGcc_Lin2dTanObl,
Geom2dGcc_Lin2d2Tan,
Geom2dGcc_QualifiedCurve,
)
from OCP.GeomAbs import GeomAbs_CurveType
from OCP.GeomAPI import GeomAPI, GeomAPI_ProjectPointOnCurve
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
from OCP.TopoDS import TopoDS_Edge, TopoDS_Vertex
from build123d.build_enums import Sagitta, Tangency
from build123d.geometry import TOLERANCE, Vector, VectorLike
from build123d.geometry import Axis, TOLERANCE, Vector, VectorLike
from .zero_d import Vertex
from .shape_core import ShapeList, downcast
from .shape_core import ShapeList
if TYPE_CHECKING:
from build123d.topology.one_d import Edge # pragma: no cover
@ -117,22 +119,16 @@ def _edge_to_qualified_2d(
"""Convert a TopoDS_Edge into 2d curve & extract properties"""
# 1) Underlying curve + range (also retrieve location to be safe)
loc = edge.Location()
hcurve3d = BRep_Tool.Curve_s(edge, float(), float())
first, last = BRep_Tool.Range_s(edge)
# 2) Apply location if the edge is positioned by a TopLoc_Location
if not loc.IsIdentity():
trsf = loc.Transformation()
hcurve3d = tcast(Geom_Curve, hcurve3d.Transformed(trsf))
# 3) Convert to 2D on Plane.XY (Z-up frame at origin)
# 2) Convert to 2D on Plane.XY (Z-up frame at origin)
hcurve2d = GeomAPI.To2d_s(hcurve3d, _pln_xy) # -> Handle_Geom2d_Curve
# 4) Wrap in an adaptor using the same parametric range
# 3) Wrap in an adaptor using the same parametric range
adapt2d = Geom2dAdaptor_Curve(hcurve2d, first, last)
# 5) Create the qualified curve (unqualified is fine here)
# 4) Create the qualified curve (unqualified is fine here)
qcurve = Geom2dGcc_QualifiedCurve(adapt2d, position_constaint.value)
return qcurve, hcurve2d, first, last, adapt2d
@ -153,6 +149,18 @@ def _param_in_trim(
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,
@ -201,6 +209,41 @@ def _two_arc_edges_from_params(
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:
@ -646,3 +689,134 @@ def _make_tan_on_rad_arcs(
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

@ -56,7 +56,7 @@ import numpy as np
import warnings
from collections.abc import Iterable
from itertools import combinations
from math import ceil, copysign, cos, floor, inf, isclose, pi, radians
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
@ -240,6 +240,8 @@ from .constrained_lines import (
_make_3tan_arcs,
_make_tan_cen_arcs,
_make_tan_on_rad_arcs,
_make_tan_oriented_lines,
_make_2tan_lines,
)
if TYPE_CHECKING: # pragma: no cover
@ -356,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__(
@ -792,6 +809,8 @@ class Mixin1D(Shape):
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)
)
@ -818,10 +837,13 @@ class Mixin1D(Shape):
vts = common_set.vertices()
eds = common_set.edges()
if vts and eds:
filtered_vts = ShapeList([
v for v in vts
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
@ -1958,6 +1980,157 @@ class Edge(Mixin1D, Shape[TopoDS_Edge]):
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,
@ -2814,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")
@ -2842,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,
@ -2852,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:
@ -2871,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
@ -2882,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
@ -3392,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]
@ -3908,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

@ -64,6 +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_Curve, BRepAdaptor_Surface
from OCP.BRepAlgo import BRepAlgo
from OCP.BRepAlgoAPI import BRepAlgoAPI_Common, BRepAlgoAPI_Section
from OCP.BRepBuilderAPI import (
@ -80,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,
@ -98,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 (
@ -1029,6 +1041,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

@ -263,7 +263,10 @@ def _make_topods_face_from_wires(
for inner_wire in inner_wires:
if not BRep_Tool.IsClosed_s(inner_wire):
raise ValueError("Cannot build face(s): inner wire is not closed")
face_builder.Add(inner_wire)
sf_s = ShapeFix_Shape(inner_wire)
sf_s.Perform()
fixed_inner_wire = TopoDS.Wire_s(sf_s.Shape())
face_builder.Add(fixed_inner_wire)
face_builder.Build()

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

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