Merge branch 'dev' into intersections-2d (fix import conflict)
2
.github/workflows/benchmark.yml
vendored
|
|
@ -12,7 +12,7 @@ jobs:
|
|||
# "3.11",
|
||||
"3.12",
|
||||
]
|
||||
os: [macos-13, macos-14, ubuntu-latest, windows-latest]
|
||||
os: [macos-15-intel, macos-14, ubuntu-latest, windows-latest]
|
||||
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
|
|
|
|||
2
.github/workflows/test.yml
vendored
|
|
@ -13,7 +13,7 @@ jobs:
|
|||
# "3.12",
|
||||
"3.13",
|
||||
]
|
||||
os: [macos-13, macos-14, ubuntu-latest, windows-latest]
|
||||
os: [macos-15-intel, macos-14, ubuntu-latest, windows-latest]
|
||||
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
|
|
|
|||
|
|
@ -10,6 +10,10 @@ build:
|
|||
python: "3.10"
|
||||
apt_packages:
|
||||
- graphviz
|
||||
jobs:
|
||||
post_checkout:
|
||||
# necessary to ensure that the development builds get a correct version tag
|
||||
- git fetch --unshallow || true
|
||||
|
||||
# Build from the docs/ directory with Sphinx
|
||||
sphinx:
|
||||
|
|
@ -21,8 +25,3 @@ python:
|
|||
path: .
|
||||
extra_requirements:
|
||||
- docs
|
||||
|
||||
# Explicitly set the version of Python and its requirements
|
||||
# python:
|
||||
# install:
|
||||
# - requirements: docs/requirements.txt
|
||||
|
|
|
|||
15
NOTICE
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
build123d
|
||||
Copyright (c) 2022–2025 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.
|
||||
16
README.md
|
|
@ -19,9 +19,17 @@
|
|||
[](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
8
docs/assets/example_airfoil.svg
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
<?xml version='1.0' encoding='utf-8'?>
|
||||
<svg width="100.089989mm" height="13.093747mm" viewBox="-0.000798 -0.085182 1.001332 0.130994" version="1.1" xmlns="http://www.w3.org/2000/svg">
|
||||
<g transform="scale(1,-1)" stroke-linecap="round">
|
||||
<g fill="none" stroke="rgb(0,0,0)" stroke-width="0.0009003885533792664">
|
||||
<path d="M 1.0,-0.0 C 0.829823,0.035412 0.658159,0.060683 0.48501,0.075813 C 0.403802,0.083052 0.322454,0.085791 0.240967,0.084029 C 0.198485,0.08366 0.156475,0.079149 0.114938,0.070497 C 0.074523,0.05954 0.027058,0.048994 0.001793,0.012613 C -0.0025,0.000458 0.001085,-0.00896 0.012548,-0.01564 C 0.02178,-0.020765 0.031585,-0.024392 0.041962,-0.026521 C 0.062557,-0.030869 0.083358,-0.033834 0.104363,-0.035416 C 0.149789,-0.038322 0.195215,-0.041227 0.24064,-0.044133 C 0.321442,-0.046309 0.402208,-0.045216 0.482939,-0.040853 C 0.655652,-0.03182 0.828005,-0.018202 1.0,-0.0" />
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 924 B |
BIN
docs/assets/surface_modeling/heart_token.png
Normal file
|
After Width: | Height: | Size: 27 KiB |
BIN
docs/assets/surface_modeling/spitfire_wing.png
Normal file
|
After Width: | Height: | Size: 44 KiB |
|
|
@ -0,0 +1,11 @@
|
|||
<?xml version='1.0' encoding='utf-8'?>
|
||||
<svg width="100.09mm" height="66.151142mm" viewBox="-541.648921 -836.924186 4767.847817 3151.14975" version="1.1" xmlns="http://www.w3.org/2000/svg">
|
||||
<g transform="scale(1,-1)" stroke-linecap="round">
|
||||
<g fill="none" stroke="rgb(0,0,0)" stroke-width="4.28720455097892">
|
||||
<path d="M 1445.821235,834.74528 C 1353.526765,803.718243 1260.88331,770.545273 1167.994034,735.580745 C 1083.806246,703.883592 999.414296,670.712071 914.854789,636.113423 C 835.638754,603.69438 756.274425,570.022422 676.77418,534.952257 C 592.583511,497.803918 508.238612,459.095508 423.75228,418.390166 C 381.475605,398.017947 339.163641,377.140102 296.820744,355.66775 C 255.380094,334.651781 213.909574,313.067151 172.417787,290.800527 C 132.498839,269.376021 92.559713,247.323024 52.616058,224.499576 C 33.433555,213.541072 14.251486,202.395739 -4.930148,191.063577 C -23.484296,180.09936 -42.03687,168.953007 -60.583168,157.587759 C -78.431472,146.650245 -96.273964,135.510001 -114.110039,124.195809 C -131.629708,113.082326 -149.143185,101.801012 -166.62766,90.088045 C -183.749938,78.617716 -200.844402,66.733433 -217.887184,54.406354 C -249.053676,31.859239 -280.057077,7.844124 -310.705712,-17.830688 C -324.493158,-29.38285 -338.206616,-41.273839 -351.816854,-53.534273 C -364.358757,-64.832326 -376.813009,-76.4441 -389.144607,-88.405566 C -400.391943,-99.315314 -411.537246,-110.515963 -422.53694,-122.049338 C -432.452058,-132.445516 -442.248865,-143.112041 -451.870806,-154.096773 C -460.428098,-163.866068 -468.84708,-173.887048 -477.05134,-184.212339 C -484.238191,-193.257193 -491.260272,-202.535563 -498.010658,-212.099865 C -503.829085,-220.343718 -509.445657,-228.800001 -514.706406,-237.507752 C -519.174839,-244.90404 -523.386558,-252.481753 -527.120208,-260.233274 C -530.2732,-266.779278 -533.085259,-273.449232 -535.256161,-280.125931 C -537.126266,-285.877518 -538.520579,-291.634109 -539.139475,-297.089851 C -539.710024,-302.119395 -539.621572,-306.893256 -538.815367,-311.085197 C -538.009295,-315.276448 -536.485707,-318.88597 -534.41361,-321.787038 C -532.173969,-324.922679 -529.293531,-327.230668 -526.080734,-328.912927 C -522.384603,-330.848265 -518.248591,-331.955443 -513.912155,-332.549837 C -508.837989,-333.245352 -503.489405,-333.238769 -498.016517,-332.811088 C -491.653993,-332.313885 -485.123469,-331.247553 -478.514247,-329.834557 C -470.940437,-328.21534 -463.263282,-326.140884 -455.535716,-323.780678 C -446.817103,-321.117779 -438.03432,-318.091135 -429.219119,-314.830251 C -419.4158,-311.203845 -409.572388,-307.287744 -399.708184,-303.182158 C -388.876522,-298.673905 -378.01979,-293.937177 -367.149854,-289.051048 C -355.343719,-283.74409 -343.522009,-278.26089 -331.692086,-272.665067 C -318.963322,-266.644073 -306.225051,-260.492693 -293.48194,-254.263696 C -279.88052,-247.615147 -266.273587,-240.878173 -252.664091,-234.095751 C -238.238065,-226.906402 -223.809159,-219.665987 -209.379903,-212.417551 C -194.175885,-204.777738 -178.971635,-197.135641 -163.767154,-189.491259 C -147.290706,-181.209732 -130.81417,-172.933298 -114.327757,-164.445833 C -96.638051,-155.338894 -78.936973,-145.988995 -61.23183,-136.489711 C -42.83628,-126.618999 -24.437139,-116.590472 -6.034405,-106.404129 C 12.988826,-95.875361 32.015044,-85.181632 51.041971,-74.350743 C 90.663309,-51.796303 130.287184,-28.649245 169.904219,-5.058022 C 211.090446,19.468633 252.269483,44.474456 293.437331,69.839459 C 335.517614,95.767754 377.586396,122.072076 419.642854,148.660515 C 503.76059,201.847286 587.828904,256.147719 671.854879,311.120502 C 751.312767,363.113312 830.733706,415.715977 910.121501,468.777679 C 995.01224,525.524664 1079.865697,582.797171 1164.674248,640.617824 C 1192.283028,659.441198 1219.886541,678.326945 1247.484787,697.275067 C 1270.73957,713.242184 1293.996044,729.258338 1317.241888,745.321373 C 1327.107887,752.139255 1336.976715,758.968255 1346.848371,765.808374 C 1355.493823,771.798596 1364.146132,777.799924 1372.767724,783.793106 C 1380.127277,788.908999 1387.464446,794.018955 1394.889693,799.180553 C 1401.009487,803.434676 1407.189112,807.723879 1413.119965,811.887911 C 1417.7599,815.145595 1422.247573,818.326668 1427.380659,821.845665 C 1431.063255,824.370277 1435.078042,827.068818 1437.610752,828.998585 C 1439.134823,830.159833 1440.12222,831.0427 1443.76641,833.306567 C 1444.982916,834.062292 1446.495484,834.97191 1445.821235,834.74528" />
|
||||
<path d="M 4088.698422,-2201.97176 C 4071.863286,-2209.539657 4054.994642,-2217.480665 4038.106206,-2225.738798 C 4022.573261,-2233.338843 4007.022586,-2241.206509 3991.458597,-2249.421198 C 3983.516432,-2253.614748 3975.570735,-2257.900919 3967.622793,-2262.30905 C 3960.261943,-2266.392612 3952.898981,-2270.579106 3945.536956,-2274.915202 C 3943.845675,-2275.912386 3942.154454,-2276.917073 3940.463292,-2277.929262 C 3938.829503,-2278.906677 3937.195728,-2279.889856 3935.56293,-2280.905309 C 3931.124503,-2283.667466 3926.692829,-2286.658395 3922.292205,-2289.898531 C 3918.777241,-2292.48891 3915.280436,-2295.235265 3911.829585,-2298.184808 C 3910.073846,-2299.687297 3908.329767,-2301.242202 3906.611091,-2302.876449 C 3905.350277,-2304.077409 3904.101247,-2305.31876 3902.891914,-2306.64651 C 3902.453922,-2307.124054 3902.026791,-2307.61984 3901.610521,-2308.133868 C 3901.299294,-2308.518314 3901.002293,-2308.919081 3900.719517,-2309.336169 C 3900.527397,-2309.621912 3900.344384,-2309.916518 3900.220419,-2310.243794 C 3900.138415,-2310.46029 3900.08225,-2310.691082 3900.113043,-2310.851579 C 3900.143825,-2311.012017 3900.2615,-2311.10221 3900.393136,-2311.146406 C 3900.591347,-2311.212954 3900.82121,-2311.175213 3901.054853,-2311.119196 C 3901.395934,-2311.034587 3901.742062,-2310.920466 3902.093238,-2310.776833 C 3902.560145,-2310.589421 3903.03076,-2310.365917 3903.501882,-2310.129577 C 3904.091767,-2309.832167 3904.682134,-2309.519273 3905.272983,-2309.190897 C 3906.803549,-2308.34235 3908.334632,-2307.429826 3909.864882,-2306.507962 C 3911.837632,-2305.316998 3913.809818,-2304.101833 3915.782074,-2302.891418 C 3918.161608,-2301.426976 3920.541478,-2299.976332 3922.921685,-2298.539486 C 3927.169463,-2295.979761 3931.419103,-2293.492922 3935.667474,-2291.12314 C 3937.263565,-2290.232961 3938.85938,-2289.359703 3940.455335,-2288.478295 C 3942.141204,-2287.546398 3943.827175,-2286.607933 3945.51325,-2285.6629 C 3952.851904,-2281.554558 3960.1915,-2277.358362 3967.530929,-2273.121841 C 3975.456854,-2268.545879 3983.382716,-2263.92158 3991.308409,-2259.279052 C 4006.85028,-2250.172017 4022.391679,-2241.00337 4037.933217,-2231.854444 C 4045.982183,-2227.115049 4054.031164,-2222.377487 4062.08016,-2217.64176 C 4066.897545,-2214.806906 4071.71493,-2211.972052 4076.532315,-2209.137199 C 4078.284341,-2208.105943 4080.036161,-2207.074566 4081.787775,-2206.043066 C 4082.482313,-2205.633843 4083.180026,-2205.222922 4083.880913,-2204.810304 C 4084.459844,-2204.469581 4085.044497,-2204.12585 4085.605526,-2203.794355 C 4086.04433,-2203.535079 4086.468682,-2203.283289 4086.954417,-2202.999576 C 4087.302828,-2202.796074 4087.682821,-2202.576147 4087.921957,-2202.429398 C 4088.065839,-2202.341102 4088.15873,-2202.279298 4088.504102,-2202.086294 C 4088.619387,-2202.02187 4088.762804,-2201.942827 4088.698422,-2201.97176" />
|
||||
<path d="M -538.815367,-311.085197 L -91.009814,-565.713777 L 357.94024,-813.228178 L 802.388988,-1050.515762 L 1236.747229,-1274.592501 L 1655.552655,-1482.6405 L 2053.538547,-1672.043435 L 2425.7,-1840.419453 L 2767.356868,-1985.651129 L 3074.212618,-2105.912089 L 3342.40836,-2199.689984 L 3568.571378,-2265.8055 L 3749.85754,-2303.427198 L 3883.987068,-2312.081962 L 3969.273206,-2291.660954" />
|
||||
<path d="M 3969.273206,-2291.660954 L 4106.195866,-2183.789653 L 4191.480731,-2048.45593 L 4224.055294,-1887.361685 L 4203.50991,-1702.532772 L 4130.102951,-1496.293521 L 4004.75755,-1271.237512 L 3829.05,-1030.194953 L 3605.189924,-776.197095 L 3335.992494,-512.438108 L 3024.843022,-242.234914 L 2675.654394,31.014524 L 2292.817859,303.873937 L 1881.14781,572.911958 L 1445.821235,834.74528" />
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 7.9 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 54 KiB After Width: | Height: | Size: 54 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 26 KiB |
|
|
@ -15,6 +15,7 @@ Cheat Sheet
|
|||
|
||||
.. grid-item-card:: 1D - BuildLine
|
||||
|
||||
| :class:`~objects_curve.Airfoil`
|
||||
| :class:`~objects_curve.ArcArcTangentArc`
|
||||
| :class:`~objects_curve.ArcArcTangentLine`
|
||||
| :class:`~objects_curve.Bezier`
|
||||
|
|
|
|||
68
docs/heart_token.py
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
# [Code]
|
||||
from build123d import *
|
||||
from ocp_vscode import show
|
||||
|
||||
# Create the edges of one half the heart surface
|
||||
l1 = JernArc((0, 0), (1, 1.4), 40, -17)
|
||||
l2 = JernArc(l1 @ 1, l1 % 1, 4.5, 175)
|
||||
l3 = IntersectingLine(l2 @ 1, l2 % 1, other=Edge.make_line((0, 0), (0, 20)))
|
||||
l4 = ThreePointArc(l3 @ 1, (0, 0, 1.5) + (l3 @ 1 + l1 @ 0) / 2, l1 @ 0)
|
||||
heart_half = Wire([l1, l2, l3, l4])
|
||||
# [SurfaceEdges]
|
||||
|
||||
# Create a point elevated off the center
|
||||
surface_pnt = l2.arc_center + (0, 0, 1.5)
|
||||
# [SurfacePoint]
|
||||
|
||||
# Create the surface from the edges and point
|
||||
top_right_surface = Pos(Z=0.5) * -Face.make_surface(heart_half, [surface_pnt])
|
||||
# [Surface]
|
||||
|
||||
# Use the mirror method to create the other top and bottom surfaces
|
||||
top_left_surface = top_right_surface.mirror(Plane.YZ)
|
||||
bottom_right_surface = top_right_surface.mirror(Plane.XY)
|
||||
bottom_left_surface = -top_left_surface.mirror(Plane.XY)
|
||||
# [Surfaces]
|
||||
|
||||
# Create the left and right sides
|
||||
left_wire = Wire([l3, l2, l1])
|
||||
left_side = Pos(Z=-0.5) * Shell.extrude(left_wire, (0, 0, 1))
|
||||
right_side = left_side.mirror(Plane.YZ)
|
||||
# [Sides]
|
||||
|
||||
# Put all of the faces together into a Shell/Solid
|
||||
heart = Solid(
|
||||
Shell(
|
||||
[
|
||||
top_right_surface,
|
||||
top_left_surface,
|
||||
bottom_right_surface,
|
||||
bottom_left_surface,
|
||||
left_side,
|
||||
right_side,
|
||||
]
|
||||
)
|
||||
)
|
||||
# [Solid]
|
||||
|
||||
# Build a frame around the heart
|
||||
with BuildPart() as heart_token:
|
||||
with BuildSketch() as outline:
|
||||
with BuildLine():
|
||||
add(l1)
|
||||
add(l2)
|
||||
add(l3)
|
||||
Line(l3 @ 1, l1 @ 0)
|
||||
make_face()
|
||||
mirror(about=Plane.YZ)
|
||||
center = outline.sketch
|
||||
offset(amount=2, kind=Kind.INTERSECTION)
|
||||
add(center, mode=Mode.SUBTRACT)
|
||||
extrude(amount=2, both=True)
|
||||
add(heart)
|
||||
|
||||
heart_token.part.color = "Red"
|
||||
|
||||
show(heart_token)
|
||||
# [End]
|
||||
# export_gltf(heart_token.part, "heart_token.glb", binary=True)
|
||||
|
|
@ -29,60 +29,38 @@
|
|||
: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
|
||||
.. literalinclude:: ../examples/tea_cup.py
|
||||
:start-after: [Code]
|
||||
:end-before: [End]
|
||||
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -76,6 +76,13 @@ The following objects all can be used in BuildLine contexts. Note that
|
|||
|
||||
.. grid:: 3
|
||||
|
||||
.. grid-item-card:: :class:`~objects_curve.Airfoil`
|
||||
|
||||
.. image:: assets/example_airfoil.svg
|
||||
|
||||
+++
|
||||
Airfoil described by 4 digit NACA profile
|
||||
|
||||
.. grid-item-card:: :class:`~objects_curve.Bezier`
|
||||
|
||||
.. image:: assets/bezier_curve_example.svg
|
||||
|
|
@ -228,6 +235,7 @@ Reference
|
|||
.. py:module:: objects_curve
|
||||
|
||||
.. autoclass:: BaseLineObject
|
||||
.. autoclass:: Airfoil
|
||||
.. autoclass:: Bezier
|
||||
.. autoclass:: BlendCurve
|
||||
.. autoclass:: CenterArc
|
||||
|
|
|
|||
77
docs/spitfire_wing_gordon.py
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
"""
|
||||
Supermarine Spitfire Wing
|
||||
"""
|
||||
|
||||
# [Code]
|
||||
|
||||
from build123d import *
|
||||
from ocp_vscode import show
|
||||
|
||||
wing_span = 36 * FT + 10 * IN
|
||||
wing_leading = 2.5 * FT
|
||||
wing_trailing = wing_span / 4 - wing_leading
|
||||
wing_leading_fraction = wing_leading / (wing_leading + wing_trailing)
|
||||
wing_tip_section = wing_span / 2 - 1 * IN # distance from root to last section
|
||||
|
||||
# Create leading and trailing edges
|
||||
leading_edge = EllipticalCenterArc(
|
||||
(0, 0), wing_span / 2, wing_leading, start_angle=270, end_angle=360
|
||||
)
|
||||
trailing_edge = EllipticalCenterArc(
|
||||
(0, 0), wing_span / 2, wing_trailing, start_angle=0, end_angle=90
|
||||
)
|
||||
|
||||
# [AirfoilSizes]
|
||||
# Calculate the airfoil sizes from the leading/trailing edges
|
||||
airfoil_sizes = []
|
||||
for i in [0, 1]:
|
||||
tip_axis = Axis(i * (wing_tip_section, 0, 0), (0, 1, 0))
|
||||
leading_pnt = leading_edge.intersect(tip_axis)[0]
|
||||
trailing_pnt = trailing_edge.intersect(tip_axis)[0]
|
||||
airfoil_sizes.append(trailing_pnt.Y - leading_pnt.Y)
|
||||
|
||||
# [Airfoils]
|
||||
# Create the root and tip airfoils - note that they are different NACA profiles
|
||||
airfoil_root = Plane.YZ * scale(
|
||||
Airfoil("2213").translate((-wing_leading_fraction, 0, 0)), airfoil_sizes[0]
|
||||
)
|
||||
airfoil_tip = (
|
||||
Plane.YZ
|
||||
* Pos(Z=wing_tip_section)
|
||||
* scale(Airfoil("2205").translate((-wing_leading_fraction, 0, 0)), airfoil_sizes[1])
|
||||
)
|
||||
|
||||
# [Profiles]
|
||||
# Create the Gordon surface profiles and guides
|
||||
profiles = airfoil_root.edges() + airfoil_tip.edges()
|
||||
profiles.append(leading_edge @ 1) # wing tip
|
||||
guides = [leading_edge, trailing_edge]
|
||||
# Create the wing surface as a Gordon Surface
|
||||
wing_surface = -Face.make_gordon_surface(profiles, guides)
|
||||
# Create the root of the wing
|
||||
wing_root = -Face(Wire(wing_surface.edges().filter_by(Edge.is_closed)))
|
||||
|
||||
# [Solid]
|
||||
# Create the wing Solid
|
||||
wing = Solid(Shell([wing_surface, wing_root]))
|
||||
wing.color = 0x99A3B9 # Azure Blue
|
||||
|
||||
show(wing)
|
||||
# [End]
|
||||
# Documentation artifact generation
|
||||
# wing_control_edges = Curve(
|
||||
# [airfoil_root, airfoil_tip, Vertex(leading_edge @ 1), leading_edge, trailing_edge]
|
||||
# )
|
||||
# visible, _ = wing_control_edges.project_to_viewport((50 * FT, -50 * FT, 50 * FT))
|
||||
# max_dimension = max(*Compound(children=visible).bounding_box().size)
|
||||
# svg = ExportSVG(scale=100 / max_dimension)
|
||||
# svg.add_shape(visible)
|
||||
# svg.write("assets/surface_modeling/spitfire_wing_profiles_guides.svg")
|
||||
|
||||
# export_gltf(
|
||||
# wing,
|
||||
# "assets/surface_modeling/spitfire_wing.glb",
|
||||
# binary=True,
|
||||
# linear_deflection=0.1,
|
||||
# angular_deflection=1,
|
||||
# )
|
||||
106
docs/tutorial_spitfire_wing_gordon.rst
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
#############################################
|
||||
Tutorial: Spitfire Wing with Gordon Surface
|
||||
#############################################
|
||||
|
||||
In this advanced tutorial we construct a Supermarine Spitfire wing as a
|
||||
:meth:`~topology.Face.make_gordon_surface`—a powerful technique for surfacing
|
||||
from intersecting *profiles* and *guides*. A Gordon surface blends a grid of
|
||||
curves into a smooth, coherent surface as long as the profiles and guides
|
||||
intersect consistently.
|
||||
|
||||
.. note::
|
||||
Gordon surfaces work best when *each profile intersects each guide exactly
|
||||
once*, producing a well‑formed curve network.
|
||||
|
||||
Overview
|
||||
========
|
||||
|
||||
We will:
|
||||
|
||||
1. Define overall wing dimensions and elliptic leading/trailing edge guide curves
|
||||
2. Sample the guides to size the root and tip airfoils (different NACA profiles)
|
||||
3. Build the Gordon surface from the airfoil *profiles* and wing‑edge *guides*
|
||||
4. Close the root with a planar face and build the final :class:`~topology.Solid`
|
||||
|
||||
.. raw:: html
|
||||
|
||||
<script type="module" src="https://unpkg.com/@google/model-viewer/dist/model-viewer.min.js"></script>
|
||||
<model-viewer poster="_images/spitfire_wing.png" src="_static/spitfire_wing.glb" alt="A tea cup modelled in build123d" auto-rotate camera-controls style="width: 100%; height: 50vh;"></model-viewer>
|
||||
|
||||
Step 1 — Dimensions and guide curves
|
||||
====================================
|
||||
|
||||
We model a single wing (half‑span), with an elliptic leading and trailing edge.
|
||||
These two edges act as the *guides* for the Gordon surface.
|
||||
|
||||
.. literalinclude:: spitfire_wing_gordon.py
|
||||
:start-after: [Code]
|
||||
:end-before: [AirfoilSizes]
|
||||
|
||||
|
||||
Step 2 — Root and tip airfoil sizing
|
||||
====================================
|
||||
|
||||
We intersect the guides with planes normal to the span to size the airfoil sections.
|
||||
The resulting chord lengths define uniform scales for each airfoil curve.
|
||||
|
||||
.. literalinclude:: spitfire_wing_gordon.py
|
||||
:start-after: [AirfoilSizes]
|
||||
:end-before: [Airfoils]
|
||||
|
||||
Step 3 — Build airfoil profiles (root and tip)
|
||||
==============================================
|
||||
|
||||
We place two different NACA airfoils on :data:`Plane.YZ`—with the airfoil origins
|
||||
shifted so the leading edge fraction is aligned—then scale to the chord lengths
|
||||
from Step 2.
|
||||
|
||||
.. literalinclude:: spitfire_wing_gordon.py
|
||||
:start-after: [Airfoils]
|
||||
:end-before: [Profiles]
|
||||
|
||||
|
||||
Step 4 — Gordon surface construction
|
||||
====================================
|
||||
|
||||
A Gordon surface needs *profiles* and *guides*. Here the airfoil edges are the
|
||||
profiles; the elliptic edges are the guides. We also add the wing tip section
|
||||
so the profile grid closes at the tip.
|
||||
|
||||
.. literalinclude:: spitfire_wing_gordon.py
|
||||
:start-after: [Profiles]
|
||||
:end-before: [Solid]
|
||||
|
||||
.. image:: ./assets/surface_modeling/spitfire_wing_profiles_guides.svg
|
||||
:align: center
|
||||
:alt: Elliptic leading/trailing guides
|
||||
|
||||
|
||||
Step 5 — Cap the root and create the solid
|
||||
==========================================
|
||||
|
||||
We extract the closed root edge loop, make a planar cap, and form a solid shell.
|
||||
|
||||
.. literalinclude:: spitfire_wing_gordon.py
|
||||
:start-after: [Solid]
|
||||
:end-before: [End]
|
||||
|
||||
.. image:: ./assets/surface_modeling/spitfire_wing.png
|
||||
:align: center
|
||||
:alt: Final wing solid
|
||||
|
||||
Tips for robust Gordon surfaces
|
||||
-------------------------------
|
||||
|
||||
- Ensure each profile intersects each guide once and only once
|
||||
- Keep the curve network coherent (no duplicated or missing intersections)
|
||||
- When possible, reuse the same :class:`~topology.Edge` objects across adjacent faces
|
||||
|
||||
Complete listing
|
||||
================
|
||||
|
||||
For convenience, here is the full script in one block:
|
||||
|
||||
.. literalinclude:: spitfire_wing_gordon.py
|
||||
:start-after: [Code]
|
||||
:end-before: [End]
|
||||
125
docs/tutorial_surface_heart_token.rst
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
##################################
|
||||
Tutorial: Heart Token (Basics)
|
||||
##################################
|
||||
|
||||
This hands‑on tutorial introduces the fundamentals of surface modeling by building
|
||||
a heart‑shaped token from a small set of non‑planar faces. We’ll create
|
||||
non‑planar surfaces, mirror them, add side faces, and assemble a closed shell
|
||||
into a solid.
|
||||
|
||||
As described in the `topology_` section, a BREP model consists of vertices, edges, faces,
|
||||
and other elements that define the boundary of an object. When creating objects with
|
||||
non-planar faces, it is often more convenient to explicitly create the boundary faces of
|
||||
the object. To illustrate this process, we will create the following game token:
|
||||
|
||||
.. raw:: html
|
||||
|
||||
<script type="module" src="https://unpkg.com/@google/model-viewer/dist/model-viewer.min.js"></script>
|
||||
<model-viewer poster="_images/heart_token.png" src="_static/heart_token.glb" alt="Game Token" auto-rotate camera-controls style="width: 100%; height: 50vh;"></model-viewer>
|
||||
|
||||
Useful :class:`~topology.Face` creation methods include
|
||||
:meth:`~topology.Face.make_surface`, :meth:`~topology.Face.make_bezier_surface`,
|
||||
and :meth:`~topology.Face.make_surface_from_array_of_points`. See the
|
||||
:doc:`surface_modeling` overview for the full list.
|
||||
|
||||
In this case, we'll use the ``make_surface`` method, providing it with the edges that define
|
||||
the perimeter of the surface and a central point on that surface.
|
||||
|
||||
To create the perimeter, we'll define the perimeter edges. Since the heart is
|
||||
symmetric, we'll only create half of its surface here:
|
||||
|
||||
.. literalinclude:: heart_token.py
|
||||
:start-after: [Code]
|
||||
:end-before: [SurfaceEdges]
|
||||
|
||||
Note that ``l4`` is not in the same plane as the other lines; it defines the center line
|
||||
of the heart and archs up off ``Plane.XY``.
|
||||
|
||||
.. image:: ./assets/surface_modeling/token_heart_perimeter.png
|
||||
:align: center
|
||||
:alt: token perimeter
|
||||
|
||||
In preparation for creating the surface, we'll define a point on the surface:
|
||||
|
||||
.. literalinclude:: heart_token.py
|
||||
:start-after: [SurfaceEdges]
|
||||
:end-before: [SurfacePoint]
|
||||
|
||||
We will then use this point to create a non-planar ``Face``:
|
||||
|
||||
.. literalinclude:: heart_token.py
|
||||
:start-after: [SurfacePoint]
|
||||
:end-before: [Surface]
|
||||
|
||||
.. image:: ./assets/surface_modeling/token_half_surface.png
|
||||
:align: center
|
||||
:alt: token perimeter
|
||||
|
||||
Note that the surface was raised up by 0.5 using an Algebra expression with Pos. Also,
|
||||
note that the ``-`` in front of ``Face`` simply flips the face normal so that the colored
|
||||
side is up, which isn't necessary but helps with viewing.
|
||||
|
||||
Now that one half of the top of the heart has been created, the remainder of the top
|
||||
and bottom can be created by mirroring:
|
||||
|
||||
.. literalinclude:: heart_token.py
|
||||
:start-after: [Surface]
|
||||
:end-before: [Surfaces]
|
||||
|
||||
The sides of the heart are going to be created by extruding the outside of the perimeter
|
||||
as follows:
|
||||
|
||||
.. literalinclude:: heart_token.py
|
||||
:start-after: [Surfaces]
|
||||
:end-before: [Sides]
|
||||
|
||||
.. image:: ./assets/surface_modeling/token_sides.png
|
||||
:align: center
|
||||
:alt: token sides
|
||||
|
||||
With the top, bottom, and sides, the complete boundary of the object is defined. We can
|
||||
now put them together, first into a :class:`~topology.Shell` and then into a
|
||||
:class:`~topology.Solid`:
|
||||
|
||||
.. literalinclude:: heart_token.py
|
||||
:start-after: [Sides]
|
||||
:end-before: [Solid]
|
||||
|
||||
.. image:: ./assets/surface_modeling/token_heart_solid.png
|
||||
:align: center
|
||||
:alt: token heart solid
|
||||
|
||||
.. note::
|
||||
When creating a Solid from a Shell, the Shell must be "water-tight," meaning it
|
||||
should have no holes. For objects with complex Edges, it's best practice to reuse
|
||||
Edges in adjoining Faces whenever possible to avoid slight mismatches that can
|
||||
create openings.
|
||||
|
||||
Finally, we'll create the frame around the heart as a simple extrusion of a planar
|
||||
shape defined by the perimeter of the heart and merge all of the components together:
|
||||
|
||||
.. literalinclude:: heart_token.py
|
||||
:start-after: [Solid]
|
||||
:end-before: [End]
|
||||
|
||||
Note that an additional planar line is used to close ``l1`` and ``l3`` so a ``Face``
|
||||
can be created. The :func:`~operations_generic.offset` function defines the outside of
|
||||
the frame as a constant distance from the heart itself.
|
||||
|
||||
Summary
|
||||
-------
|
||||
|
||||
In this tutorial, we've explored surface modeling techniques to create a non-planar
|
||||
heart-shaped object using build123d. By utilizing methods from the :class:`~topology.Face`
|
||||
class, such as :meth:`~topology.Face.make_surface`, we constructed the perimeter and
|
||||
central point of the surface. We then assembled the complete boundary of the object
|
||||
by creating the top, bottom, and sides, and combined them into a :class:`~topology.Shell`
|
||||
and eventually a :class:`~topology.Solid`. Finally, we added a frame around the heart
|
||||
using the :func:`~operations_generic.offset` function to maintain a constant distance
|
||||
from the heart.
|
||||
|
||||
Next steps
|
||||
----------
|
||||
|
||||
Continue to :doc:`tutorial_heart_token` for an advanced example using
|
||||
:meth:`~topology.Face.make_gordon_surface` to create a Supermarine Spitfire wing.
|
||||
|
|
@ -1,156 +1,55 @@
|
|||
################
|
||||
#################
|
||||
Surface Modeling
|
||||
################
|
||||
#################
|
||||
|
||||
Surface modeling is employed to create objects with non-planar surfaces that can't be
|
||||
generated using functions like :func:`~operations_part.extrude`,
|
||||
:func:`~operations_generic.sweep`, or :func:`~operations_part.revolve`. Since there are no
|
||||
specific builders designed to assist with the creation of non-planar surfaces or objects,
|
||||
the following should be considered a more advanced technique.
|
||||
|
||||
As described in the `topology_` section, a BREP model consists of vertices, edges, faces,
|
||||
and other elements that define the boundary of an object. When creating objects with
|
||||
non-planar faces, it is often more convenient to explicitly create the boundary faces of
|
||||
the object. To illustrate this process, we will create the following game token:
|
||||
Surface modeling refers to the direct creation and manipulation of the skin of a 3D
|
||||
object—its bounding faces—rather than starting from volumetric primitives or solid
|
||||
operations.
|
||||
|
||||
.. raw:: html
|
||||
Instead of defining a shape by extruding or revolving a 2D profile to fill a volume,
|
||||
surface modeling focuses on building the individual curved or planar faces that together
|
||||
define the outer boundary of a part. This approach allows for precise control of complex
|
||||
freeform geometry such as aerodynamic surfaces, boat hulls, or organic transitions that
|
||||
cannot easily be expressed with simple parametric solids.
|
||||
|
||||
<script type="module" src="https://unpkg.com/@google/model-viewer/dist/model-viewer.min.js"></script>
|
||||
<model-viewer poster="_images/heart_token.png" src="_static/heart_token.glb" alt="Game Token" auto-rotate camera-controls style="width: 100%; height: 50vh;"></model-viewer>
|
||||
In build123d, as in other CAD kernels based on BREP (Boundary Representation) modeling,
|
||||
all solids are ultimately defined by their boundaries: a hierarchy of faces, edges, and
|
||||
vertices. Each face represents a finite patch of a geometric surface (plane, cylinder,
|
||||
Bézier patch, etc.) bounded by one or more edge loops or wires. When adjacent faces share
|
||||
edges consistently and close into a continuous boundary, they form a manifold
|
||||
:class:`~topology.Shell`—the watertight surface of a volume. If this shell is properly
|
||||
oriented and encloses a finite region of space, the model becomes a solid.
|
||||
|
||||
There are several methods of the :class:`~topology.Face` class that can be used to create
|
||||
non-planar surfaces:
|
||||
Surface modeling therefore operates at the most fundamental level of BREP construction.
|
||||
Rather than relying on higher-level modeling operations to implicitly generate faces,
|
||||
it allows you to construct and connect those faces explicitly. This provides a path to
|
||||
build geometry that blends analytical and freeform shapes seamlessly, with full control
|
||||
over continuity, tangency, and curvature across boundaries.
|
||||
|
||||
* :meth:`~topology.Face.make_bezier_surface`,
|
||||
* :meth:`~topology.Face.make_surface`, and
|
||||
* :meth:`~topology.Face.make_surface_from_array_of_points`.
|
||||
This section provides:
|
||||
- A concise overview of surface‑building tools in build123d
|
||||
- Hands‑on tutorials, from fundamentals to advanced techniques like Gordon surfaces
|
||||
|
||||
In this case, we'll use the ``make_surface`` method, providing it with the edges that define
|
||||
the perimeter of the surface and a central point on that surface.
|
||||
.. rubric:: Available surface methods
|
||||
|
||||
To create the perimeter, we'll use a ``BuildLine`` instance as follows. Since the heart is
|
||||
symmetric, we'll only create half of its surface here:
|
||||
Methods on :class:`~topology.Face` for creating non‑planar surfaces:
|
||||
|
||||
.. code-block:: 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 *water‑tight* or *manifold* (no gaps).
|
||||
|
||||
Finally, we'll create the frame around the heart as a simple extrusion of a planar
|
||||
shape defined by the perimeter of the heart and merge all of the components together:
|
||||
.. toctree::
|
||||
:maxdepth: 1
|
||||
|
||||
.. code-block:: 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.
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -81,6 +81,7 @@ __all__ = [
|
|||
"BuildSketch",
|
||||
# 1D Curve Objects
|
||||
"BaseLineObject",
|
||||
"Airfoil",
|
||||
"Bezier",
|
||||
"BlendCurve",
|
||||
"CenterArc",
|
||||
|
|
|
|||
|
|
@ -38,8 +38,10 @@ from pathlib import Path
|
|||
from typing import Literal, Optional, TextIO, Union
|
||||
import warnings
|
||||
|
||||
from OCP.Bnd import Bnd_Box
|
||||
from OCP.BRep import BRep_Builder
|
||||
from OCP.BRepGProp import BRepGProp
|
||||
from OCP.BRepBndLib import BRepBndLib
|
||||
from OCP.BRepGProp import BRepGProp, BRepGProp_Face
|
||||
from OCP.BRepTools import BRepTools
|
||||
from OCP.GProp import GProp_GProps
|
||||
from OCP.Quantity import Quantity_ColorRGBA
|
||||
|
|
@ -145,38 +147,43 @@ def import_step(filename: PathLike | str | bytes) -> Compound:
|
|||
clean_name = "".join(ch for ch in name if unicodedata.category(ch)[0] != "C")
|
||||
return clean_name.translate(str.maketrans(" .()", "____"))
|
||||
|
||||
def get_color(shape: TopoDS_Shape) -> Quantity_ColorRGBA:
|
||||
"""Get the color - take that of the largest Face if multiple"""
|
||||
def get_shape_color_from_cache(obj: TopoDS_Shape) -> Quantity_ColorRGBA | None:
|
||||
"""Get the color of a shape from a cache"""
|
||||
key = obj.TShape().__hash__()
|
||||
if key in _color_cache:
|
||||
return _color_cache[key]
|
||||
|
||||
def get_col(obj: TopoDS_Shape) -> Quantity_ColorRGBA:
|
||||
col = Quantity_ColorRGBA()
|
||||
if (
|
||||
has_color = (
|
||||
color_tool.GetColor(obj, XCAFDoc_ColorCurv, col)
|
||||
or color_tool.GetColor(obj, XCAFDoc_ColorGen, col)
|
||||
or color_tool.GetColor(obj, XCAFDoc_ColorSurf, col)
|
||||
):
|
||||
return col
|
||||
|
||||
shape_color = get_col(shape)
|
||||
|
||||
colors = {}
|
||||
face_explorer = TopExp_Explorer(shape, TopAbs_FACE)
|
||||
while face_explorer.More():
|
||||
current_face = face_explorer.Current()
|
||||
properties = GProp_GProps()
|
||||
BRepGProp.SurfaceProperties_s(current_face, properties)
|
||||
area = properties.Mass()
|
||||
color = get_col(current_face)
|
||||
if color is not None:
|
||||
colors[area] = color
|
||||
face_explorer.Next()
|
||||
|
||||
# If there are multiple colors, return the one from the largest face
|
||||
if colors:
|
||||
shape_color = sorted(colors.items())[-1][1]
|
||||
)
|
||||
_color_cache[key] = col if has_color else None
|
||||
return _color_cache[key]
|
||||
|
||||
def get_color(shape: TopoDS_Shape) -> Quantity_ColorRGBA | None:
|
||||
"""Get the color - take that of the largest Face if multiple"""
|
||||
shape_color = get_shape_color_from_cache(shape)
|
||||
if shape_color is not None:
|
||||
return shape_color
|
||||
|
||||
max_extent = -1.0
|
||||
winner = None
|
||||
exp = TopExp_Explorer(shape, TopAbs_FACE)
|
||||
while exp.More():
|
||||
face = exp.Current()
|
||||
col = get_shape_color_from_cache(face)
|
||||
if col is not None:
|
||||
box = Bnd_Box()
|
||||
BRepBndLib.Add_s(face, box)
|
||||
extent = box.SquareExtent()
|
||||
if extent > max_extent:
|
||||
max_extent = extent
|
||||
winner = col
|
||||
exp.Next()
|
||||
return winner
|
||||
|
||||
def build_assembly(parent_tdf_label: TDF_Label | None = None) -> list[Shape]:
|
||||
"""Recursively extract object into an assembly"""
|
||||
sub_tdf_labels = TDF_LabelSequence()
|
||||
|
|
@ -211,6 +218,9 @@ def import_step(filename: PathLike | str | bytes) -> Compound:
|
|||
if not os.path.exists(filename):
|
||||
raise FileNotFoundError(filename)
|
||||
|
||||
# Retrieving color info is expensive so cache the lookups
|
||||
_color_cache: dict[int, Quantity_ColorRGBA | None] = {}
|
||||
|
||||
fmt = TCollection_ExtendedString("XCAF")
|
||||
doc = TDocStd_Document(fmt)
|
||||
shape_tool = XCAFDoc_DocumentTool.ShapeTool_s(doc.Main())
|
||||
|
|
|
|||
|
|
@ -29,11 +29,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 (0–1)
|
||||
self.thickness: float = t #: Maximum thickness as fraction of chord
|
||||
self.finite_te: bool = finite_te #: If True, trailing edge is finite
|
||||
|
||||
@property
|
||||
def camber_line(self) -> Edge:
|
||||
"""Camber line of the airfoil as an Edge."""
|
||||
return Edge.make_spline(self._camber_points) # type: ignore[arg-type]
|
||||
|
||||
|
||||
class Bezier(BaseEdgeObject):
|
||||
"""Line Object: Bezier Curve
|
||||
|
||||
|
|
@ -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__(
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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
|
||||
267
tests/test_direct_api/test_constrained_lines.py
Normal file
|
|
@ -0,0 +1,267 @@
|
|||
"""
|
||||
build123d tests
|
||||
|
||||
name: test_constrained_lines.py
|
||||
by: Gumyr
|
||||
date: October 8, 2025
|
||||
|
||||
desc:
|
||||
This python module contains tests for the build123d project.
|
||||
|
||||
license:
|
||||
|
||||
Copyright 2025 Gumyr
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
|
||||
"""
|
||||
|
||||
import math
|
||||
import pytest
|
||||
from OCP.gp import gp_Pnt2d, gp_Dir2d, gp_Lin2d
|
||||
from build123d import Edge, Axis, Vector, Tangency, Plane
|
||||
from build123d.topology.constrained_lines import (
|
||||
_make_2tan_lines,
|
||||
_make_tan_oriented_lines,
|
||||
_edge_from_line,
|
||||
)
|
||||
from build123d.geometry import TOLERANCE
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def unit_circle() -> Edge:
|
||||
"""A simple unit circle centered at the origin on XY."""
|
||||
return Edge.make_circle(1.0, Plane.XY)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# utility tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_edge_from_line():
|
||||
line = _edge_from_line(gp_Pnt2d(0, 0), gp_Pnt2d(1, 0))
|
||||
assert Edge(line).length == 1
|
||||
|
||||
with pytest.raises(RuntimeError) as excinfo:
|
||||
_edge_from_line(gp_Pnt2d(0, 0), gp_Pnt2d(0, 0))
|
||||
assert "Failed to build edge from line contacts" in str(excinfo.value)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _make_2tan_lines tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_two_circles_tangents(unit_circle):
|
||||
"""Tangent lines between two separated circles should yield four results."""
|
||||
c1 = unit_circle
|
||||
c2 = unit_circle.translate((3, 0, 0)) # displaced along X
|
||||
lines = _make_2tan_lines(c1, c2, edge_factory=Edge)
|
||||
# There should be 4 external/internal tangents
|
||||
assert len(lines) in (4, 2)
|
||||
for ln in lines:
|
||||
assert isinstance(ln, Edge)
|
||||
# Tangent lines should not intersect the circle interior
|
||||
dmin = c1.distance_to(ln)
|
||||
assert dmin >= -1e-6
|
||||
|
||||
|
||||
def test_two_constrained_circles_tangents1(unit_circle):
|
||||
"""Tangent lines between two separated circles should yield four results."""
|
||||
c1 = unit_circle
|
||||
c2 = unit_circle.translate((3, 0, 0)) # displaced along X
|
||||
lines = _make_2tan_lines((c1, Tangency.ENCLOSING), c2, edge_factory=Edge)
|
||||
# There should be 2 external/internal tangents
|
||||
assert len(lines) == 2
|
||||
for ln in lines:
|
||||
assert isinstance(ln, Edge)
|
||||
# Tangent lines should not intersect the circle interior
|
||||
dmin = c1.distance_to(ln)
|
||||
assert dmin >= -1e-6
|
||||
|
||||
|
||||
def test_two_constrained_circles_tangents2(unit_circle):
|
||||
"""Tangent lines between two separated circles should yield four results."""
|
||||
c1 = unit_circle
|
||||
c2 = unit_circle.translate((3, 0, 0)) # displaced along X
|
||||
lines = _make_2tan_lines(
|
||||
(c1, Tangency.ENCLOSING), (c2, Tangency.ENCLOSING), edge_factory=Edge
|
||||
)
|
||||
# There should be 1 external/external tangents
|
||||
assert len(lines) == 1
|
||||
for ln in lines:
|
||||
assert isinstance(ln, Edge)
|
||||
# Tangent lines should not intersect the circle interior
|
||||
dmin = c1.distance_to(ln)
|
||||
assert dmin >= -1e-6
|
||||
|
||||
|
||||
def test_curve_and_point_tangent(unit_circle):
|
||||
"""A line tangent to a circle and passing through a point should exist."""
|
||||
pt = Vector(2.0, 0.0)
|
||||
lines = _make_2tan_lines(unit_circle, pt, edge_factory=Edge)
|
||||
assert len(lines) == 2
|
||||
for ln in lines:
|
||||
# The line must pass through the given point (approximately)
|
||||
dist_to_point = ln.distance_to(pt)
|
||||
assert math.isclose(dist_to_point, 0.0, abs_tol=1e-6)
|
||||
# It should also touch the circle at exactly one point
|
||||
dist_to_circle = unit_circle.distance_to(ln)
|
||||
assert math.isclose(dist_to_circle, 0.0, abs_tol=TOLERANCE)
|
||||
|
||||
|
||||
def test_invalid_tangent_raises(unit_circle):
|
||||
"""Non-intersecting degenerate input result in no output."""
|
||||
lines = _make_2tan_lines(unit_circle, unit_circle, edge_factory=Edge)
|
||||
assert len(lines) == 0
|
||||
|
||||
with pytest.raises(RuntimeError) as excinfo:
|
||||
_make_2tan_lines(unit_circle, Vector(0, 0), edge_factory=Edge)
|
||||
assert "Unable to find common tangent line(s)" in str(excinfo.value)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _make_tan_oriented_lines tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize("angle_deg", [math.radians(30), -math.radians(30)])
|
||||
def test_oriented_tangents_with_x_axis(unit_circle, angle_deg):
|
||||
"""Lines tangent to a circle at ±30° from the X-axis."""
|
||||
lines = _make_tan_oriented_lines(unit_circle, Axis.X, angle_deg, edge_factory=Edge)
|
||||
assert all(isinstance(e, Edge) for e in lines)
|
||||
# The tangent lines should all intersect the X axis (red line)
|
||||
for ln in lines:
|
||||
p = ln.position_at(0.5)
|
||||
assert abs(p.Z) < 1e-9
|
||||
|
||||
lines = _make_tan_oriented_lines(unit_circle, Axis.X, 0, edge_factory=Edge)
|
||||
assert len(lines) == 0
|
||||
|
||||
lines = _make_tan_oriented_lines(
|
||||
unit_circle, Axis((0, -2), (1, 0)), 0, edge_factory=Edge
|
||||
)
|
||||
assert len(lines) == 0
|
||||
|
||||
|
||||
def test_oriented_tangents_with_y_axis(unit_circle):
|
||||
"""Lines tangent to a circle and 30° from Y-axis should exist."""
|
||||
angle = math.radians(30)
|
||||
lines = _make_tan_oriented_lines(unit_circle, Axis.Y, angle, edge_factory=Edge)
|
||||
assert len(lines) >= 1
|
||||
# They should roughly touch the circle (tangent distance ≈ 0)
|
||||
for ln in lines:
|
||||
assert unit_circle.distance_to(ln) < 1e-6
|
||||
|
||||
|
||||
def test_oriented_constrained_tangents_with_y_axis(unit_circle):
|
||||
angle = math.radians(30)
|
||||
lines = _make_tan_oriented_lines(
|
||||
(unit_circle, Tangency.ENCLOSING), Axis.Y, angle, edge_factory=Edge
|
||||
)
|
||||
assert len(lines) == 1
|
||||
for ln in lines:
|
||||
assert unit_circle.distance_to(ln) < 1e-6
|
||||
|
||||
|
||||
def test_invalid_oriented_tangent_raises(unit_circle):
|
||||
"""Non-intersecting degenerate input result in no output."""
|
||||
|
||||
with pytest.raises(ValueError) as excinfo:
|
||||
_make_tan_oriented_lines(unit_circle, Axis.Z, 1, edge_factory=Edge)
|
||||
assert "reference Axis can't be perpendicular to Plane.XY" in str(excinfo.value)
|
||||
|
||||
with pytest.raises(ValueError) as excinfo:
|
||||
_make_tan_oriented_lines(
|
||||
unit_circle, Axis((1, 2, 3), (0, 0, -1)), 1, edge_factory=Edge
|
||||
)
|
||||
assert "reference Axis can't be perpendicular to Plane.XY" in str(excinfo.value)
|
||||
|
||||
|
||||
def test_invalid_oriented_tangent(unit_circle):
|
||||
lines = _make_tan_oriented_lines(
|
||||
unit_circle, Axis((1, 0), (0, 1)), 0, edge_factory=Edge
|
||||
)
|
||||
assert len(lines) == 0
|
||||
|
||||
lines = _make_tan_oriented_lines(
|
||||
unit_circle.translate((0, 1 + 1e-7)), Axis.X, 0, edge_factory=Edge
|
||||
)
|
||||
assert len(lines) == 0
|
||||
|
||||
|
||||
def test_make_constrained_lines0(unit_circle):
|
||||
lines = Edge.make_constrained_lines(unit_circle, unit_circle.translate((3, 0, 0)))
|
||||
assert len(lines) == 4
|
||||
for ln in lines:
|
||||
assert unit_circle.distance_to(ln) < 1e-6
|
||||
|
||||
|
||||
def test_make_constrained_lines1(unit_circle):
|
||||
lines = Edge.make_constrained_lines(unit_circle, (3, 0))
|
||||
assert len(lines) == 2
|
||||
for ln in lines:
|
||||
assert unit_circle.distance_to(ln) < 1e-6
|
||||
|
||||
|
||||
def test_make_constrained_lines3(unit_circle):
|
||||
lines = Edge.make_constrained_lines(unit_circle, Axis.X, angle=30)
|
||||
assert len(lines) == 2
|
||||
for ln in lines:
|
||||
assert unit_circle.distance_to(ln) < 1e-6
|
||||
assert abs((ln @ 1).Y) < 1e-6
|
||||
|
||||
|
||||
def test_make_constrained_lines4(unit_circle):
|
||||
lines = Edge.make_constrained_lines(unit_circle, Axis.Y, angle=30)
|
||||
assert len(lines) == 2
|
||||
for ln in lines:
|
||||
assert unit_circle.distance_to(ln) < 1e-6
|
||||
assert abs((ln @ 1).X) < 1e-6
|
||||
|
||||
|
||||
def test_make_constrained_lines5(unit_circle):
|
||||
lines = Edge.make_constrained_lines(
|
||||
(unit_circle, Tangency.ENCLOSING), Axis.Y, angle=30
|
||||
)
|
||||
assert len(lines) == 1
|
||||
for ln in lines:
|
||||
assert unit_circle.distance_to(ln) < 1e-6
|
||||
|
||||
|
||||
def test_make_constrained_lines6(unit_circle):
|
||||
lines = Edge.make_constrained_lines(
|
||||
(unit_circle, Tangency.ENCLOSING), Axis.Y, direction=(1, 1)
|
||||
)
|
||||
assert len(lines) == 1
|
||||
for ln in lines:
|
||||
assert unit_circle.distance_to(ln) < 1e-6
|
||||
|
||||
|
||||
def test_make_constrained_lines_raises(unit_circle):
|
||||
with pytest.raises(TypeError) as excinfo:
|
||||
Edge.make_constrained_lines(unit_circle, Axis.Z, ref_angle=1)
|
||||
assert "Unexpected argument(s): ref_angle" in str(excinfo.value)
|
||||
|
||||
with pytest.raises(TypeError) as excinfo:
|
||||
Edge.make_constrained_lines(unit_circle)
|
||||
assert "Provide exactly 2 tangency targets." in str(excinfo.value)
|
||||
|
||||
with pytest.raises(RuntimeError) as excinfo:
|
||||
Edge.make_constrained_lines(Axis.X, Axis.Y)
|
||||
assert "Unable to find common tangent line(s)" in str(excinfo.value)
|
||||
|
||||
with pytest.raises(TypeError) as excinfo:
|
||||
Edge.make_constrained_lines(unit_circle, ("three", 0))
|
||||
assert "Invalid tangency:" in str(excinfo.value)
|
||||
|
|
@ -37,7 +37,7 @@ from build123d.geometry import Axis, Plane, Vector
|
|||
from build123d.objects_curve import CenterArc, EllipticalCenterArc
|
||||
from build123d.objects_sketch import Circle, Rectangle, RegularPolygon
|
||||
from build123d.operations_generic import sweep
|
||||
from build123d.topology import Edge, Face, Wire
|
||||
from build123d.topology import Edge, Face, Wire, Vertex
|
||||
from OCP.GeomProjLib import GeomProjLib
|
||||
|
||||
|
||||
|
|
@ -183,8 +183,23 @@ class TestEdge(unittest.TestCase):
|
|||
line = Edge.make_line((-2, 0), (2, 0))
|
||||
self.assertAlmostEqual(line.trim(0.25, 0.75).position_at(0), (-1, 0, 0), 5)
|
||||
self.assertAlmostEqual(line.trim(0.25, 0.75).position_at(1), (1, 0, 0), 5)
|
||||
with self.assertRaises(ValueError):
|
||||
line.trim(0.75, 0.25)
|
||||
|
||||
l1 = CenterArc((0, 0), 1, 0, 180)
|
||||
l2 = l1.trim(0, l1 @ 0.5)
|
||||
self.assertAlmostEqual(l2 @ 0, (1, 0, 0), 5)
|
||||
self.assertAlmostEqual(l2 @ 1, (0, 1, 0), 5)
|
||||
|
||||
l3 = l1.trim((1, 0), (0, 1))
|
||||
self.assertAlmostEqual(l3 @ 0, (1, 0, 0), 5)
|
||||
self.assertAlmostEqual(l3 @ 1, (0, 1, 0), 5)
|
||||
|
||||
l4 = l1.trim(0.5, (-1, 0))
|
||||
self.assertAlmostEqual(l4 @ 0, (0, 1, 0), 5)
|
||||
self.assertAlmostEqual(l4 @ 1, (-1, 0, 0), 5)
|
||||
|
||||
l5 = l1.trim(0.5, Vertex(-1, 0))
|
||||
self.assertAlmostEqual(l5 @ 0, (0, 1, 0), 5)
|
||||
self.assertAlmostEqual(l5 @ 1, (-1, 0, 0), 5)
|
||||
|
||||
line.wrapped = None
|
||||
with self.assertRaises(ValueError):
|
||||
|
|
@ -213,6 +228,10 @@ class TestEdge(unittest.TestCase):
|
|||
e4_trim = Edge(a4).trim_to_length(0.5, 2)
|
||||
self.assertAlmostEqual(e4_trim.length, 2, 5)
|
||||
|
||||
e5 = e1.trim_to_length((5, 5), 1)
|
||||
self.assertAlmostEqual(e5 @ 0, (5, 5), 5)
|
||||
self.assertAlmostEqual(e5.length, 1, 5)
|
||||
|
||||
e1.wrapped = None
|
||||
with self.assertRaises(ValueError):
|
||||
e1.trim_to_length(0.1, 2)
|
||||
|
|
|
|||
|
|
@ -31,9 +31,11 @@ import os
|
|||
import platform
|
||||
import random
|
||||
import unittest
|
||||
from unittest.mock import PropertyMock, patch
|
||||
|
||||
from unittest.mock import patch, PropertyMock
|
||||
from OCP.Geom import Geom_RectangularTrimmedSurface
|
||||
from OCP.GeomAPI import GeomAPI_ExtremaCurveCurve
|
||||
|
||||
from build123d.build_common import Locations, PolarLocations
|
||||
from build123d.build_enums import Align, CenterOf, ContinuityLevel, GeomType
|
||||
from build123d.build_line import BuildLine
|
||||
|
|
@ -57,7 +59,6 @@ from build123d.operations_generic import fillet, offset
|
|||
from build123d.operations_part import extrude
|
||||
from build123d.operations_sketch import make_face
|
||||
from build123d.topology import Edge, Face, Shell, Solid, Wire
|
||||
from OCP.GeomAPI import GeomAPI_ExtremaCurveCurve
|
||||
|
||||
|
||||
class TestFace(unittest.TestCase):
|
||||
|
|
@ -359,6 +360,231 @@ class TestFace(unittest.TestCase):
|
|||
self.assertAlmostEqual(loc.position, (0.0, 1.0, 1.5), 5)
|
||||
self.assertAlmostEqual(loc.orientation, (0, -90, 0), 5)
|
||||
|
||||
def test_make_gordon_surface(self):
|
||||
def create_test_curves(
|
||||
num_profiles: int = 3,
|
||||
num_guides: int = 4,
|
||||
u_range: float = 1.0,
|
||||
v_range: float = 1.0,
|
||||
):
|
||||
profiles: list[Edge] = []
|
||||
guides: list[Edge] = []
|
||||
|
||||
intersection_points = [
|
||||
[(0.0, 0.0, 0.0) for _ in range(num_guides)]
|
||||
for _ in range(num_profiles)
|
||||
]
|
||||
|
||||
for i in range(num_profiles):
|
||||
for j in range(num_guides):
|
||||
u = i * u_range / (num_profiles - 1)
|
||||
v = j * v_range / (num_guides - 1)
|
||||
z = 0.2 * math.sin(u * math.pi) * math.cos(v * math.pi)
|
||||
intersection_points[i][j] = (u, v, z)
|
||||
|
||||
for i in range(num_profiles):
|
||||
points = [intersection_points[i][j] for j in range(num_guides)]
|
||||
profiles.append(Spline(points))
|
||||
|
||||
for j in range(num_guides):
|
||||
points = [intersection_points[i][j] for i in range(num_profiles)]
|
||||
guides.append(Spline(points))
|
||||
|
||||
return profiles, guides
|
||||
|
||||
profiles, guides = create_test_curves()
|
||||
|
||||
tolerance = 3e-4
|
||||
gordon_surface = Face.make_gordon_surface(profiles, guides, tolerance=tolerance)
|
||||
|
||||
self.assertIsInstance(
|
||||
gordon_surface, Face, "The returned object should be a Face."
|
||||
)
|
||||
|
||||
def point_at_uv_against_expected(u: float, v: float, expected_point: Vector):
|
||||
point_at_uv = gordon_surface.position_at(u, v)
|
||||
self.assertAlmostEqual(
|
||||
point_at_uv.X,
|
||||
expected_point.X,
|
||||
delta=tolerance,
|
||||
msg=f"X coordinate mismatch at ({u},{v})",
|
||||
)
|
||||
self.assertAlmostEqual(
|
||||
point_at_uv.Y,
|
||||
expected_point.Y,
|
||||
delta=tolerance,
|
||||
msg=f"Y coordinate mismatch at ({u},{v})",
|
||||
)
|
||||
self.assertAlmostEqual(
|
||||
point_at_uv.Z,
|
||||
expected_point.Z,
|
||||
delta=tolerance,
|
||||
msg=f"Z coordinate mismatch at ({u},{v})",
|
||||
)
|
||||
|
||||
point_at_uv_against_expected(
|
||||
u=0.0, v=0.0, expected_point=guides[0].position_at(0.0)
|
||||
)
|
||||
point_at_uv_against_expected(
|
||||
u=1.0, v=0.0, expected_point=profiles[0].position_at(1.0)
|
||||
)
|
||||
point_at_uv_against_expected(
|
||||
u=0.0, v=1.0, expected_point=guides[0].position_at(1.0)
|
||||
)
|
||||
point_at_uv_against_expected(
|
||||
u=1.0, v=1.0, expected_point=profiles[-1].position_at(1.0)
|
||||
)
|
||||
|
||||
temp_curve = profiles[0]
|
||||
profiles[0] = Edge()
|
||||
with self.assertRaises(ValueError):
|
||||
gordon_surface = Face.make_gordon_surface(
|
||||
profiles, guides, tolerance=tolerance
|
||||
)
|
||||
|
||||
profiles[0] = temp_curve
|
||||
guides[0] = Edge()
|
||||
with self.assertRaises(ValueError):
|
||||
gordon_surface = Face.make_gordon_surface(
|
||||
profiles, guides, tolerance=tolerance
|
||||
)
|
||||
|
||||
def test_make_gordon_surface_input_types(self):
|
||||
tolerance = 3e-4
|
||||
|
||||
def point_at_uv_against_expected(u: float, v: float, expected_point: Vector):
|
||||
point_at_uv = gordon_surface.position_at(u, v)
|
||||
self.assertAlmostEqual(
|
||||
point_at_uv.X,
|
||||
expected_point.X,
|
||||
delta=tolerance,
|
||||
msg=f"X coordinate mismatch at ({u},{v})",
|
||||
)
|
||||
self.assertAlmostEqual(
|
||||
point_at_uv.Y,
|
||||
expected_point.Y,
|
||||
delta=tolerance,
|
||||
msg=f"Y coordinate mismatch at ({u},{v})",
|
||||
)
|
||||
self.assertAlmostEqual(
|
||||
point_at_uv.Z,
|
||||
expected_point.Z,
|
||||
delta=tolerance,
|
||||
msg=f"Z coordinate mismatch at ({u},{v})",
|
||||
)
|
||||
|
||||
points = [
|
||||
Vector(0, 0, 0),
|
||||
Vector(10, 0, 0),
|
||||
Vector(12, 20, 1),
|
||||
Vector(4, 22, -1),
|
||||
]
|
||||
|
||||
profiles = [Line(points[0], points[1]), Line(points[3], points[2])]
|
||||
guides = [Line(points[0], points[3]), Line(points[1], points[2])]
|
||||
gordon_surface = Face.make_gordon_surface(profiles, guides, tolerance=tolerance)
|
||||
point_at_uv_against_expected(
|
||||
u=0.5,
|
||||
v=0.5,
|
||||
expected_point=(points[0] + points[1] + points[2] + points[3]) / 4,
|
||||
)
|
||||
|
||||
profiles = [
|
||||
ThreePointArc(
|
||||
points[0], (points[0] + points[1]) / 2 + Vector(0, 0, 2), points[1]
|
||||
),
|
||||
ThreePointArc(
|
||||
points[3], (points[3] + points[2]) / 2 + Vector(0, 0, 3), points[2]
|
||||
),
|
||||
]
|
||||
guides = [
|
||||
Line(profiles[0] @ 0, profiles[1] @ 0),
|
||||
Line(profiles[0] @ 1, profiles[1] @ 1),
|
||||
]
|
||||
gordon_surface = Face.make_gordon_surface(profiles, guides, tolerance=tolerance)
|
||||
point_at_uv_against_expected(u=0.0, v=0.5, expected_point=guides[0] @ 0.5)
|
||||
point_at_uv_against_expected(u=1.0, v=0.5, expected_point=guides[1] @ 0.5)
|
||||
|
||||
profiles = [
|
||||
Edge.make_bezier(
|
||||
points[0],
|
||||
points[0] + Vector(1, 0, 1),
|
||||
points[1] - Vector(1, 0, 1),
|
||||
points[1],
|
||||
),
|
||||
Edge.make_bezier(
|
||||
points[3],
|
||||
points[3] + Vector(1, 0, 1),
|
||||
points[2] - Vector(1, 0, 1),
|
||||
points[2],
|
||||
),
|
||||
]
|
||||
guides = [
|
||||
Line(profiles[0] @ 0, profiles[1] @ 0),
|
||||
Line(profiles[0] @ 1, profiles[1] @ 1),
|
||||
]
|
||||
gordon_surface = Face.make_gordon_surface(profiles, guides, tolerance=tolerance)
|
||||
point_at_uv_against_expected(u=0.0, v=0.5, expected_point=guides[0] @ 0.5)
|
||||
point_at_uv_against_expected(u=1.0, v=0.5, expected_point=guides[1] @ 0.5)
|
||||
|
||||
profiles = [
|
||||
Edge.make_ellipse(10, 6),
|
||||
Edge.make_ellipse(8, 7).translate((1, 2, 10)),
|
||||
]
|
||||
guides = [
|
||||
Line(profiles[0] @ 0, profiles[1] @ 0),
|
||||
Line(profiles[0] @ 0.5, profiles[1] @ 0.5),
|
||||
]
|
||||
gordon_surface = Face.make_gordon_surface(profiles, guides, tolerance=tolerance)
|
||||
point_at_uv_against_expected(u=0.0, v=0.5, expected_point=guides[0] @ 0.5)
|
||||
point_at_uv_against_expected(u=1.0, v=0.5, expected_point=guides[0] @ 0.5)
|
||||
|
||||
profiles = [
|
||||
points[0],
|
||||
ThreePointArc(
|
||||
points[1], (points[1] + points[3]) / 2 + Vector(0, 0, 2), points[3]
|
||||
),
|
||||
points[2],
|
||||
]
|
||||
guides = [
|
||||
Spline(
|
||||
points[0],
|
||||
profiles[1] @ 0,
|
||||
points[2],
|
||||
),
|
||||
Spline(
|
||||
points[0],
|
||||
profiles[1] @ 1,
|
||||
points[2],
|
||||
),
|
||||
]
|
||||
gordon_surface = Face.make_gordon_surface(profiles, guides, tolerance=tolerance)
|
||||
point_at_uv_against_expected(u=0.0, v=1.0, expected_point=guides[0] @ 1)
|
||||
point_at_uv_against_expected(u=1.0, v=1.0, expected_point=guides[1] @ 1)
|
||||
point_at_uv_against_expected(u=1.0, v=0.0, expected_point=points[0])
|
||||
|
||||
profiles = [
|
||||
Line(points[0], points[1]),
|
||||
(points[0] + points[2]) / 2,
|
||||
Line(points[3], points[2]),
|
||||
]
|
||||
guides = [
|
||||
Spline(
|
||||
profiles[0] @ 0,
|
||||
profiles[1],
|
||||
profiles[2] @ 0,
|
||||
),
|
||||
Spline(
|
||||
profiles[0] @ 1,
|
||||
profiles[1],
|
||||
profiles[2] @ 1,
|
||||
),
|
||||
]
|
||||
with self.assertRaises(ValueError):
|
||||
gordon_surface = Face.make_gordon_surface(
|
||||
profiles, guides, tolerance=tolerance
|
||||
)
|
||||
|
||||
def test_make_surface(self):
|
||||
corners = [Vector(x, y) for x in [-50.5, 50.5] for y in [-24.5, 24.5]]
|
||||
net_exterior = Wire(
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||