Repository: damogranlabs/classy_blocks Branch: development Commit: 16e4160b930a Files: 294 Total size: 829.0 KB Directory structure: gitextract_gjyxw34u/ ├── .editorconfig ├── .github/ │ ├── ISSUE_TEMPLATE.md │ └── workflows/ │ └── build_ci.yml ├── .gitignore ├── .pre-commit-config.yaml ├── CHANGELOG.md ├── CITATION.cff ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── docs/ │ └── optimization/ │ └── quality-criteria.ods ├── examples/ │ ├── advanced/ │ │ ├── chop_preserve.py │ │ ├── collapsed.py │ │ ├── edge_grading.py │ │ ├── merged.py │ │ └── project.py │ ├── assembly/ │ │ ├── l_joint.py │ │ ├── n_joint.py │ │ └── t_joint.py │ ├── bug.py │ ├── case/ │ │ ├── Allrun.mesh │ │ ├── case.foam │ │ ├── constant/ │ │ │ └── geometry/ │ │ │ └── terrain.stl │ │ └── system/ │ │ ├── collapseDict │ │ ├── controlDict │ │ ├── fvSchemes │ │ └── fvSolution │ ├── chaining/ │ │ ├── flywheel.py │ │ ├── helmholtz_nozzle.py │ │ ├── labyrinth.py │ │ ├── orifice_plate.py │ │ ├── tank.py │ │ ├── test_tube.py │ │ └── venturi_tube.py │ ├── complex/ │ │ ├── airfoil/ │ │ │ └── airfoil.py │ │ ├── cyclone/ │ │ │ ├── README │ │ │ ├── __init__.py │ │ │ ├── cyclone.py │ │ │ ├── geometry.py │ │ │ ├── parameters.py │ │ │ └── regions/ │ │ │ ├── __init__.py │ │ │ ├── body.py │ │ │ ├── core.py │ │ │ ├── fillaround.py │ │ │ ├── inlet.py │ │ │ ├── inner_ring.py │ │ │ ├── pipe.py │ │ │ ├── region.py │ │ │ └── skirt.py │ │ ├── gear/ │ │ │ ├── gear.py │ │ │ ├── involute_gear.py │ │ │ └── tooth.py │ │ ├── heater/ │ │ │ ├── heater.py │ │ │ └── parameters.py │ │ ├── karman.py │ │ └── plate/ │ │ ├── parameters.py │ │ └── plate.py │ ├── modification/ │ │ └── move_vertex.py │ ├── operation/ │ │ ├── box.py │ │ ├── channel.py │ │ ├── connector.py │ │ ├── extrude.py │ │ ├── loft.py │ │ ├── revolve.py │ │ └── wedge.py │ ├── optimization/ │ │ ├── diffuser_free.py │ │ ├── diffuser_line.py │ │ ├── duct.py │ │ └── simple.py │ ├── shape/ │ │ ├── custom.py │ │ ├── cylinder.py │ │ ├── elbow.py │ │ ├── extruded_ring.py │ │ ├── frustum.py │ │ ├── hemisphere.py │ │ ├── one_core_cylinder.py │ │ ├── quarter_cylinder.py │ │ ├── revolved_ring.py │ │ ├── shell.py │ │ ├── splined/ │ │ │ ├── combined_example.py │ │ │ ├── spline_ring.py │ │ │ ├── spline_ring_whole_half_quarter.py │ │ │ └── spline_round.py │ │ ├── torus.py │ │ └── wrapped_cylinder.py │ ├── stack/ │ │ ├── cube.py │ │ └── fusilli.py │ └── transform/ │ └── mirror.py ├── pyproject.toml ├── src/ │ └── classy_blocks/ │ ├── __init__.py │ ├── assemble/ │ │ ├── __init__.py │ │ ├── assembler.py │ │ ├── depot.py │ │ ├── dump.py │ │ └── settings.py │ ├── base/ │ │ ├── __init__.py │ │ ├── element.py │ │ ├── exceptions.py │ │ └── transforms.py │ ├── cbtyping.py │ ├── construct/ │ │ ├── __init__.py │ │ ├── assemblies/ │ │ │ ├── assembly.py │ │ │ └── joints.py │ │ ├── curves/ │ │ │ ├── __init__.py │ │ │ ├── analytic.py │ │ │ ├── curve.py │ │ │ ├── discrete.py │ │ │ ├── interpolated.py │ │ │ └── interpolators.py │ │ ├── edges.py │ │ ├── flat/ │ │ │ ├── __init__.py │ │ │ ├── face.py │ │ │ ├── sketch.py │ │ │ └── sketches/ │ │ │ ├── __init__.py │ │ │ ├── annulus.py │ │ │ ├── disk.py │ │ │ ├── grid.py │ │ │ ├── mapped.py │ │ │ └── spline_round.py │ │ ├── operations/ │ │ │ ├── __init__.py │ │ │ ├── box.py │ │ │ ├── connector.py │ │ │ ├── extrude.py │ │ │ ├── loft.py │ │ │ ├── operation.py │ │ │ ├── revolve.py │ │ │ └── wedge.py │ │ ├── point.py │ │ ├── series.py │ │ ├── shape.py │ │ ├── shapes/ │ │ │ ├── __init__.py │ │ │ ├── cylinder.py │ │ │ ├── elbow.py │ │ │ ├── frustum.py │ │ │ ├── rings.py │ │ │ ├── round.py │ │ │ ├── shell.py │ │ │ └── sphere.py │ │ └── stack.py │ ├── grading/ │ │ ├── __init__.py │ │ ├── analyze/ │ │ │ ├── __init__.py │ │ │ ├── catalogue.py │ │ │ ├── probe.py │ │ │ └── row.py │ │ ├── define/ │ │ │ ├── __init__.py │ │ │ ├── chop.py │ │ │ ├── collector.py │ │ │ ├── grading.py │ │ │ └── relations.py │ │ └── graders/ │ │ ├── __init__.py │ │ ├── auto.py │ │ ├── fixed.py │ │ ├── inflation.py │ │ ├── manager.py │ │ └── simple.py │ ├── items/ │ │ ├── __init__.py │ │ ├── block.py │ │ ├── edges/ │ │ │ ├── __init__.py │ │ │ ├── arcs/ │ │ │ │ ├── __init__.py │ │ │ │ ├── angle.py │ │ │ │ ├── arc.py │ │ │ │ ├── arc_base.py │ │ │ │ └── origin.py │ │ │ ├── curve.py │ │ │ ├── edge.py │ │ │ ├── factory.py │ │ │ ├── line.py │ │ │ └── project.py │ │ ├── patch.py │ │ ├── side.py │ │ ├── vertex.py │ │ └── wires/ │ │ ├── __init__.py │ │ ├── axis.py │ │ ├── manager.py │ │ └── wire.py │ ├── lists/ │ │ ├── __init__.py │ │ ├── block_list.py │ │ ├── edge_list.py │ │ ├── face_list.py │ │ ├── patch_list.py │ │ └── vertex_list.py │ ├── lookup/ │ │ ├── __init__.py │ │ ├── cell_registry.py │ │ ├── connection_registry.py │ │ ├── face_registry.py │ │ └── point_registry.py │ ├── mesh.py │ ├── modify/ │ │ ├── __init__.py │ │ ├── find/ │ │ │ ├── __init__.py │ │ │ ├── finder.py │ │ │ ├── geometric.py │ │ │ └── shape.py │ │ └── reorient/ │ │ ├── __init__.py │ │ └── viewpoint.py │ ├── optimize/ │ │ ├── __init__.py │ │ ├── cell.py │ │ ├── clamps/ │ │ │ ├── __init__.py │ │ │ ├── clamp.py │ │ │ ├── curve.py │ │ │ ├── free.py │ │ │ └── surface.py │ │ ├── connection.py │ │ ├── grid.py │ │ ├── junction.py │ │ ├── links.py │ │ ├── optimizer.py │ │ ├── quality.py │ │ ├── record.py │ │ ├── report.py │ │ └── smoother.py │ ├── util/ │ │ ├── __init__.py │ │ ├── constants.py │ │ ├── frame.py │ │ ├── functions.py │ │ └── tools.py │ └── write/ │ ├── __init__.py │ ├── formats.py │ ├── vtk.py │ └── writer.py ├── tests/ │ ├── __init__.py │ ├── fixtures/ │ │ ├── __init__.py │ │ ├── block.py │ │ ├── data.py │ │ └── mesh.py │ ├── helpers/ │ │ ├── __init__.py │ │ └── collect_outputs.py │ ├── test_assemble.py │ ├── test_bugs/ │ │ ├── __init__.py │ │ └── test_grading.py │ ├── test_construct/ │ │ ├── __init__.py │ │ ├── test_assembly.py │ │ ├── test_chaining.py │ │ ├── test_curves/ │ │ │ ├── __init__.py │ │ │ ├── test_analytic.py │ │ │ ├── test_discrete.py │ │ │ ├── test_interpolated.py │ │ │ └── test_interpolators.py │ │ ├── test_edge_data.py │ │ ├── test_flat/ │ │ │ ├── __init__.py │ │ │ ├── test_annulus.py │ │ │ ├── test_disk.py │ │ │ ├── test_face.py │ │ │ └── test_sketch.py │ │ ├── test_operation/ │ │ │ ├── __init__.py │ │ │ ├── test_box.py │ │ │ ├── test_connector.py │ │ │ ├── test_extrude.py │ │ │ ├── test_operation.py │ │ │ └── test_wedge.py │ │ ├── test_point.py │ │ ├── test_shape.py │ │ ├── test_shell.py │ │ └── test_stack.py │ ├── test_grading/ │ │ ├── __init__.py │ │ ├── test_catalogue.py │ │ ├── test_collector.py │ │ ├── test_edge_grading.py │ │ ├── test_grading.py │ │ ├── test_inflation.py │ │ ├── test_layers.py │ │ ├── test_probe.py │ │ └── test_relations.py │ ├── test_items/ │ │ ├── __init__.py │ │ ├── test_axis.py │ │ ├── test_block.py │ │ ├── test_edge.py │ │ ├── test_patch.py │ │ ├── test_side.py │ │ ├── test_vertex.py │ │ └── test_wire.py │ ├── test_lists/ │ │ ├── __init__.py │ │ ├── test_edge_list.py │ │ ├── test_face_list.py │ │ └── test_patch_list.py │ ├── test_mesh.py │ ├── test_modify/ │ │ ├── __init__.py │ │ ├── test_finder.py │ │ └── test_reorient.py │ ├── test_optimize/ │ │ ├── __init__.py │ │ ├── optimize_fixtures.py │ │ ├── test_cell.py │ │ ├── test_clamps.py │ │ ├── test_grid.py │ │ ├── test_links.py │ │ ├── test_optimizer.py │ │ ├── test_quality.py │ │ └── test_smoother.py │ ├── test_propagation.py │ └── test_util/ │ ├── __init__.py │ ├── test_frame.py │ ├── test_functions.py │ ├── test_imports.py │ └── test_tools.py └── tox.ini ================================================ FILE CONTENTS ================================================ ================================================ FILE: .editorconfig ================================================ # http://editorconfig.org root = true [*] indent_style = space indent_size = 4 trim_trailing_whitespace = true insert_final_newline = true charset = utf-8 end_of_line = lf [*.bat] indent_style = tab end_of_line = crlf [LICENSE] insert_final_newline = false [Makefile] indent_style = tab ================================================ FILE: .github/ISSUE_TEMPLATE.md ================================================ * classy_blocks version: * Python version: * Operating System: ### Description Describe what you were trying to get done. Tell us what happened, what went wrong, and what you expected to happen. ### What I Did ``` Paste the command(s) you ran and the output. If there was a crash, please include the traceback here. ``` ================================================ FILE: .github/workflows/build_ci.yml ================================================ name: Test and analyze on: [ push ] jobs: pytests: strategy: matrix: python-version: ["3.9", "3.10", "3.11", "3.12", "3.13", "3.14"] fail-fast: false runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v4 with: python-version: ${{ matrix.python-version }} - name: Install dependencies run: | python -m pip install -U pip tox - name: pytest run: | tox -e py Analysis: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - name: Set up Python 3.13 uses: actions/setup-python@v4 with: python-version: "3.13" - name: Install dependencies run: | python -m pip install -U pip tox - name: analysis run: | tox -e analysis ================================================ FILE: .gitignore ================================================ # Why not include workspace and its settings/tasks/cfgs in git so others can benefit? #.vscode #*.code-workspace venv_*/ tmp # openfoam case files and results from testing polyMesh/ cellToRegion blockMeshDict log.* *.vtk # Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] *$py.class # OpenFOAM files *.gz log.* postProcessing *~ */processor* */[0-9]* */[0-9]*.[0-9]* */!0.org */constant/extendedFeatureEdgeMesh *.eMesh *polyMesh */constant/cellToRegion *.obj # C extensions *.so # Distribution / packaging .Python build/ develop-eggs/ dist/ downloads/ eggs/ .eggs/ lib/ lib64/ parts/ sdist/ var/ wheels/ pip-wheel-metadata/ share/python-wheels/ *.egg-info/ .installed.cfg *.egg MANIFEST # PyInstaller # Usually these files are written by a python script from a template # before PyInstaller builds the exe, so as to inject date/other infos into it. *.manifest *.spec # Installer logs pip-log.txt pip-delete-this-directory.txt # Unit test / coverage reports htmlcov/ .tox/ .nox/ .coverage .coverage.* .cache nosetests.xml coverage.xml *.cover *.py,cover .hypothesis/ .pytest_cache/ tests/outputs # Translations *.mo *.pot # Django stuff: *.log local_settings.py db.sqlite3 db.sqlite3-journal # Flask stuff: instance/ .webassets-cache # Scrapy stuff: .scrapy # Sphinx documentation docs/_build/ # PyBuilder target/ # Jupyter Notebook .ipynb_checkpoints # IPython profile_default/ ipython_config.py # pyenv .python-version # pipenv # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. # However, in case of collaboration, if having platform-specific dependencies or dependencies # having no cross-platform support, pipenv may install dependencies that don't work, or not # install all needed dependencies. #Pipfile.lock # PEP 582; used by e.g. github.com/David-OConnor/pyflow __pypackages__/ # Celery stuff celerybeat-schedule celerybeat.pid # SageMath parsed files *.sage.py # Environments .env .venv env/ venv/ ENV/ env.bak/ venv.bak/ # Spyder project settings .spyderproject .spyproject # Rope project settings .ropeproject # mkdocs documentation /site # mypy .mypy_cache/ .dmypy.json dmypy.json # Pyre type checker .pyre/ # IDEs on hold .idea .vscode # Mac specific .DS_Store ================================================ FILE: .pre-commit-config.yaml ================================================ # See https://pre-commit.com for more information # See https://pre-commit.com/hooks.html for more hooks repos: - repo: https://github.com/pre-commit/pre-commit-hooks rev: v4.4.0 hooks: - id: trailing-whitespace - id: check-toml - id: end-of-file-fixer - repo: https://github.com/charliermarsh/ruff-pre-commit rev: "v0.9.9" hooks: # Run the linter. - id: ruff args: [--fix] - id: ruff args: [--select, I, --fix] # Run the formatter. - id: ruff-format ================================================ FILE: CHANGELOG.md ================================================ # Changelog All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [1.10.0] ### Added - Edge grading: - Added `Operation.chop_edge(corner_1, corner_2, ...)` that offers fine-control of grading of each edge; see `examples/advanced/edge_grading.py` for more info - Automatic grading: - A complete rewrite of automatic graders (`FixedCountGrader`, `SimpleGrader`) - Resurrected `InflationGrader` and this time it works (proved on the cyclone case) - Example for spline sketches/shapes - Optimization: - Added manual clipping to bounds (scipy's methods often ignore them) ### Changed - Optimization - Bugfix: optimization with links now takes more distant neighbours into account (a problem with certain cases) ### Removed - Automatic grading: removed `SmoothGrader` (proves to be useless in real world and difficult to control); better to use edge grading (see `.chop_edge()`) ## [1.9.6] ### Added - Convenience methods: - `Curve.get_closest_point()` - A new VTK writer for sketches: `classy_blocks.write.vtk.sketch_to_vtk()`, useful for debugging sketches alone - Make `SketchOptimizer` work on all sketches, not just `cb.MappedSketch` ### Changed - Bugfix: mirror a point over an arbitrary plane; also now supports mirroring a point array - Bugfix: when finding points to connect clamps to junctions before optimization, do not call transform() on clamps or it will move them, making the optimizer unable to find actual junctions - Bugfix: ignore optimization iterations with zero or negative improvement ## [1.9.5] ### Added - A WrappedDisk example ### Changed - Bugfix: inner edges on OneCoreDisk - Bugfix: inner edges on WrappedDisk - Bugfix: WrappedDisk chopping instructions ## [1.9.4] ### Added - Reintroduce sorting of vertices according to their influence on mesh quality prior to optimization (a.k.a. sensitivity) ### Changed - Bugfix: rollback on badly initialized clamps - Favour non-ortho/aspect during optimization (produces better meshes) ## [1.9.3] ### Added - `trust-constr` optimization algorithm - pass arbitrary options to `scipy.optimize.minimize` ### Changed - Bugfix: always use grid quality when checking for rollback ## [1.9.2] - Bugfix: mixed grid and junction qualities ## [1.9.1] - Bugfix: multiple projections to the same surface ### Removed - Mapper class (not exposed as a public API) and replaced with lookup classes ## [1.9.0] ### Added - Optimization refactor: Optimizers now have a `.config` attribute that the user can change for fine adjustments - New classy_blocks logo ### Changed - Optimization refactor: quality functions are now more reliable and yield better quality once finished ### Removed - Removed Python 3.8 support ## [1.8.0] ### Added - Hex*/QuadGrid objects now support `merge_tol` for merging approximately coincident points ### Changed - Improvement on performance of mesh assembly (5x+) - Improvement on performance of optimization (20x+) - Minor API change: use `mesh.settings.property` instead of `mesh.settings['property']` - Chopping propagation now doesn't overwrite already defined wires within the same block; it's possible to chop two surrounding blocks and the middle one will have different gradings on appropriate wires - Bugfixes: - Correct center point of OneCoreDisk - Correct inner angle calculation for optimization ## [1.7.1] ### Changed - Bugfix: wrong grading propagation on inverted wires ## [1.7.0] ### Added - Automatic grading: - `FixedCountGrader`: simple and quick, grades all blocks in all directions with the same number. Useful while developing meshes - settings blocking etc. - `SimpleGrader`: quick and good for cases where blocks do not differ much in size. Sets simple-graded counts on blocks based on wanted cell size. - `SmoothGrader`: Tries to stretch and squeeze cells within blocks by using two graded chops in each direction. The idea is to try to keep difference in cell size between neighbouring blocks as little as possible. Blocks that cannot maintain required cell size are simply/uniformly chopped. - Possibility to define rings by their inner radius - Spline round shapes - Cylinders: - Add symmetry patch to SemiCylinder - New examples: - Quarter cylinder - One core cylinder - `ShapeOptimizer` can optimize shapes _before_ they are added to mesh ### Changed - Renamed `classy_blocks.typing` module to `classy_blocks.cbtyping` due to name clash - Bugfixes ## [1.6.4] ### Added - MappedSketch.merge() method ### Changed - Improved blocking in cyclone example ## [1.6.3] ### Changed - Bugfix: sorting of cells by sensitivity - Bugfix: sensitivity calculation moved clamps around - Output of optimization reports - Optimization will now skip cells that were made illegal during optimization ### Removed - Quality caching (produced invalid results and did not speed up optimization) ## [1.6.2] ### Added - QuarterSplineRound and QuarterSplineRoundRing; cylinders and rings with an arbitrary, parametrized spline cross-section - Lofts and Shapes, created with spline side-edges (when multiple sketches for mid-sections are provided) - Examples for splined shapes (as above) ### Changed - Bugfixes ## [1.6.1] ### Added - Assemblies and *Joints - Examples thereof ### Changed - Some bugfixes ## [1.6.0] ### Added - Array element; handling multiple points at once (makes transforms faster) - Complete overhaul of Optimization: - Optimizer has become SketchOptimizer or MeshOptimizer - MappedSketch is now smoothed by SketchSmoother - Mesh can also be smoothed by MeshSmoother - Gear example ### Changed - Cell quality: adjusted calculation so that it works for quadrangles and hexahedrons - Clamps no longer refer to Vertex objects but only store points as locations - Links same as clamps above, locations only ### Removed - QuadMap is no longer needed (handled by Grid classes) ## [1.5.3] ### Added - RoundSolidShape.remove_inner_edges() can now remove edges from a specific face (start, end or both) ### Changed - Bugfix: invalid cells in round shapes (due to wrong spline point calculation) ## [1.5.2] ### Added - Spline edges on QuarterDisk, HalfDisk and Disk (FourCoreDisk) to improve mesh quality - Face.remove_edges() to reset all edges to simple lines - RoundSolidShape.remove_inner_edges() to get rid of splines in case the vertices need to be moved (in optimization, for example) ### Changed - Updated affected tutorials (diffusers, cyclone) ## [1.5.1] ### Added - Optimization: - Choice of solver (different problems require - work best - with different solvers) - Richer output (timing, relative improvement) for easier choice of solver - Curves: - get_param_at_length() - Direct imports of various classes - A new example: custom sketch - Mesh: - assemble(): an option to skip creating edges; most useful when assembling mesh before optimization but later a backport() will be called ### Changed - Improved optimization speed ## [1.5.0] ### Added - Curves: - get_tangent() - get_normal() - get_binormal() - Mapped sketch - Definition of any fixed-blocking sketch - Automatic laplacian smoothing (fixed outer edges, movable inner vertices) - Sketches - OneCoreDisk - FourCoreDisk (the default Disk for Shapes) - WrappedDisk - Oval - WrappedDisk - Grid (A cartesian array of rectangular faces in XY plane) - grid property of Sketch/Shape/Stack - Stacks - LoftedShape: a generic shape from 2 Sketches with the same number of faces - Examples: Heater, Fusilli - Mesh.delete() will omit given operation from blockMeshDict but its data stays in mesh (chops, patches, etc.) ### Changed - Definitions of Disks sketches - All off-the-shelf Shapes are now a LoftedShape - Calling .transform() with a Mirror transformation will warn about creating an inverted block ## [1.4.1] ### Changed - Improved optimization output ### Removed - Relaxation within optimization - Numerical integration of analytic curve lengths; use discretization instead ## [1.4.0] ### Added - Channel example - Cyclone example - Mirror transform on points, operations, shapes, mirror example - Operation: - `get_closest_face()`, `get_closest_side()`, `get_normal_face()` - Connector operation - Geometric finders: find_on_plane() - functions.point_to_line_distance() ### Changed - Optimization improvements: - Default parameters for clamp optimization - Clamps are sorted by sensitivity, not "junction quality" as before (improves optimization speed) - Clamp parameters follow domain scale (Read more below) - Raise an Exception when adding more than one Clamp for the same vertex #### Clamp Parameters Previously: - RadialClamp had a single parameter, the _angle_ of the point (and change thereof) - In Linelamp the parameter _t_ went from 0 to 1 regardless of the distance between points This created difficulties with optimization algorithms with extra large or very small domains. Optimization speed also drastically changing with simply scaling the dimensions. This has been changed: - RadialClamp's parameter is now multiplied with radius so it means actual _distance_ - LineClamp's parameter now goes from 0 to _distance between points_ When working with CurveClamps, this kind of _automatic_ correction cannot be made so it is advisable that parameter is of a similar magnitudes than points' coordinates. ### Removed - Junction.delta() is now handled by optimization automatically ## [1.3.3] ### Added - Airfoil example - Translation and Rotational Link: move together with vertices being optimized (see the airfoil example) ### Changed - Renamed ParametricCurveClamp to CurveClamp (takes Curve object of any kind) - Interpolated curves' indexes are now between 0 and 1 (easier to work with than using len(points) every time) - Optimization driver: - Termination tolerance is now based on initial improvement instead of quality - Relaxation starts at 0.5 by default and increases linearly to 1 in a given number of relaxed iterations ### Removed - Curve.get_closest_param() now finds initial_param automatically ## [1.3.2] Curves ### Added - *Curve objects for dealing with edge specification and optimization ## [1.3.1] Optimization/Backport ### Added - mesh.clear() removes lists of all items that were populated during mesh.assemble() - mesh.backport() updates user supplied operations' points with results of optimization/modification ### Changed - Optimizer: under-relaxation for the first optimization iterations ## [1.3.0] Blocking Optimization ### Added - **Blocking Optimization** - Finders for easier fetching vertices, generated by mesh.assemble() - GeometricFinder lists vertices inside searchable geometric entities - RoundSolidFinder identifies vertices on core/shell of round solids - An Optimizer class that handles blocking optimization - Clamp classes that define degrees of freedom of optimizing points: - Free (3 DoF) - Slide along a curve (line, parametric curve) (1 DoF) - Move on a surface (parametric surface) (2 DoF) - **Reorienting Operations and Faces** - `Face`: - `shift()` method to rotate points around - `reorient()` method that rotates points so that they start nearest to given position - `ViewpointOrienter`: a class for auto-orienting operation's points based on specified points 'in front' and 'above' the operation. ### Changed - Projection Behaviour - Calling .project() on a Point/Vertex will add the new geometry instead of replacing it. - Calling .project_edge() on an Operation will add the new geometry instead of replacing it. - Calling .project_side() on an Operation will add the new geometry to edges instead of replacing them (but will replace existing label for the side) ## [1.2.0] Shell Shape ### Added - A Shell Shape, created from arbitrary set of faces ### Changed - Operation.get_face() will not auto-reorder faces (causes confusion for users) ### Fixed - A bug where Operation.project_face(..., points=True) won't project vertices ## [1. 1. 0] Default Extrude Direction ### Added - Extrude now takes a vector of a float. If a float is given, direction is normal of the base face. ## [1. 0. 0] Refactor A complete overhaul of all objects in an attempt to create a proper SOLID-obeying package with type hinting, static typing and no python-ish duck-typing hacks. ### Added - examples and showcases from `classy_examples` repo - static type analysis, formatting, linting - Origin and Angle edges (Foundation and ESI alternatives to arc) - Projection of vertices to geometry - Import convention `import classy_blocks as cb` and direct imports of user-usable objects from `cb`, like `cb.Mesh, cb.Loft, cb.Arc` - `Operation.faces` property that creates new faces on-the-fly for easier chaining of new operations - A Frame object that simplifies addressing edges/wires/other stuff between pairs of vertices on a hexahedron - ExtrudedRing.fill() method has been added to create cylinders inside rings ### Changed - Major package layout refactor - Edge specification (Arc, Origin, Angle, Project, Spline, PolyLine objects) - Reverted Face specification for operations - The Block object is not directly available to the user as it makes no sense to do so - Import convention: `import classy_blocks as cb` for examples - Changed examples so that an example file runs directly instead of calling run.py (that created a lot of confusion) - Box() is now an operation (previously Shape) - simplified cylinder and sphere creation - Chaining of elbows/cylinders/etc always with start_face parameters (instead of negative length) ### Removed - *Wall shapes will be created later with a different approach - Examples with *Wall shapes will be recreated later with new approaches - airfoil2d example requires blocking optimization so it will be recreated when that feature is available - sphere example will be recreated when 'offset' is available - block.from_points has been removed (use Loft) - T-joint will be added when a skew transform is implemented ## [0.0.1] Status Quo ### Added examples and showcases from classy_examples repo static type analysis, formatting, linting ### Changed Major package layout refactor Major CI refactor ### Removed Some dependencies, docs and other stuff that is not ready ATM ================================================ FILE: CITATION.cff ================================================ cff-version: 1.2.0 message: "If you use this software, please cite it as below." title: classy_blocks abstract: "A Python library for generating block-structured meshes in OpenFOAM and other CFD workflows." repository-code: "https://github.com/damogranlabs/classy_blocks" license: "MIT" doi: 10.5281/zenodo.16785017 authors: - family-names: Jurkovič given-names: Nejc keywords: - "CFD" - "OpenFOAM" - "meshing" - "block-structured" - "blockMesh" - "blockMeshDict" - "geometry" - "modeling" - "hexahedral" - "parametric" ================================================ FILE: CONTRIBUTING.md ================================================ # Contributing Contributions are welcome, and they are greatly appreciated! Every little bit helps, and credit will always be given. You can contribute in many ways: ## Types of Contributions ### Report Bugs Report bugs at [Github Issues](https://github.com/FranzBangar/classy_blocks/issues). If you are reporting a bug, please include: - Your operating system name and version. - Any details about your local setup that might be helpful in troubleshooting. - Detailed steps to reproduce the bug. ### Fix Bugs Look through the GitHub issues for bugs. Anything tagged with \"bug\" and \"help wanted\" is open to whoever wants to implement it. ### Implement Features Look through the GitHub issues for features. Anything tagged with \"enhancement\" and \"help wanted\" is open to whoever wants to implement it. ### Write Documentation classy_blocks could always use more documentation, whether as part of the official classy_blocks docs, in docstrings, or even on the web in blog posts, articles, and such. ### Submit Feedback The best way to send feedback is to file an issue at [Github Issues](https://github.com/FranzBangar/classy_blocks/issues). If you are proposing a feature: - Explain in detail how it would work. - Keep the scope as narrow as possible, to make it easier to implement. - Remember that this is a volunteer-driven project, and that contributions are welcome :) ## Get Started! Ready to contribute? Here\'s how to set up `classy_blocks` for local development. 1. Fork the `classy_blocks` repo on GitHub 2. Clone your fork locally: ``` shell $ git clone git@github.com:your_name_here/classy_blocks.git ``` 3. Create a branch for local development: ``` shell $ git checkout -b name-of-your-bugfix-or-feature ``` Now you can make your changes locally. 4. Prepare and activate virtual environment, update pip: ``` shell $ python -m venv venv $ "venv/bin/activate" $ python -m pip install -U pip ``` 5. Install required dependencies (based on what you want to contribute). - Small fixes like documentation, typo or similar: no need to install anything. Do your change and submit PR. Code/test/examples fixes: install local `classy_block` package and development requirements: ``` shell $ python -m pip install -e .[dev] ``` 6. Install `pre-commit` hook by [official docs](https://pre-commit.com/#3-install-the-git-hook-scripts). ``` shell $ pre-commit install ``` 7. If code changes were made: check that your changes pass tests, typing, formatting and other rules. ``` shell $ python -m pytest $ ruff check --fix src tests $ mypy src tests ``` Make sure you test on all python versions. Help yourself with `tox` configurations. 1. Commit your changes and push your branch to GitHub: ``` shell $ git add . $ git commit -m "Your detailed description of your changes." $ git push origin name-of-your-bugfix-or-feature ``` 2. Submit a pull request through the GitHub website. ## Pull Request Guidelines Before you submit a pull request, check that it meets these guidelines: 1. The pull request should include tests. Check the results of GitHub actions and make sure that nothing was broken. 2. If the pull request adds functionality, the docs should be updated. Put your new functionality into a function with a docstring, and add the feature to the list in [README.md]("https://github.com/damogranlabs/classy_blocks/blob/master/README.md") and/or [CHANGELOG.md](https://github.com/damogranlabs/classy_blocks/blob/master/CHANGELOG.md). ## Uploading To PyPi Just follow the official documentation: - [Generate distribution](https://packaging.python.org/en/latest/tutorials/packaging-projects/#generating-distribution-archives) - [Upload to PyPi](https://packaging.python.org/en/latest/tutorials/packaging-projects/#uploading-the-distribution-archives) ## Tips * To run a subset of tests: ``` shell $ python -m pytest -k TestGrading ``` Follow official `pytest` documentation: https://docs.pytest.org/en/7.3.x/how-to/usage.html#usage * `tox` is not installed by default, as it is only dependency of GitHub Actions. Install it manually if required. ================================================ FILE: LICENSE ================================================ MIT License Copyright (c) 2022, Nejc Jurkovic Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================ ![classy_blocks logo](docs/classy_logo.svg "classy_blocks logo") Python classes for easier creation of OpenFOAM's blockMesh dictionaries. # About `blockMesh` is a very powerful mesher but creating even simple meshes requires a lot of manual work. Attempts to simplify or parametrize blockMeshDicts with `#calc` or `m4` often become cryptic and hard to maintain. `classy_blocks` aims to reduce this overhead by providing a more intuitive workflow, reusable building blocks and automatic helpers for constructing and optimizing block-structured hexahedral meshes. Nonetheless, it is not an automatic mesher, so some geometries are better suited than others. ## Tutorial Check out the [classy_blocks tutorial on damogranlabs.com](https://damogranlabs.com/2023/04/classy_blocks-tutorial-part-1-the-basics/)! ## Useful For ### Fields - Turbomachinery (impellers, propellers) - Microfluidics - Flow around buildings - Heat transfer (PCB models, heatsinks) - Airfoils (2D) - Solids (heat transfer, mechanical stresses) ### Cases - Simpler rotational geometry (immersed rotors, mixers, cyclones) - Pipes/channels - Tanks/plenums/containers - External aerodynamics of blunt bodies - Modeling thin geometry (seals, labyrinths) - Parametric studies - Background meshes for snappy (cylindrical, custom) - 2D and axisymmetric cases - Overset meshes ## Not Good For - External aerodynamics of vehicles (too complex to mesh manually, without refinement generates too many cells) - Complex geometry in general - One-off simulations (use automatic meshers) # How To Use It - To install the current _stable_ version from pypi, use `pip install classy_blocks` - To download the cutting-edge development version, install the development branch from Github: `pip install git+https://github.com/damogranlabs/classy_blocks.git@development` - If you want to run examples, follow instructions in [Examples](#examples) - If you want to contribute, follow instructions in [CONTRIBUTING.md](CONTRIBUTING.md) # Features ## Workflow As opposed to blockMesh where the user is expected to manually enter pre-calculated vertices, edges, blocks and whatnot, classy_blocks tries to mimic procedural modeling of modern 3D CAD programs. Here, a Python script contains steps that describe geometry of blocks, their cell count, grading, patches and so on. At the end, the procedure is translated directly to blockMeshDict and no manual editing of the latter should be required. ## Building Elements _Unchecked items are not implemented yet but are on a TODO list_ - [x] Manual definition of a Block with Vertices, Edges and Faces - [x] Operations (Loft, Extrude, Revolve) - [x] Loft - [x] Extrude - [x] Revolve - [x] Wedge (a shortcut to Revolve for 2D axisymmetric cases) - [ ] Three-sided pyramid (for axisymmetric cases through the axis) - [x] Connector (A Loft between two existing Operations) - [x] Sketches (collections of 2D faces) of common cross-sections - [x] Quarter and Semi circle - [x] Circle - [x] Boxed circle - [x] Oval with straight sides - [x] Ellipse (and ovals in various configurations) - [x] Cartesian grid - [x] Simple way of creating custom Sketches - [x] Easy shape creation from Sketches - [x] Predefined Shapes - [x] Box (shortcut to Block aligned with coordinate system) - [x] Elbow (bent pipe of various diameters/cross-sections) - [x] Cone Frustum (truncated cone) - [x] Cylinder - [x] Ring (annulus) - [x] Hemisphere - [x] Stacks (collections of similar Shapes stacked on top of each other) - [x] Predefined parametric Objects - [x] T-joint (round pipes) - [x] X-joint - [x] N-joint (multiple pipes) - [x] Other building tools - [x] Use existing Operation's Face to generate a new Operation - [x] Chain Shape's start/end surface to generate a new Shape - [x] Expand Shape's outer surface to generate a new Shape (Cylinder/Annulus > Annulus) - [x] Contract Shape's inner surface into a new Shape (Annulus > Cylinder/Annulus) - [x] Offset Operation's faces to form new operations (Shell) ## Modifiers After blocks have been placed, it is possible to create new geometry based on placed blocks or to modify them. - [x] Move Vertex/Edge/Face - [x] Delete a Block created by a Shape or Object - [x] Project Vertex/Edge/Face - [x] Optimize point position of a sketch/shape (with arbitrarily constrained movement) ## Meshing Specification - [x] Simple definition of all supported kinds of edges with a dedicated class (Arc/Origin/Angle/Spline/PolyLine/Project) - [x] Automatic sorting/reorienting of block vertices based on specified _front_ and _top_ points - [x] Automatic calculation of cell count and grading by specifying any of a number of parameters (cell-to-cell expansion ratio, start cell width, end cell width, total expansion ratio) - [x] [Edge grading](https://www.openfoam.com/documentation/user-guide/4-mesh-generation-and-conversion/4.3-mesh-generation-with-the-blockmesh-utility#x13-450004.3.1.3) (separate specification for each edge) - [x] Automatic propagation of grading and cell count from a single block to all connected blocks as required by blockMesh - [x] Projections of vertices, edges and block faces to geometry (triangulated and [searchable surfaces](https://www.openfoam.com/documentation/guides/latest/doc/guide-meshing-snappyhexmesh-geometry.html#meshing-snappyhexmesh-searchable-objects)) - [x] Face merging as described by [blockMesh user guide](https://www.openfoam.com/documentation/user-guide/4-mesh-generation-and-conversion/4.3-mesh-generation-with-the-blockmesh-utility#x13-470004.3.2). Breaks the pure-hexahedral-mesh rule but can often save the day for trickier geometries. Automatic duplication of points on merged block faces - [x] Auto grading for high-Re meshes - [x] Auto grading for Low-Re meshes: boundary layer with specified cell-to-cell expansion, transition with 2:1 (or user-defined) expansion, and specified 'bulk' cell size # Examples How to run: - Install `classy_blocks` as described above - `cd` to directory of the chosen example - Run `python `; that will write blockMeshDict to examples/case - Run `blockMesh` on the case - Open `examples/case/case.foam` in ParaView to view the result For instance: ```bash cd examples/chaining python tank.py blockMesh -case ../case ``` ## Operations Analogous to a sketch in 3D CAD software, a Face is a set of 4 vertices and 4 edges. An _Operation_ is a 3D shape obtained by swiping a Face into 3rd dimension by a specified rule. Here is a Revolve as an example: ```python # a quadrangle with one curved side base = cb.Face( [ # quad vertices [0, 0, 0], [1, 0, 0], [1, 1, 0], [0, 1, 0] ], [ # edges: None specifies a straight edge cb.Arc([0.5, -0.2, 0]), None, None, None ] ) revolve = cb.Revolve( base, # face to revolve f.deg2rad(45), # revolve angle [0, -1, 0], # axis [-2, 0, 0] # origin ) revolve.chop(0, count=15) # first edge revolve.chop(1, count=15) # second edge revolve.chop(2, start_size=0.05) # revolve direction mesh.add(revolve) ``` ![Ducts](showcase/elbows.png "Ducts") > See `examples/operations` for an example of each operation. ## Shapes Some basic shapes are ready-made so that there's no need for workout with Operations. A simple Cylinder: ```python inlet = cb.Cylinder([x_start, 0, 0], [x_end, 0, 0], [0, 0, radius]) inlet.chop_radial(count=n_cells_radial, end_size=boundary_layer_thickness) inlet.chop_axial(start_size=axial_cell_size, end_size=2*axial_cell_size) inlet.chop_tangential(count=n_cells_tangential) inlet.set_start_patch('inlet') inlet.set_outer_patch('wall') inlet.set_end_patch('outlet') mesh.add(inlet) ``` > See `examples/shape` for use of each _shape_ 3D pipes with twists and turns (chained Elbow and Cylinder Shapes) ![Piping](showcase/piping.png "Piping") ### Chaining and Expanding/Contracting Useful for Shapes, mostly for piping and rotational geometry; An existing Shape's start or end sketch can be reused as a starting sketch for a new Shape, as long as they are compatible. For instance, an `Elbow` can be _chained_ to a `Cylinder` just like joining pipes in plumbing. Moreover, most shapes* can be expanded to form a _wall_ version of the same shape. For instance, expanding a `Cylinder` creates an `ExtrudedRing` or an `ExtrudedRing` can be filled to obtain a `Cylinder` that fills it. A simple tank with rounded edges ![Tank](showcase/tank.png "Tank") A flywheel in a case. Construction starts with a Cylinder which is then expanded and chained from left towards right. VTK Blocking output for debug is shown in the middle ![Flywheel](showcase/flywheel.png "Flywheel") Venturi tube ![Venturi tube](showcase/venturi_tube.png "Venturi tube") > See `examples/chaining` for an example of each operation. ## Custom Sketches and Shapes A Sketch is a collection of faces - essentially a 2D geometric object, split into quadrangles. Each Face in a Sketch is transformed into 3D space, creating a Shape. A number of predefined Sketches is available to choose from but it's easy to create a custom one. ```python disk_in_square = cb.WrappedDisk(start_point, corner_point, disk_diameter/2, normal) shape = cb.ExtrudedShape(disk_in_square, length) ``` ### Sketch Smoothing and Optimization Points that define a custom sketch can only be placed approximately. Their positions can then be defined by Laplacian smoothing or optimization to obtain best face quality. > See `examples/shape/custom` for an example with a custom sketch. ## Stacks A collection of similar Shapes; a Stack is created by starting with a Sketch, then transforming it a number of times, obtaining Shapes, stacked on top of each other. An Oval sketch, translated and rotated to obtain a Shape from which a Stack is made. ![Stack](showcase/fusilli.png "Fusilli stack") A `Grid` sketch, consisting of 3x3 faces is Extruded 2 times to obtain a Stack. The bottom-middle box is removed from the mesh so that flow around a cube can be studied: ```python base = Grid(point_1, point_2, 3, 3) stack = ExtrudedStack(base, side * 2, 2) # ... mesh.delete(stack.grid[0][1][1]) ``` ![Cube](showcase/cube.png "Flow around a Cube") > See `examples/stack/cube.py` for the full script. An electric heater in water, a mesh with two cellZones. The heater zone with surrounding fuild of square cross-section is an extruded `WrappedDisk` followed by a `RevolvedStack` of the same cross-sections. The center is then filled with a `SemiCylinder`. ![Heater](showcase/heater.png "Heater") > See `examples/complex/heater` for the full script. ## Assemblies A collection of pre-assembled parametric Shapes, ready to be used for further construction. Three pipes, joined in a single point. ![N-Joint](showcase/n_joint.png) ## Projection To Geometry [Any geometry that snappyHexMesh understands](https://www.openfoam.com/documentation/guides/latest/doc/guide-meshing-snappyhexmesh-geometry.html) is also supported by blockMesh. That includes searchable surfaces such as spheres and cylinders and triangulated surfaces. Projecting a block side to a geometry is straightforward; edges, however, can be projected to a single geometry (will 'snap' to the closest point) or to an intersection of two surfaces, which will define it exactly. Geometry is specified as a simple dictionary of strings and is thrown in blockMeshDict exactly as provided by the user. ```python geometry = { 'terrain': [ 'type triSurfaceMesh', 'name terrain', 'file "terrain.stl"', ], 'left_wall': [ 'type searchablePlane', 'planeType pointAndNormal', 'point (-1 0 0)', 'normal (1 0 0)', ] } box = cb.Box([-1., -1., -1.], [1., 1., 1.]) box.project_side('bottom', 'terrain') box.project_edge(0, 1, 'terrain') box.project_edge(3, 0, ['terrain', 'left_wall']) ``` Edges and faces, projected to an STL surface ![Projected](showcase/projected.png "Projected edges and faces") Mesh for studying flow around a sphere. Edges and faces of inner ('wall') and outer ('prismatic layers') cells are projected to a searchableSphere, adding no additional requirements for STL geometry. ![Sphere](showcase/sphere.png "Flow around a sphere") ## Face Merging Simply provide names of patches to be merged and call `mesh.merge_patches(, )`. classy_blocks will take care of point duplication and whatnot. ```python box = cb.Box([-0.5, -0.5, 0], [0.5, 0.5, 1]) for i in range(3): box.chop(i, count=25) box.set_patch('top', 'box_top') mesh.add(box) cylinder = cb.Cylinder( [0, 0, 1], [0, 0, 2], [0.25, 0, 1] ) cylinder.chop_axial(count=10) cylinder.chop_radial(count=10) cylinder.chop_tangential(count=20) cylinder.set_bottom_patch('cylinder_bottom') mesh.add(cylinder) mesh.merge_patches('box_top', 'cylinder_bottom') ``` ## Offsetting Faces It is possible to create new blocks by offsetting existing blocks' faces. As an example, a sphere can be created by offsetting all six faces of a simple box, then projected to a `searchableSphere`. > See `examples/shapes/shell.py` for the sphere tutorial. ## Automatic Blocking Optimization Once an approximate blocking is established, one can fetch specific vertices and specifies certain degrees of freedom along which those vertices will be moved to get blocks of better quality. Block is treated as a single cell for which OpenFOAM's cell quality criteria are calculated and optimized per user's instructions. Points can move freely (3 degrees of freedom), along a specified line/curve (1 DoF) or surface (2 DoF). ```python # [...] A simple setup with two cylinders of different radii, # connected by a short conical frustum that has bad cells # [...] mesh.assemble() # Find inside vertices at connecting frustum finder = cb.RoundSolidFinder(mesh, frustum) inner_vertices = finder.find_core(True).union(finder.find_core(False)) optimizer = cb.Optimizer(mesh) # Move chosen vertices along a line, parallel to x-axis for vertex in inner_vertices: clamp = cb.LineClamp(vertex, vertex.position, vertex.position + f.vector(1, 0, 0)) optimizer.add_clamp(clamp) optimizer.optimize() mesh.write(os.path.join("..", "case", "system", "blockMeshDict"), debug_path="debug.vtk") ``` The result (basic blocking > optimized): ![Diffuser](showcase/diffuser_optimization.png "Diffuser") > See `examples/optimization` for the diffuser example. Airfoil core with blunt trailing edge (imported points from NACA generator) and adjustable angle of attack. Exact blocking is determined by in-situ optimization (see `examples/complex/airfoil.py`). A simulation-ready mesh needs additional blocks to expand domain further away from the airfoil. ![Airfoil](showcase/airfoil.png "Airfoil core mesh") ## Chopping In classy_blocks lingo, _chopping_ means setting block's cell count and optionally grading. For best control it can be specified manually with something like `operation.chop(axis, start_size=..., c2c_expansion=...)` (or any other supported combination of parameters) but it only needs to be done on a single block in a _row_ of connected blocks. All touching blocks will inherit user-specified chops automatically. ### Edge Grading When setting cell counts and expansion ratios, it is possible to specify which value to keep constant. When propagating chops through the mesh this will keep the required value constant. For instance, thickness of the first cell at the wall can be maintained to keep desired `y+` throughout the mesh. This is done by simply specifying a `preserve="..."` keyword. Example: a block chopped in a classic way where cell sizes will increase when block size increases: ![Classic block grading](showcase/classy_classic_grading.png "Classic block grading") The same case but with a specified `preserve="start_size"` keyword for the bottom and `preserve="end_size"` for the top edge: ![Grading for consistent cell size](showcase/classy_edges_grading.png "Classy block grading") It is also possible to locate a specific edge and modify its grading by using `operation.chop_edge(corner_1, corner_2, ...)`. For example, this cylinder with wall boundary layers has some edges manually redefined: ![Manual edge grading](showcase/edge_grading.png "Manual edge grading") ### Automatic Grading classy_blocks offers automatic graders that will set cell counts and gradings throughout the whole mesh with no user intervention: - `FixedCountGrader` will simply set the same cell count on all blocks (useful for debugging during mesh development). - `SimpleGrader` will try to maintain user-specified cell size (a high-Re mesh). - `InflationGrader` will require the user to set wall patches first, then it will set cell sizes on the wall to maintain required first cell thickness, make a boundary (inflation) layer that will maintain specified cell-to-cell expansion. Between the last boundary-layer cell and the bulk, a _buffer_ layer will be created with a larger (user-specified) cell-to-cell expansion to save on cell count. All automatic graders will only grade what the user has not chopped yet - so it's easy to override grader's doings by manually chopping shapes first. > Check out `examples/complex/cyclone` to see that almost no manual chopping is required to grade the whole mesh! ## Debugging By default, a `debug.vtk` file is created where each block represents a hexahedral cell. By showing `block_ids` with a proper color scale the blocking can be visualized. This is useful when blockMesh fails with errors reporting invalid/inside-out blocks but VTK will happily show anything. ## Also 2D mesh for studying Karman Vortex Street ![Karman Vortex Street](showcase/karman.png "Karman vortex street") A parametric, Low-Re mesh of a real-life impeller _(not included in examples)_ ![Impeller - Low Re](showcase/impeller_full.png "Low-Re Impeller") A gear, made from a curve of a single tooth, calculated by [py_gear_gen](https://github.com/heartworm/py_gear_gen) ![Gear](showcase/gear.png "Gear") A complex example: parametric, Low-Re mesh of a cyclone ![Cyclone](showcase/cyclone.png "Cyclone") > See `examples/complex/cyclone` for a full example of a complex building workflow. # Prerequisites Package (python) dependencies can be found in _pyproject.toml_ file. Other dependencies that must be installed: - python3.9 and higher - OpenFoam: .org or .com version is supported, foam-extend's blockMesh doesn't support multigrading but is otherwise also compatible. BlockMesh is not required to run Python scripts. There is an ongoing effort to create VTK meshes from within classy_blocks. See the wip_mesher branch for the latest updates. # Technical Information There's no official documentation yet so here are some tips for easier navigation through source. ## The Process, Roughly 1. User writes a script that defines operations/shapes/objects, their edges, projections, cell counts, whatever is needed. 1. All the stuff is added to mesh. 1. Mesh converts user entered data into vertices, blocks, edges and whatnot. 1. The mesh can be written at that point; or, 1. Modification of placed geometry, either by manually moving vertices or by utilizing some sort of optimization algorithm. 1. Output of optimized/modified mesh. ## Support? If you are stuck, try reading the [classy_blocks tutorial on damogranlabs.com](https://damogranlabs.com/2023/04/classy_blocks-tutorial-part-1-the-basics/). You are free to join the [OpenFOAM Discord channel](https://discord.gg/P9p9eHn) where classy_blocks users and developers hang out. If you have collosal plans for meshing but no resources, write an email to [Nejc Jurkovic](mailto:kandelabr@gmail.com) and we'll discuss your options. ================================================ FILE: examples/advanced/chop_preserve.py ================================================ """demonstrates the 'preserve' keyword to .chop() method""" import os import classy_blocks as cb mesh = cb.Mesh() start = cb.Box([0, 0, 0], [1, 1, 0.1]) start.chop(0, start_size=0.1) start.chop(1, length_ratio=0.5, start_size=0.01, c2c_expansion=1.2, preserve="start_size") start.chop(1, length_ratio=0.5, end_size=0.01, c2c_expansion=1 / 1.2, preserve="end_size") start.chop(2, count=1) mesh.add(start) expand_start = start.get_face("right") expand = cb.Loft(expand_start, expand_start.copy().translate([1, 0, 0]).scale(2)) expand.chop(2, start_size=0.1) mesh.add(expand) contract_start = expand.get_face("top") contract = cb.Loft(contract_start, contract_start.copy().translate([1, 0, 0]).scale(0.25)) contract.chop(2, start_size=0.1) mesh.add(contract) end = cb.Extrude(contract.get_face("top"), 1) end.chop(2, start_size=0.1) mesh.add(end) mesh.set_default_patch("walls", "wall") mesh.write(os.path.join("..", "case", "system", "blockMeshDict"), debug_path="debug.vtk") ================================================ FILE: examples/advanced/collapsed.py ================================================ import os import classy_blocks as cb mesh = cb.Mesh() bottom_face = cb.Face([[0, 0, 0], [1, 0, 0], [0.5, 1, 0], [0.5, 1, 0]]) top_face = cb.Face([[0, 0, 1], [1, 0.5, 1], [1, 0.5, 1], [0, 1, 1]]) loft = cb.Loft(bottom_face, top_face) extrude = cb.Extrude(top_face, 1) revolve_face = cb.Face([[2, 0, 0], [3, 0, 0], [3, 1, 0], [2, 1, 0]]) revolve = cb.Revolve(revolve_face, 1, [1, 0, 0], [0, 0, 0]) mesh.add(loft) mesh.add(extrude) mesh.add(revolve) mesh.assemble() grader = cb.FixedCountGrader(mesh) grader.grade() mesh.set_default_patch("walls", "wall") mesh.write(os.path.join("..", "case", "system", "blockMeshDict"), debug_path="debug.vtk") ================================================ FILE: examples/advanced/edge_grading.py ================================================ """A simple cylinder but with custom grading of selected edges""" import os import classy_blocks as cb mesh = cb.Mesh() axis_point_1 = [0, 0, 0] axis_point_2 = [1, 0, 0] radius_point_1 = [0, 1, 0] cylinder = cb.Cylinder(axis_point_1, axis_point_2, radius_point_1) cylinder.set_start_patch("inlet") cylinder.set_end_patch("outlet") cylinder.set_outer_patch("walls") # Chop and grade bl_thickness = 1e-3 core_size = 0.1 # Edge chopping can only be done on a completely specified mesh; cylinder.chop_axial(count=30) cylinder.chop_radial(start_size=core_size, end_size=bl_thickness) cylinder.chop_tangential(start_size=core_size) # After all edges have been specified (chopped), # specific ones can be changed manually. # Keep in mind that count is already fixed and cannot be changed. # Case 1: remove grading cylinder.shell[0].chop_edge(0, 1, c2c_expansion=1) # Case 2: make a thicker first cell cylinder.shell[1].chop_edge(0, 1, end_size=5 * bl_thickness) # Case 3: weird random multigrading cylinder.shell[2].chop_edge(0, 1, length_ratio=0.5, count=2) cylinder.shell[2].chop_edge(0, 1, c2c_expansion=1 / 1.5) # Case 4: chop one block differently so that neighbour block will be edge-graded cylinder.shell[1].chop(2, count=30, start_size=0.01) mesh.add(cylinder) cylinder.set_start_patch("walls") cylinder.set_end_patch("walls") mesh.modify_patch("walls", "wall") mesh.write(os.path.join("..", "case", "system", "blockMeshDict"), debug_path="debug.vtk") ================================================ FILE: examples/advanced/merged.py ================================================ import os import classy_blocks as cb mesh = cb.Mesh() # two boxes, connected with a geometrically identical # but different cell count coarse_box = cb.Box([-0.5, -0.5, 0], [0.5, 0.5, 1]) for i in (0, 1, 2): coarse_box.chop(i, count=10) coarse_box.set_patch("bottom", "inlet") coarse_box.set_patch("top", "box_slave") mesh.add(coarse_box) fine_box = coarse_box.copy().translate([0, 0, 1]) for i in (0, 1, 2): fine_box.unchop(i) fine_box.chop(i, count=25) fine_box.set_patch("bottom", "box_master") fine_box.set_patch("top", "cylinder_master") mesh.add(fine_box) # merge the boxes mesh.merge_patches("box_master", "box_slave") # add another cylinder on top; this will have no # coincident points cylinder = cb.Cylinder([0, 0, 2], [0, 0, 3], [0.25, 0, 2]) cylinder.chop_axial(count=10) cylinder.chop_radial(count=10) cylinder.chop_tangential(count=10) cylinder.set_start_patch("cylinder_slave") cylinder.set_end_patch("outlet") mesh.add(cylinder) mesh.merge_patches("cylinder_master", "cylinder_slave") mesh.set_default_patch("walls", "wall") mesh.write(os.path.join("..", "case", "system", "blockMeshDict"), debug_path="debug.vtk") ================================================ FILE: examples/advanced/project.py ================================================ import os import classy_blocks as cb geometry = { "terrain": [ "type triSurfaceMesh", "name terrain", 'file "terrain.stl"', ], "left_wall": [ "type searchablePlane", "planeType pointAndNormal", "point (-1 0 0)", "normal (1 0 0)", "pointAndNormalDict { point (-1 0 0); normal (1 0 0); }", # ESI version ], "front_wall": [ "type searchablePlane", "planeType pointAndNormal", "point (0 -1 0)", "normal (0 1 0)", "pointAndNormalDict { point (0 -1 0); normal (0 1 0); }", # ESI version ], } mesh = cb.Mesh() # 'miss' the vertices deliberately; # project them to geometry later box = cb.Box([-0.9, -0.9, -0.9], [1.0, 1.0, 1.0]) # project misplaced vertices box.project_corner(0, ["terrain", "left_wall", "front_wall"]) box.project_corner(1, "terrain") box.project_corner(2, "terrain") # project a face to geometry; # when using Loft/Extrude/Revolve, you could specify # those when creating a Face; you'd still have to # project other sides this way box.project_side("bottom", "terrain") # projection of an edge to a surface will move it in various directions, # depending on the geometry, distorting the box's side box.project_edge(0, 1, "terrain") # to avoid that, project an edge to two surfaces; # this will make it stick to their intersection box.project_edge(3, 0, ["terrain", "left_wall"]) # an edge can remain 'not projected' but this can cause # bad quality cells if geometries differ enough # extrude.block.project_edge(1, 2, 'terrain') # extrude.block.project_edge(2, 3, 'terrain') # extrude.block.project_edge(3, 0, 'terrain') for axis in (0, 1, 2): box.chop(axis, count=20) box.set_patch("bottom", "terrain") mesh.add(box) mesh.set_default_patch("atmosphere", "patch") mesh.add_geometry(geometry) mesh.write(os.path.join("..", "case", "system", "blockMeshDict"), debug_path="debug.vtk") ================================================ FILE: examples/assembly/l_joint.py ================================================ import os import classy_blocks as cb from classy_blocks.construct.assemblies.joints import LJoint from classy_blocks.util import functions as f mesh = cb.Mesh() axis_point_1 = [0.0, 0.0, 0.0] axis_point_2 = [5.0, 5.0, 0.0] radius_point_1 = [0.0, 0.0, 2.0] joint = LJoint(axis_point_1, axis_point_2, radius_point_1) cell_size = f.norm(radius_point_1) / 10 joint.chop_axial(start_size=cell_size * 5, end_size=cell_size) joint.chop_radial(start_size=cell_size, end_size=cell_size / 10) joint.chop_tangential(start_size=cell_size) joint.set_outer_patch("walls") joint.set_hole_patch(0, "inlet") joint.set_hole_patch(1, "outlet") mesh.add(joint) mesh.write(os.path.join("..", "case", "system", "blockMeshDict"), debug_path="debug.vtk") ================================================ FILE: examples/assembly/n_joint.py ================================================ import os import classy_blocks as cb from classy_blocks.construct.assemblies.joints import NJoint from classy_blocks.util import functions as f mesh = cb.Mesh() axis_point_1 = [0.0, 0.0, 0.0] axis_point_2 = [5.0, 5.0, 0.0] radius_point_1 = [0.0, 0.0, 2.0] joint = NJoint(axis_point_1, axis_point_2, radius_point_1, 3) cell_size = f.norm(radius_point_1) / 10 joint.chop_axial(start_size=cell_size * 5, end_size=cell_size) joint.chop_radial(start_size=cell_size, end_size=cell_size / 10) joint.chop_tangential(start_size=cell_size) joint.set_outer_patch("walls") joint.set_hole_patch(0, "inlet") joint.set_hole_patch(1, "outlet_1") joint.set_hole_patch(2, "outlet_2") mesh.add(joint) mesh.write(os.path.join("..", "case", "system", "blockMeshDict"), debug_path="debug.vtk") ================================================ FILE: examples/assembly/t_joint.py ================================================ import os import classy_blocks as cb from classy_blocks.construct.assemblies.joints import TJoint from classy_blocks.util import functions as f mesh = cb.Mesh() axis_point_1 = [0.0, 0.0, 0.0] axis_point_2 = [5.0, 5.0, 0.0] radius_point_1 = [0.0, 0.0, 2.0] joint = TJoint(axis_point_1, axis_point_2, radius_point_1) cell_size = f.norm(radius_point_1) / 10 joint.chop_axial(start_size=cell_size * 5, end_size=cell_size) joint.chop_radial(start_size=cell_size, end_size=cell_size / 10) joint.chop_tangential(start_size=cell_size) joint.set_outer_patch("walls") joint.set_hole_patch(0, "inlet") joint.set_hole_patch(1, "outlet_1") joint.set_hole_patch(2, "outlet_2") mesh.add(joint) mesh.write(os.path.join("..", "case", "system", "blockMeshDict"), debug_path="debug.vtk") ================================================ FILE: examples/bug.py ================================================ import numpy as np import classy_blocks as cb box = cb.Box([0, 0, 0], [1, 1, 1]) base_face = box.bottom_face neighbour_face = base_face.copy().translate([4, 0, 0]) common_face = base_face.copy().rotate(np.pi / 2, [0, 1, 0]).translate([2, 0, 2]) left_loft = cb.Loft(base_face, common_face) right_loft = cb.Loft(neighbour_face, common_face.copy().shift(2).invert()) for axis in range(3): left_loft.chop(axis, start_size=0.05, total_expansion=5) right_loft.chop(2, count=10) mesh = cb.Mesh() mesh.add(left_loft) mesh.add(right_loft) mesh.write("case/system/blockMeshDict", debug_path="debug.vtk") ================================================ FILE: examples/case/Allrun.mesh ================================================ #!/bin/bash cd "${0%/*}" || exit source $WM_PROJECT_DIR/bin/tools/RunFunctions source $WM_PROJECT_DIR/bin/tools/CleanFunctions cleanCase touch case.foam runApplication blockMesh runApplication checkMesh -constant runApplication setsToZones -noFlipMap -constant cat log.checkMesh ================================================ FILE: examples/case/case.foam ================================================ ================================================ FILE: examples/case/constant/geometry/terrain.stl ================================================ solid terrain facet normal -0.024250 -0.041091 -0.998861 outer loop vertex 0.750000 -0.750000 -1.004215 vertex 1.000000 -0.750000 -1.010285 vertex 1.000000 -1.000000 -1.000000 endloop endfacet facet normal 0.000000 -0.016859 -0.999858 outer loop vertex 0.750000 -0.750000 -1.004215 vertex 1.000000 -1.000000 -1.000000 vertex 0.750000 -1.000000 -1.000000 endloop endfacet facet normal -0.443104 -0.488797 -0.751489 outer loop vertex -0.250000 -0.750000 -0.822242 vertex -0.000000 -0.750000 -0.969651 vertex -0.000000 -1.000000 -0.807041 endloop endfacet facet normal -0.120582 -0.179942 -0.976259 outer loop vertex -0.250000 -0.750000 -0.822242 vertex -0.000000 -1.000000 -0.807041 vertex -0.250000 -1.000000 -0.776163 endloop endfacet facet normal -0.702447 0.195577 -0.684337 outer loop vertex -0.250000 0.250000 -1.028510 vertex -0.000000 0.250000 -1.285126 vertex -0.000000 0.000000 -1.356573 endloop endfacet facet normal -0.560580 0.394713 -0.727978 outer loop vertex -0.250000 0.250000 -1.028510 vertex -0.000000 0.000000 -1.356573 vertex -0.250000 0.000000 -1.164061 endloop endfacet facet normal 0.718016 0.025483 -0.695560 outer loop vertex 0.750000 0.250000 -1.299716 vertex 1.000000 0.250000 -1.041645 vertex 1.000000 0.000000 -1.050804 endloop endfacet facet normal 0.761426 0.128877 -0.635312 outer loop vertex 0.750000 0.250000 -1.299716 vertex 1.000000 0.000000 -1.050804 vertex 0.750000 0.000000 -1.350430 endloop endfacet facet normal -0.003102 0.085318 -0.996349 outer loop vertex 0.250000 0.250000 -1.375672 vertex 0.500000 0.250000 -1.376451 vertex 0.500000 0.000000 -1.397858 endloop endfacet facet normal 0.164177 0.248885 -0.954517 outer loop vertex 0.250000 0.250000 -1.375672 vertex 0.500000 0.000000 -1.397858 vertex 0.250000 0.000000 -1.440858 endloop endfacet facet normal -0.108058 0.267877 -0.957374 outer loop vertex 0.250000 0.750000 -1.215000 vertex 0.500000 0.750000 -1.243217 vertex 0.500000 0.500000 -1.313168 endloop endfacet facet normal -0.107787 0.268133 -0.957333 outer loop vertex 0.250000 0.750000 -1.215000 vertex 0.500000 0.500000 -1.313168 vertex 0.250000 0.500000 -1.285021 endloop endfacet facet normal 0.693720 0.042652 -0.718981 outer loop vertex 0.750000 0.750000 -1.276436 vertex 1.000000 0.750000 -1.035220 vertex 1.000000 0.500000 -1.050051 endloop endfacet facet normal 0.716199 0.089436 -0.692142 outer loop vertex 0.750000 0.750000 -1.276436 vertex 1.000000 0.500000 -1.050051 vertex 0.750000 0.500000 -1.308740 endloop endfacet facet normal -0.308379 0.057996 -0.949494 outer loop vertex -0.750000 0.250000 -0.879763 vertex -0.500000 0.250000 -0.960958 vertex -0.500000 0.000000 -0.976228 endloop endfacet facet normal -0.515177 -0.192949 -0.835083 outer loop vertex -0.750000 0.250000 -0.879763 vertex -0.500000 0.000000 -0.976228 vertex -0.750000 0.000000 -0.821999 endloop endfacet facet normal -0.009779 -0.042150 -0.999063 outer loop vertex -0.750000 0.750000 -0.997553 vertex -0.500000 0.750000 -1.000000 vertex -0.500000 0.500000 -0.989453 endloop endfacet facet normal -0.077516 -0.109624 -0.990946 outer loop vertex -0.750000 0.750000 -0.997553 vertex -0.500000 0.500000 -0.989453 vertex -0.750000 0.500000 -0.969896 endloop endfacet facet normal -0.202669 0.085588 -0.975500 outer loop vertex -0.250000 0.750000 -1.000000 vertex -0.000000 0.750000 -1.051940 vertex -0.000000 0.500000 -1.073874 endloop endfacet facet normal -0.283384 0.000000 -0.959006 outer loop vertex -0.250000 0.750000 -1.000000 vertex -0.000000 0.500000 -1.073874 vertex -0.250000 0.500000 -1.000000 endloop endfacet facet normal 0.090939 0.300921 -0.949303 outer loop vertex -0.750000 -0.750000 -0.773644 vertex -0.500000 -0.750000 -0.749696 vertex -0.500000 -1.000000 -0.828943 endloop endfacet facet normal 0.387183 0.550744 -0.739439 outer loop vertex -0.750000 -0.750000 -0.773644 vertex -0.500000 -1.000000 -0.828943 vertex -0.750000 -1.000000 -0.959848 endloop endfacet facet normal -0.530624 -0.447490 -0.719855 outer loop vertex -0.750000 -0.250000 -0.767011 vertex -0.500000 -0.250000 -0.951293 vertex -0.500000 -0.500000 -0.795883 endloop endfacet facet normal -0.237277 -0.126036 -0.963231 outer loop vertex -0.750000 -0.250000 -0.767011 vertex -0.500000 -0.500000 -0.795883 vertex -0.750000 -0.500000 -0.734300 endloop endfacet facet normal -0.465774 -0.553780 -0.690204 outer loop vertex -0.250000 -0.250000 -1.166940 vertex -0.000000 -0.250000 -1.335649 vertex -0.000000 -0.500000 -1.135064 endloop endfacet facet normal -0.213413 -0.330632 -0.919314 outer loop vertex -0.250000 -0.250000 -1.166940 vertex -0.000000 -0.500000 -1.135064 vertex -0.250000 -0.500000 -1.077028 endloop endfacet facet normal 0.108859 -0.014277 -0.993955 outer loop vertex 0.250000 -0.750000 -1.030971 vertex 0.500000 -0.750000 -1.003591 vertex 0.500000 -1.000000 -1.000000 endloop endfacet facet normal -0.041027 -0.163146 -0.985748 outer loop vertex 0.250000 -0.750000 -1.030971 vertex 0.500000 -1.000000 -1.000000 vertex 0.250000 -1.000000 -0.989595 endloop endfacet facet normal 0.191868 -0.659198 -0.727079 outer loop vertex 0.250000 -0.250000 -1.404968 vertex 0.500000 -0.250000 -1.338995 vertex 0.500000 -0.500000 -1.112336 endloop endfacet facet normal 0.307112 -0.577923 -0.756100 outer loop vertex 0.250000 -0.250000 -1.404968 vertex 0.500000 -0.500000 -1.112336 vertex 0.250000 -0.500000 -1.213881 endloop endfacet facet normal 0.753183 -0.049161 -0.655972 outer loop vertex 0.750000 -0.250000 -1.337537 vertex 1.000000 -0.250000 -1.050489 vertex 1.000000 -0.500000 -1.031753 endloop endfacet facet normal 0.355515 -0.559897 -0.748414 outer loop vertex 0.750000 -0.250000 -1.337537 vertex 1.000000 -0.500000 -1.031753 vertex 0.750000 -0.500000 -1.150509 endloop endfacet facet normal 0.004670 -0.599025 -0.800716 outer loop vertex 0.500000 -0.250000 -1.338995 vertex 0.750000 -0.250000 -1.337537 vertex 0.750000 -0.500000 -1.150509 endloop endfacet facet normal -0.112406 -0.667420 -0.736149 outer loop vertex 0.500000 -0.250000 -1.338995 vertex 0.750000 -0.500000 -1.150509 vertex 0.500000 -0.500000 -1.112336 endloop endfacet facet normal 0.186150 -0.050603 -0.981217 outer loop vertex 0.500000 0.000000 -1.397858 vertex 0.750000 0.000000 -1.350430 vertex 0.750000 -0.250000 -1.337537 endloop endfacet facet normal 0.005676 -0.229181 -0.973367 outer loop vertex 0.500000 0.000000 -1.397858 vertex 0.750000 -0.250000 -1.337537 vertex 0.500000 -0.250000 -1.338995 endloop endfacet facet normal 0.767829 -0.000807 -0.640655 outer loop vertex 0.750000 0.000000 -1.350430 vertex 1.000000 0.000000 -1.050804 vertex 1.000000 -0.250000 -1.050489 endloop endfacet facet normal 0.753662 -0.033851 -0.656390 outer loop vertex 0.750000 0.000000 -1.350430 vertex 1.000000 -0.250000 -1.050489 vertex 0.750000 -0.250000 -1.337537 endloop endfacet facet normal -0.215134 -0.593051 -0.775892 outer loop vertex -0.000000 -0.250000 -1.335649 vertex 0.250000 -0.250000 -1.404968 vertex 0.250000 -0.500000 -1.213881 endloop endfacet facet normal -0.238789 -0.607704 -0.757413 outer loop vertex -0.000000 -0.250000 -1.335649 vertex 0.250000 -0.500000 -1.213881 vertex -0.000000 -0.500000 -1.135064 endloop endfacet facet normal -0.316557 -0.134798 -0.938947 outer loop vertex -0.000000 0.000000 -1.356573 vertex 0.250000 0.000000 -1.440858 vertex 0.250000 -0.250000 -1.404968 endloop endfacet facet normal -0.266328 -0.080393 -0.960524 outer loop vertex -0.000000 0.000000 -1.356573 vertex 0.250000 -0.250000 -1.404968 vertex -0.000000 -0.250000 -1.335649 endloop endfacet facet normal 0.165124 -0.226039 -0.960021 outer loop vertex 0.250000 0.000000 -1.440858 vertex 0.500000 0.000000 -1.397858 vertex 0.500000 -0.250000 -1.338995 endloop endfacet facet normal 0.252731 -0.137493 -0.957717 outer loop vertex 0.250000 0.000000 -1.440858 vertex 0.500000 -0.250000 -1.338995 vertex 0.250000 -0.250000 -1.404968 endloop endfacet facet normal -0.235202 -0.158703 -0.958902 outer loop vertex -0.000000 -0.750000 -0.969651 vertex 0.250000 -0.750000 -1.030971 vertex 0.250000 -1.000000 -0.989595 endloop endfacet facet normal -0.522078 -0.465040 -0.714963 outer loop vertex -0.000000 -0.750000 -0.969651 vertex 0.250000 -1.000000 -0.989595 vertex -0.000000 -1.000000 -0.807041 endloop endfacet facet normal -0.246582 -0.572241 -0.782137 outer loop vertex -0.000000 -0.500000 -1.135064 vertex 0.250000 -0.500000 -1.213881 vertex 0.250000 -0.750000 -1.030971 endloop endfacet facet normal -0.200410 -0.540606 -0.817056 outer loop vertex -0.000000 -0.500000 -1.135064 vertex 0.250000 -0.750000 -1.030971 vertex -0.000000 -0.750000 -0.969651 endloop endfacet facet normal 0.349043 -0.373790 -0.859331 outer loop vertex 0.250000 -0.500000 -1.213881 vertex 0.500000 -0.500000 -1.112336 vertex 0.500000 -0.750000 -1.003591 endloop endfacet facet normal 0.088046 -0.588180 -0.803923 outer loop vertex 0.250000 -0.500000 -1.213881 vertex 0.500000 -0.750000 -1.003591 vertex 0.250000 -0.750000 -1.030971 endloop endfacet facet normal -0.630214 -0.262763 -0.730606 outer loop vertex -0.500000 -0.250000 -0.951293 vertex -0.250000 -0.250000 -1.166940 vertex -0.250000 -0.500000 -1.077028 endloop endfacet facet normal -0.690678 -0.381790 -0.614166 outer loop vertex -0.500000 -0.250000 -0.951293 vertex -0.250000 -0.500000 -1.077028 vertex -0.500000 -0.500000 -0.795883 endloop endfacet facet normal -0.600655 0.009207 -0.799455 outer loop vertex -0.500000 0.000000 -0.976228 vertex -0.250000 0.000000 -1.164061 vertex -0.250000 -0.250000 -1.166940 endloop endfacet facet normal -0.651311 -0.075312 -0.755065 outer loop vertex -0.500000 0.000000 -0.976228 vertex -0.250000 -0.250000 -1.166940 vertex -0.500000 -0.250000 -0.951293 endloop endfacet facet normal -0.608781 -0.066169 -0.790574 outer loop vertex -0.250000 0.000000 -1.164061 vertex -0.000000 0.000000 -1.356573 vertex -0.000000 -0.250000 -1.335649 endloop endfacet facet normal -0.559354 0.009546 -0.828874 outer loop vertex -0.250000 0.000000 -1.164061 vertex -0.000000 -0.250000 -1.335649 vertex -0.250000 -0.250000 -1.166940 endloop endfacet facet normal -0.066181 -0.129457 -0.989374 outer loop vertex -1.000000 -0.250000 -0.750288 vertex -0.750000 -0.250000 -0.767011 vertex -0.750000 -0.500000 -0.734300 endloop endfacet facet normal 0.276537 0.216659 -0.936262 outer loop vertex -1.000000 -0.250000 -0.750288 vertex -0.750000 -0.500000 -0.734300 vertex -1.000000 -0.500000 -0.808140 endloop endfacet facet normal -0.249750 -0.208009 -0.945704 outer loop vertex -1.000000 0.000000 -0.755977 vertex -0.750000 0.000000 -0.821999 vertex -0.750000 -0.250000 -0.767011 endloop endfacet facet normal -0.066726 -0.022698 -0.997513 outer loop vertex -1.000000 0.000000 -0.755977 vertex -0.750000 -0.250000 -0.767011 vertex -1.000000 -0.250000 -0.750288 endloop endfacet facet normal -0.523161 -0.084584 -0.848026 outer loop vertex -0.750000 0.000000 -0.821999 vertex -0.500000 0.000000 -0.976228 vertex -0.500000 -0.250000 -0.951293 endloop endfacet facet normal -0.584261 -0.174338 -0.792619 outer loop vertex -0.750000 0.000000 -0.821999 vertex -0.500000 -0.250000 -0.951293 vertex -0.750000 -0.250000 -0.767011 endloop endfacet facet normal 0.519629 0.510358 -0.685215 outer loop vertex -1.000000 -0.750000 -0.963231 vertex -0.750000 -0.750000 -0.773644 vertex -0.750000 -1.000000 -0.959848 endloop endfacet facet normal 0.156929 0.143709 -0.977098 outer loop vertex -1.000000 -0.750000 -0.963231 vertex -0.750000 -1.000000 -0.959848 vertex -1.000000 -1.000000 -1.000000 endloop endfacet facet normal 0.280093 0.149243 -0.948301 outer loop vertex -1.000000 -0.500000 -0.808140 vertex -0.750000 -0.500000 -0.734300 vertex -0.750000 -0.750000 -0.773644 endloop endfacet facet normal 0.541684 0.443122 -0.714298 outer loop vertex -1.000000 -0.500000 -0.808140 vertex -0.750000 -0.750000 -0.773644 vertex -1.000000 -0.750000 -0.963231 endloop endfacet facet normal -0.235426 -0.176569 -0.955719 outer loop vertex -0.750000 -0.500000 -0.734300 vertex -0.500000 -0.500000 -0.795883 vertex -0.500000 -0.750000 -0.749696 endloop endfacet facet normal 0.094210 0.154775 -0.983448 outer loop vertex -0.750000 -0.500000 -0.734300 vertex -0.500000 -0.750000 -0.749696 vertex -0.750000 -0.750000 -0.773644 endloop endfacet facet normal 0.000000 0.000000 -1.000000 outer loop vertex -0.500000 0.750000 -1.000000 vertex -0.250000 0.750000 -1.000000 vertex -0.250000 0.500000 -1.000000 endloop endfacet facet normal -0.042115 -0.042115 -0.998225 outer loop vertex -0.500000 0.750000 -1.000000 vertex -0.250000 0.500000 -1.000000 vertex -0.500000 0.500000 -0.989453 endloop endfacet facet normal 0.000000 0.000000 -1.000000 outer loop vertex -0.500000 1.000000 -1.000000 vertex -0.250000 1.000000 -1.000000 vertex -0.250000 0.750000 -1.000000 endloop endfacet facet normal 0.000000 0.000000 -1.000000 outer loop vertex -0.500000 1.000000 -1.000000 vertex -0.250000 0.750000 -1.000000 vertex -0.500000 0.750000 -1.000000 endloop endfacet facet normal -0.025983 0.178374 -0.983620 outer loop vertex -0.250000 1.000000 -1.000000 vertex -0.000000 1.000000 -1.006604 vertex -0.000000 0.750000 -1.051940 endloop endfacet facet normal -0.203416 0.000000 -0.979092 outer loop vertex -0.250000 1.000000 -1.000000 vertex -0.000000 0.750000 -1.051940 vertex -0.250000 0.750000 -1.000000 endloop endfacet facet normal -0.033401 -0.109894 -0.993382 outer loop vertex -1.000000 0.750000 -0.989147 vertex -0.750000 0.750000 -0.997553 vertex -0.750000 0.500000 -0.969896 endloop endfacet facet normal -0.269839 -0.339232 -0.901171 outer loop vertex -1.000000 0.750000 -0.989147 vertex -0.750000 0.500000 -0.969896 vertex -1.000000 0.500000 -0.895038 endloop endfacet facet normal -0.000000 -0.009787 -0.999952 outer loop vertex -1.000000 1.000000 -1.000000 vertex -0.750000 1.000000 -1.000000 vertex -0.750000 0.750000 -0.997553 endloop endfacet facet normal -0.033573 -0.043346 -0.998496 outer loop vertex -1.000000 1.000000 -1.000000 vertex -0.750000 0.750000 -0.997553 vertex -1.000000 0.750000 -0.989147 endloop endfacet facet normal -0.000000 0.000000 -1.000000 outer loop vertex -0.750000 1.000000 -1.000000 vertex -0.500000 1.000000 -1.000000 vertex -0.500000 0.750000 -1.000000 endloop endfacet facet normal -0.009787 -0.009787 -0.999904 outer loop vertex -0.750000 1.000000 -1.000000 vertex -0.500000 0.750000 -1.000000 vertex -0.750000 0.750000 -0.997553 endloop endfacet facet normal -0.262345 -0.217237 -0.940204 outer loop vertex -1.000000 0.250000 -0.810005 vertex -0.750000 0.250000 -0.879763 vertex -0.750000 0.000000 -0.821999 endloop endfacet facet normal -0.249938 -0.204532 -0.946413 outer loop vertex -1.000000 0.250000 -0.810005 vertex -0.750000 0.000000 -0.821999 vertex -1.000000 0.000000 -0.755977 endloop endfacet facet normal -0.271132 -0.326461 -0.905489 outer loop vertex -1.000000 0.500000 -0.895038 vertex -0.750000 0.500000 -0.969896 vertex -0.750000 0.250000 -0.879763 endloop endfacet facet normal -0.255406 -0.311336 -0.915335 outer loop vertex -1.000000 0.500000 -0.895038 vertex -0.750000 0.250000 -0.879763 vertex -1.000000 0.250000 -0.810005 endloop endfacet facet normal -0.077487 -0.112904 -0.990580 outer loop vertex -0.750000 0.500000 -0.969896 vertex -0.500000 0.500000 -0.989453 vertex -0.500000 0.250000 -0.960958 endloop endfacet facet normal -0.292197 -0.324364 -0.899671 outer loop vertex -0.750000 0.500000 -0.969896 vertex -0.500000 0.250000 -0.960958 vertex -0.750000 0.250000 -0.879763 endloop endfacet facet normal -0.130650 0.127053 -0.983254 outer loop vertex 0.500000 0.750000 -1.243217 vertex 0.750000 0.750000 -1.276436 vertex 0.750000 0.500000 -1.308740 endloop endfacet facet normal 0.017054 0.269415 -0.962873 outer loop vertex 0.500000 0.750000 -1.243217 vertex 0.750000 0.500000 -1.308740 vertex 0.500000 0.500000 -1.313168 endloop endfacet facet normal 0.013121 0.675103 -0.737606 outer loop vertex 0.500000 1.000000 -1.052068 vertex 0.750000 1.000000 -1.047621 vertex 0.750000 0.750000 -1.276436 endloop endfacet facet normal -0.104973 0.604040 -0.790010 outer loop vertex 0.500000 1.000000 -1.052068 vertex 0.750000 0.750000 -1.276436 vertex 0.500000 0.750000 -1.243217 endloop endfacet facet normal 0.184971 0.136698 -0.973190 outer loop vertex 0.750000 1.000000 -1.047621 vertex 1.000000 1.000000 -1.000104 vertex 1.000000 0.750000 -1.035220 endloop endfacet facet normal 0.579871 0.550060 -0.600986 outer loop vertex 0.750000 1.000000 -1.047621 vertex 1.000000 0.750000 -1.035220 vertex 0.750000 0.750000 -1.276436 endloop endfacet facet normal -0.531867 0.228393 -0.815447 outer loop vertex -0.000000 0.750000 -1.051940 vertex 0.250000 0.750000 -1.215000 vertex 0.250000 0.500000 -1.285021 endloop endfacet facet normal -0.643799 0.066879 -0.762266 outer loop vertex -0.000000 0.750000 -1.051940 vertex 0.250000 0.500000 -1.285021 vertex -0.000000 0.500000 -1.073874 endloop endfacet facet normal -0.139771 0.547808 -0.824846 outer loop vertex -0.000000 1.000000 -1.006604 vertex 0.250000 1.000000 -1.048966 vertex 0.250000 0.750000 -1.215000 endloop endfacet facet normal -0.540111 0.150169 -0.828088 outer loop vertex -0.000000 1.000000 -1.006604 vertex 0.250000 0.750000 -1.215000 vertex -0.000000 0.750000 -1.051940 endloop endfacet facet normal -0.009855 0.607367 -0.794360 outer loop vertex 0.250000 1.000000 -1.048966 vertex 0.500000 1.000000 -1.052068 vertex 0.500000 0.750000 -1.243217 endloop endfacet facet normal -0.093611 0.550809 -0.829365 outer loop vertex 0.250000 1.000000 -1.048966 vertex 0.500000 0.750000 -1.243217 vertex 0.250000 0.750000 -1.215000 endloop endfacet facet normal -0.330744 0.238108 -0.913188 outer loop vertex -0.000000 0.250000 -1.285126 vertex 0.250000 0.250000 -1.375672 vertex 0.250000 0.000000 -1.440858 endloop endfacet facet normal -0.308364 0.261398 -0.914649 outer loop vertex -0.000000 0.250000 -1.285126 vertex 0.250000 0.000000 -1.440858 vertex -0.000000 0.000000 -1.356573 endloop endfacet facet normal -0.621825 0.266968 -0.736248 outer loop vertex -0.000000 0.500000 -1.073874 vertex 0.250000 0.500000 -1.285021 vertex 0.250000 0.250000 -1.375672 endloop endfacet facet normal -0.266629 0.622066 -0.736167 outer loop vertex -0.000000 0.500000 -1.073874 vertex 0.250000 0.250000 -1.375672 vertex -0.000000 0.250000 -1.285126 endloop endfacet facet normal -0.108504 0.243941 -0.963701 outer loop vertex 0.250000 0.500000 -1.285021 vertex 0.500000 0.500000 -1.313168 vertex 0.500000 0.250000 -1.376451 endloop endfacet facet normal -0.002926 0.340886 -0.940100 outer loop vertex 0.250000 0.500000 -1.285021 vertex 0.500000 0.250000 -1.376451 vertex 0.250000 0.250000 -1.375672 endloop endfacet facet normal 0.288061 0.190380 -0.938497 outer loop vertex 0.500000 0.250000 -1.376451 vertex 0.750000 0.250000 -1.299716 vertex 0.750000 0.000000 -1.350430 endloop endfacet facet normal 0.185733 0.083834 -0.979017 outer loop vertex 0.500000 0.250000 -1.376451 vertex 0.750000 0.000000 -1.350430 vertex 0.500000 0.000000 -1.397858 endloop endfacet facet normal 0.017697 -0.036068 -0.999193 outer loop vertex 0.500000 0.500000 -1.313168 vertex 0.750000 0.500000 -1.308740 vertex 0.750000 0.250000 -1.299716 endloop endfacet facet normal 0.285196 0.235199 -0.929164 outer loop vertex 0.500000 0.500000 -1.313168 vertex 0.750000 0.250000 -1.299716 vertex 0.500000 0.250000 -1.376451 endloop endfacet facet normal 0.718885 -0.023360 -0.694737 outer loop vertex 0.750000 0.500000 -1.308740 vertex 1.000000 0.500000 -1.050051 vertex 1.000000 0.250000 -1.041645 endloop endfacet facet normal 0.718023 -0.025108 -0.695566 outer loop vertex 0.750000 0.500000 -1.308740 vertex 1.000000 0.250000 -1.041645 vertex 0.750000 0.250000 -1.299716 endloop endfacet facet normal -0.231108 0.463744 -0.855295 outer loop vertex -0.500000 0.250000 -0.960958 vertex -0.250000 0.250000 -1.028510 vertex -0.250000 0.000000 -1.164061 endloop endfacet facet normal -0.599966 0.048776 -0.798537 outer loop vertex -0.500000 0.250000 -0.960958 vertex -0.250000 0.000000 -1.164061 vertex -0.500000 0.000000 -0.976228 endloop endfacet facet normal -0.041881 0.113207 -0.992688 outer loop vertex -0.500000 0.500000 -0.989453 vertex -0.250000 0.500000 -1.000000 vertex -0.250000 0.250000 -1.028510 endloop endfacet facet normal -0.259288 -0.109372 -0.959587 outer loop vertex -0.500000 0.500000 -0.989453 vertex -0.250000 0.250000 -1.028510 vertex -0.500000 0.250000 -0.960958 endloop endfacet facet normal -0.220168 0.629594 -0.745076 outer loop vertex -0.250000 0.500000 -1.000000 vertex -0.000000 0.500000 -1.073874 vertex -0.000000 0.250000 -1.285126 endloop endfacet facet normal -0.714022 0.079328 -0.695614 outer loop vertex -0.250000 0.500000 -1.000000 vertex -0.000000 0.250000 -1.285126 vertex -0.250000 0.250000 -1.028510 endloop endfacet facet normal -0.274424 -0.174306 -0.945679 outer loop vertex -0.500000 -0.750000 -0.749696 vertex -0.250000 -0.750000 -0.822242 vertex -0.250000 -1.000000 -0.776163 endloop endfacet facet normal 0.197298 0.296234 -0.934515 outer loop vertex -0.500000 -0.750000 -0.749696 vertex -0.250000 -1.000000 -0.776163 vertex -0.500000 -1.000000 -0.828943 endloop endfacet facet normal -0.618748 -0.560736 -0.550205 outer loop vertex -0.500000 -0.500000 -0.795883 vertex -0.250000 -0.500000 -1.077028 vertex -0.250000 -0.750000 -0.822242 endloop endfacet facet normal -0.274404 -0.174702 -0.945612 outer loop vertex -0.500000 -0.500000 -0.795883 vertex -0.250000 -0.750000 -0.822242 vertex -0.500000 -0.750000 -0.749696 endloop endfacet facet normal -0.190073 -0.541742 -0.818772 outer loop vertex -0.250000 -0.500000 -1.077028 vertex -0.000000 -0.500000 -1.135064 vertex -0.000000 -0.750000 -0.969651 endloop endfacet facet normal -0.381697 -0.659736 -0.647345 outer loop vertex -0.250000 -0.500000 -1.077028 vertex -0.000000 -0.750000 -0.969651 vertex -0.250000 -0.750000 -0.822242 endloop endfacet facet normal -0.002496 -0.016859 -0.999855 outer loop vertex 0.500000 -0.750000 -1.003591 vertex 0.750000 -0.750000 -1.004215 vertex 0.750000 -1.000000 -1.000000 endloop endfacet facet normal 0.000000 -0.014363 -0.999897 outer loop vertex 0.500000 -0.750000 -1.003591 vertex 0.750000 -1.000000 -1.000000 vertex 0.500000 -1.000000 -1.000000 endloop endfacet facet normal -0.130658 -0.500728 -0.855687 outer loop vertex 0.500000 -0.500000 -1.112336 vertex 0.750000 -0.500000 -1.150509 vertex 0.750000 -0.750000 -1.004215 endloop endfacet facet normal -0.002289 -0.398876 -0.917002 outer loop vertex 0.500000 -0.500000 -1.112336 vertex 0.750000 -0.750000 -1.004215 vertex 0.500000 -0.750000 -1.003591 endloop endfacet facet normal 0.427791 -0.077334 -0.900563 outer loop vertex 0.750000 -0.500000 -1.150509 vertex 1.000000 -0.500000 -1.031753 vertex 1.000000 -0.750000 -1.010285 endloop endfacet facet normal -0.020949 -0.504946 -0.862896 outer loop vertex 0.750000 -0.500000 -1.150509 vertex 1.000000 -0.750000 -1.010285 vertex 0.750000 -0.750000 -1.004215 endloop endfacet endsolid Exported from Blender-2.82 (sub 7) ================================================ FILE: examples/case/system/collapseDict ================================================ /*--------------------------------*- C++ -*----------------------------------*\ | ========= | | | \\ / F ield | OpenFOAM: The Open Source CFD Toolbox | | \\ / O peration | Version: v1906 | | \\ / A nd | Web: www.OpenFOAM.com | | \\/ M anipulation | | \*---------------------------------------------------------------------------*/ FoamFile { version 2.0; format ascii; class dictionary; object collapseDict; } // * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * // // If on, after collapsing check the quality of the mesh. If bad faces are // generated then redo the collapsing with stricter filtering. controlMeshQuality off; collapseEdgesCoeffs { // Edges shorter than this absolute value will be merged minimumEdgeLength 1e-8; // The maximum angle between two edges that share a point attached to // no other edges maximumMergeAngle 30; } collapseFacesCoeffs { // The initial face length factor initialFaceLengthFactor 0.5; // If the face can't be collapsed to an edge, and it has a span less than // the target face length multiplied by this coefficient, collapse it // to a point. maxCollapseFaceToPointSideLengthCoeff 0.3; // Allow early collapse of edges to a point allowEarlyCollapseToPoint on; // Fraction to premultiply maxCollapseFaceToPointSideLengthCoeff by if // allowEarlyCollapseToPoint is enabled allowEarlyCollapseCoeff 0.2; // Defining how close to the midpoint (M) of the projected // vertices line a projected vertex (X) can be before making this // an invalid edge collapse // // X---X-g----------------M----X-----------g----X--X // // Only allow a collapse if all projected vertices are outwith // guardFraction (g) of the distance form the face centre to the // furthest vertex in the considered direction guardFraction 0.1; } //controlMeshQualityCoeffs //{ // // Name of the dictionary that has the mesh quality coefficients used // // by motionSmoother::checkMesh // #include "meshQualityDict"; // // // The amount that minimumEdgeLength will be reduced by for each // // edge if that edge's collapse generates a poor quality face // edgeReductionFactor 0.5; // // // The amount that initialFaceLengthFactor will be reduced by for each // // face if its collapse generates a poor quality face // faceReductionFactor $initialFaceLengthFactor; // // // Maximum number of smoothing iterations for the reductionFactors // maximumSmoothingIterations 2; // // // Maximum number of outer iterations is mesh quality checking is enabled // maximumIterations 10; // // // Maximum number of iterations deletion of a point can cause a bad face // // to be constructed before it is forced to not be deleted // maxPointErrorCount 5; //} // ************************************************************************* // ================================================ FILE: examples/case/system/controlDict ================================================ /*--------------------------------*- C++ -*----------------------------------*\ | ========= | | | \\ / F ield | OpenFOAM: The Open Source CFD Toolbox | | \\ / O peration | Version: v1906 | | \\ / A nd | Web: www.OpenFOAM.com | | \\/ M anipulation | | \*---------------------------------------------------------------------------*/ FoamFile { version 2.0; format ascii; class dictionary; location "system"; object controlDict; } // * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * // application blockMesh; startFrom startTime; startTime 0; stopAt endTime; endTime 0; deltaT 0; writeControl timeStep; writeInterval 1; purgeWrite 0; writeFormat ascii; writePrecision 6; writeCompression off; timeFormat general; timePrecision 6; runTimeModifiable true; // ************************************************************************* // ================================================ FILE: examples/case/system/fvSchemes ================================================ /*--------------------------------*- C++ -*----------------------------------*\ | ========= | | | \\ / F ield | OpenFOAM: The Open Source CFD Toolbox | | \\ / O peration | Version: v1906 | | \\ / A nd | Web: www.OpenFOAM.com | | \\/ M anipulation | | \*---------------------------------------------------------------------------*/ FoamFile { version 2.0; format ascii; class dictionary; location "system"; object fvSchemes; } // * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * // ddtSchemes {} gradSchemes {} divSchemes {} laplacianSchemes {} interpolationSchemes {} snGradSchemes {} // ************************************************************************* // ================================================ FILE: examples/case/system/fvSolution ================================================ /*--------------------------------*- C++ -*----------------------------------*\ | ========= | | | \\ / F ield | OpenFOAM: The Open Source CFD Toolbox | | \\ / O peration | Version: v1906 | | \\ / A nd | Web: www.OpenFOAM.com | | \\/ M anipulation | | \*---------------------------------------------------------------------------*/ FoamFile { version 2.0; format ascii; class dictionary; location "system"; object fvSolution; } // * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * // // ************************************************************************* // ================================================ FILE: examples/chaining/flywheel.py ================================================ import os import classy_blocks as cb # A mesh for calculation of friction losses of a rotating rotor in a fluid. # See flywheel.svg for explanation of dimensions and block numbers; r_wheel = 8 t_rim = 1 w_rim = 3 w_wheel = 1 h_gap = 1 w_gap = 1 r_shaft = 1 # cell sizing cell_size = 0.2 bl_thickness = 0.05 c2c_expansion = 1.25 # patches fixed_patch = "fixed_wall" rotating_patch = "rotating_wall" mesh = cb.Mesh() # put all shapes into this list for easier 'handling'; # indexes refer to block numbers in sketch shapes = [] # block 0 shapes.append( cb.Cylinder([0, 0, 0], [w_gap, 0, 0], [0, r_wheel - t_rim, 0]) # axis point 1 # axis point 2 # radius point 1 ) shapes.append(cb.Cylinder.chain(shapes[0], (w_rim - w_wheel) / 2)) shapes.append(cb.ExtrudedRing.expand(shapes[0], t_rim)) shapes.append(cb.ExtrudedRing.expand(shapes[2], h_gap)) shapes.append(cb.ExtrudedRing.chain(shapes[3], w_rim)) shapes.append(cb.ExtrudedRing.chain(shapes[4], w_gap)) shapes.append(cb.ExtrudedRing.contract(shapes[5], r_wheel - t_rim)) shapes.append(cb.ExtrudedRing.contract(shapes[6], r_shaft)) shapes.append(cb.ExtrudedRing.chain(shapes[7], (w_rim - w_wheel) / 2, start_face=True)) # Chopping: # Cells on fixed wall are cell_size; # rotating wall (wheel) is resolved, thus bl_thickness. # Depending on geometry, two different scenarios are possible: # a) use end_size and c2c_expansion or # b) start_size and end_size # In a), too large cells might be obtained far away from the wall, or # in b), to high cell-to-cell expansion might be made. def double_chop(function): function(length_ratio=0.5, start_size=bl_thickness, c2c_expansion=c2c_expansion) function(length_ratio=0.5, end_size=bl_thickness, c2c_expansion=1 / c2c_expansion) # Axial for i in (1, 2, 4, 6, 8): double_chop(shapes[i].chop_axial) # Radial for i in (2, 4, 6, 7): double_chop(shapes[i].chop_radial) shapes[0].chop_radial(end_size=bl_thickness, c2c_expansion=1 / c2c_expansion) # adjust this cell size to obtain roughly the same cell sizes # in core and shell of the cylinder shapes[2].chop_tangential(start_size=4 * cell_size) # Patch names: for i in (0, 2, 3): shapes[i].set_start_patch(fixed_patch) for i in (3, 4, 5): shapes[i].set_outer_patch(fixed_patch) for i in (5, 6, 7): shapes[i].set_end_patch(fixed_patch) for shape in shapes: mesh.add(shape) mesh.set_default_patch(rotating_patch, "wall") mesh.modify_patch(fixed_patch, "wall") mesh.write(os.path.join("..", "case", "system", "blockMeshDict"), debug_path="debug.vtk") ================================================ FILE: examples/chaining/helmholtz_nozzle.py ================================================ import os import classy_blocks as cb # A nozzle with a chamber that produces self-induced oscillations. # See helmholtz_nozzle.svg for geometry explanation. # geometry data (all dimensions in meters): # inlet pipe r_inlet = 10e-3 l_inlet = 50e-3 # nozzle r_nozzle = 6e-3 l_nozzle = 20e-3 # chamber l_chamber_inner = 100e-3 l_chamber_outer = 105e-3 r_chamber_outer = 20e-3 # outlet l_outlet = 80e-3 # cell sizing cell_size = 1.5e-3 bl_size = 0.15e-3 c2c_expansion = 1.1 axial_expansion = 2 # make cells in non-interesting places longer to save on count mesh = cb.Mesh() # inlet inlet = cb.Cylinder([0, 0, 0], [l_inlet, 0, 0], [0, 0, r_inlet]) inlet.chop_axial(start_size=cell_size * axial_expansion, end_size=cell_size) inlet.chop_tangential(start_size=cell_size) inlet.set_start_patch("inlet") inlet.set_outer_patch("wall") mesh.add(inlet) # nozzle nozzle = cb.Frustum.chain(inlet, l_nozzle, r_nozzle) # cell sizing: make sure bl_size is correct here nozzle.chop_axial(length_ratio=0.5, start_size=cell_size * axial_expansion, end_size=cell_size) nozzle.chop_axial(length_ratio=0.5, start_size=cell_size, end_size=bl_size) nozzle.chop_radial(end_size=bl_size, c2c_expansion=1 / c2c_expansion) nozzle.set_outer_patch("wall") mesh.add(nozzle) # chamber: inner cylinder chamber_inner = cb.Cylinder.chain(nozzle, l_chamber_inner) # create smaller cells at inlet and outlet but leave them bigger in the middle; chamber_inner.chop_axial(length_ratio=0.25, start_size=bl_size, end_size=cell_size) chamber_inner.chop_axial(length_ratio=0.25, start_size=cell_size, end_size=cell_size * axial_expansion) chamber_inner.chop_axial(length_ratio=0.25, start_size=cell_size * axial_expansion, end_size=cell_size) chamber_inner.chop_axial(length_ratio=0.25, start_size=cell_size, end_size=bl_size) mesh.add(chamber_inner) # chamber outer: expanded ring; the end face will be moved when the mesh is assembled chamber_outer = cb.ExtrudedRing.expand(chamber_inner, r_chamber_outer - r_inlet) chamber_outer.chop_radial(length_ratio=0.5, start_size=bl_size, c2c_expansion=c2c_expansion) chamber_outer.chop_radial(length_ratio=0.5, end_size=bl_size, c2c_expansion=1 / c2c_expansion) chamber_outer.set_start_patch("wall") chamber_outer.set_end_patch("wall") chamber_outer.set_outer_patch("wall") mesh.add(chamber_outer) # translate outer points of outer chamber (and edges) to get # that inverted cone at the end; # this could also be done by a RevolvedRing for face in chamber_outer.sketch_2.faces: for i in (1, 2): face.points[i].translate([l_chamber_outer - l_chamber_inner, 0, 0]) face.add_edge(1, cb.Origin([l_inlet + l_nozzle + l_chamber_outer, 0, 0])) # outlet pipe outlet = cb.Cylinder.chain(chamber_inner, l_outlet) outlet.chop_axial(length_ratio=0.5, start_size=bl_size, end_size=cell_size) outlet.chop_axial(length_ratio=0.5, start_size=cell_size, end_size=cell_size * axial_expansion) outlet.set_outer_patch("wall") outlet.set_end_patch("outlet") mesh.add(outlet) mesh.write(os.path.join("..", "case", "system", "blockMeshDict"), debug_path="debug.vtk") ================================================ FILE: examples/chaining/labyrinth.py ================================================ import os from typing import List import classy_blocks as cb from classy_blocks.util import functions as f mesh = cb.Mesh() front_point = f.vector(20, -100, 0) above_point = f.vector(0, 0, 100) orienter = cb.ViewpointReorienter(front_point, above_point) extrudes: List[cb.Extrude] = [] face = cb.Face([[0, 0, 0], [1, 0, 0], [1, 1, 0], [0, 1, 0]]) extrude = cb.Extrude(face, 1) mesh.add(extrude) for side in ("top", "right", "top", "top", "left", "top"): face = extrude.get_face(side) # 'left', 'bottom' and 'front' faces' normals point INTO # the blocks so the default extrude direction won't work here. normal = f.unit_vector(face.center - extrude.center) extrude = cb.Extrude(face, normal) # Any extrude that was not made from the 'top' face # is now oriented differently from the first extrude. # To keep things simple and intuitive, let's reorient # the operations to keep them consistent - 'top' faces # facing upwards. orienter.reorient(extrude) mesh.add(extrude) mesh.set_default_patch("walls", "wall") grader = cb.FixedCountGrader(mesh, 5) grader.grade() mesh.write(os.path.join("..", "case", "system", "blockMeshDict"), debug_path="debug.vtk") ================================================ FILE: examples/chaining/orifice_plate.py ================================================ import os import classy_blocks as cb # see orifice_plate.svg for sketch D = 0.1 # [m] d_0 = 0.025 t = 0.005 # thickness of orifice plate entry_length = 5 # *D exit_length = 8 # *D # chopping cell_size = t # use much less in real life # to save on simulation time # use bigger cells in lenghty entry/exit sections cell_dilution = 2 mesh = cb.Mesh() r_0 = d_0 / 2 r_1 = r_0 + t / 4 r_2 = r_0 + t / 2 shapes = [None] * 7 shapes[0] = cb.Cylinder([0, 0, 0], [D * entry_length, 0, 0], [0, r_1, 0]) shapes[1] = cb.Frustum.chain(shapes[0], t / 4, r_0) shapes[2] = cb.Cylinder.chain(shapes[1], t / 4) shapes[3] = cb.Frustum.chain(shapes[2], t / 2, r_2) shapes[4] = cb.Cylinder.chain(shapes[3], D * exit_length) shapes[5] = cb.ExtrudedRing.expand(shapes[0], D / 2 - r_1) shapes[6] = cb.ExtrudedRing.expand(shapes[4], D / 2 - r_2) # chop to a sensible number of cells; # dilute long inlet/outlet sections shapes[0].chop_radial(start_size=cell_size) shapes[0].chop_tangential(start_size=cell_size) shapes[0].chop_axial(length_ratio=0.9, start_size=cell_size * cell_dilution, end_size=cell_size) shapes[0].chop_axial(length_ratio=0.1, start_size=cell_size, end_size=t / 8) shapes[6].chop_axial(length_ratio=0.1, start_size=t / 8, end_size=cell_size) shapes[6].chop_axial(length_ratio=0.9, start_size=cell_size, end_size=cell_size * cell_dilution) shapes[5].chop_radial(start_size=cell_size / 2, end_size=2 * cell_size) shapes[6].chop_radial(start_size=cell_size / 2, end_size=2 * cell_size) for i in (1, 2, 3): shapes[i].chop_axial(start_size=t / 8) for s in shapes: if s is not None: mesh.add(s) # patches for i in (0, 5): shapes[i].set_start_patch("inlet") for i in (4, 6): shapes[i].set_end_patch("outlet") mesh.set_default_patch("walls", "wall") mesh.write(os.path.join("..", "case", "system", "blockMeshDict"), debug_path="debug.vtk") ================================================ FILE: examples/chaining/tank.py ================================================ import os import classy_blocks as cb # a cylindrical tank with round end caps diameter = 0.5 length = 0.5 # including end caps mesh = cb.Mesh() cylinder = cb.Cylinder([0, 0, 0], [length, 0, 0], [0, diameter / 2, 0]) wall_name = "tank_wall" cylinder.set_outer_patch(wall_name) start_cap = cb.Hemisphere.chain(cylinder, start_face=True) start_cap.set_outer_patch(wall_name) end_cap = cb.Hemisphere.chain(cylinder, start_face=False) end_cap.set_outer_patch(wall_name) mesh.add(cylinder) mesh.add(start_cap) mesh.add(end_cap) grader = cb.SimpleGrader(mesh, 0.05) grader.grade() mesh.write(os.path.join("..", "case", "system", "blockMeshDict"), debug_path="debug.vtk") ================================================ FILE: examples/chaining/test_tube.py ================================================ import os import classy_blocks as cb # a test tube as a reactor with a part of atmosphere above and below it outer_diameter = 0.015 # main body, body_length = 0.1 cap_diameter = 0.007 # connected to a round end cap conical_length = 0.02 # with a frustum of this length wall_thickness = 0.001 # there's some reacting stuff in the tube, reaching up to the top of # the conical section stuff_zone = "stuff" # outside atmosphere extends upwards, away and below the 'body' atm_height = 0.1 atm_radius = 0.1 # refer to other examples for a more proper-ish grading setup h = 0.001 h_atm = 10 * h mesh = cb.Mesh() # the inside of test tube is modeled by 3 shapes body = cb.Cylinder([0, 0, 0], [0, 0, -body_length], [outer_diameter / 2, 0, 0]) body.chop_axial(start_size=h) body.chop_radial(start_size=h) body.chop_tangential(start_size=h) mesh.add(body) cone = cb.Frustum.chain(body, conical_length, cap_diameter / 2) cone.chop_axial(start_size=h) cone.set_cell_zone(stuff_zone) mesh.add(cone) end_cap = cb.Hemisphere.chain(cone) end_cap.chop_axial(start_size=h / 2) end_cap.set_cell_zone(stuff_zone) mesh.add(end_cap) # atmosphere atm_above = cb.Cylinder.chain(body, atm_height, start_face=True) atm_above.chop_axial(start_size=h_atm) atm_above.set_end_patch("atmosphere") mesh.add(atm_above) atm_wall = cb.ExtrudedRing.expand(atm_above, wall_thickness) atm_wall.chop_radial(start_size=h_atm) atm_wall.set_end_patch("atmosphere") mesh.add(atm_wall) atm_side_above = cb.ExtrudedRing.expand(atm_wall, atm_radius) atm_side_above.chop_radial(start_size=h_atm) atm_side_above.set_end_patch("atmosphere") atm_side_above.set_outer_patch("atmosphere") mesh.add(atm_side_above) atm_side_below = cb.ExtrudedRing.chain(atm_side_above, body_length, start_face=True) atm_side_below.chop_axial(start_size=h_atm) atm_side_below.set_outer_patch("atmosphere") atm_side_below.set_end_patch("atmosphere") mesh.add(atm_side_below) mesh.set_default_patch("tube_wall", "wall") mesh.write(os.path.join("..", "case", "system", "blockMeshDict"), debug_path="debug.vtk") ================================================ FILE: examples/chaining/venturi_tube.py ================================================ import os import numpy as np import classy_blocks as cb from classy_blocks.util import functions as f def calculate_fillet(r_pipe, r_fillet, a_cone): # see venturi_tube.svg for explanation a_cone = f.deg2rad(a_cone) y_center = r_pipe - r_fillet l_f = abs(r_fillet * np.sin(a_cone)) r_2 = y_center + r_fillet * np.cos(a_cone) r_mid = y_center + r_fillet * np.cos(a_cone / 2) return l_f, r_2, r_mid def calculate_cone(r_start, r_end, a_cone): return abs(r_end - r_start) / np.tan(f.deg2rad(a_cone)) # see venturi_tube.svg for sketch # https://www.researchgate.net/figure/The-Critical-Dimensions-of-the-Classical-Venturi-Tube-Source-p-24-Principles-and_fig5_311949745 D = 0.1 # [m] d = 0.03 # exaggerated to illustrate the awesomeness entry_length = 5 # *D entry_angle = 20 # degrees exit_length = 8 # *D exit_angle = 10 # degrees fillet_radius = 1.5 * D # also exaggerated to display even more awesomeness # chopping cell_size = 0.08 * D # to save on simulation time # use bigger cells in lenghty entry/exit sections cell_dilution = 5 mesh = cb.Mesh() shapes = [] # entry tube shapes.append(cb.Cylinder([0, 0, 0], [D * entry_length, 0, 0], [0, D / 2, 0])) # Contraction: two fillets and a cone in between # fillet from entry cylinder to entry cone l_fillet_1, r_fillet_1, r_fillet_1_mid = calculate_fillet(D / 2, fillet_radius, entry_angle / 2) # fillet from entry cone to middle cylinder l_fillet_2, r_fillet_2, r_fillet_2_mid = calculate_fillet(d / 2, -fillet_radius, entry_angle / 2) # length of cone connecting the fillets l_cone = calculate_cone(r_fillet_1, r_fillet_2, entry_angle / 2) - l_fillet_1 - l_fillet_2 # print(l_fillet_1, l_cone, l_fillet_2) # print(r_fillet_1, r_fillet_2) shapes.append(cb.Frustum.chain(shapes[-1], l_fillet_1, r_fillet_1, radius_mid=r_fillet_1_mid)) shapes.append(cb.Frustum.chain(shapes[-1], l_cone, r_fillet_2)) shapes.append(cb.Frustum.chain(shapes[-1], l_fillet_2, d / 2, radius_mid=r_fillet_2_mid)) # the narrowest part shapes.append(cb.Cylinder.chain(shapes[-1], d)) # expansion: # same as contraction but at different angle l_fillet_3, r_fillet_3, r_fillet_3_mid = calculate_fillet(d / 2, -fillet_radius, exit_angle / 2) l_fillet_4, r_fillet_4, r_fillet_4_mid = calculate_fillet(D / 2, fillet_radius, exit_angle / 2) l_cone = calculate_cone(r_fillet_3, r_fillet_4, exit_angle / 2) - l_fillet_3 - l_fillet_4 # print(l_fillet_3, l_cone, l_fillet_4) # print(r_fillet_3, r_fillet_4) shapes.append(cb.Frustum.chain(shapes[-1], l_fillet_3, r_fillet_3, radius_mid=r_fillet_3_mid)) shapes.append(cb.Frustum.chain(shapes[-1], l_cone, r_fillet_4)) shapes.append(cb.Frustum.chain(shapes[-1], l_fillet_4, D / 2, radius_mid=r_fillet_4_mid)) shapes.append(cb.Cylinder.chain(shapes[-1], D * exit_length)) # all cells sizes in longitudinal direction are fixed by the first block shapes[0].chop_radial(start_size=cell_size) shapes[0].chop_tangential(start_size=cell_size) # use smaller cells in smaller diameters for s in shapes[1:-1]: s.chop_axial(start_size=cell_size * s.sketch_1.radius * 2 / D, end_size=cell_size * s.sketch_2.radius * 2 / D) # dilute cells in first and last block shapes[0].chop_axial(start_size=cell_size * cell_dilution, end_size=cell_size) shapes[-1].chop_axial(end_size=cell_size * cell_dilution, start_size=cell_size) # patches shapes[0].set_start_patch("inlet") shapes[-1].set_end_patch("outlet") mesh.set_default_patch("walls", "wall") for s in shapes: mesh.add(s) mesh.write(os.path.join("..", "case", "system", "blockMeshDict"), debug_path="debug.vtk") ================================================ FILE: examples/complex/airfoil/airfoil.py ================================================ from typing import ClassVar import numpy as np import classy_blocks as cb from classy_blocks.util import functions as f # This rather lengthy tutorial does the following: # - reads 2D airfoil points* # - rotates the airfoil to a desired angle of attack # - scales it to actual chord length # - creates a small domain around the airfoil # - maintains thickness of 1 in 3rd dimension** # - optimizes its blocking for best results # The example does not aim to produce production-ready # airfoil meshes but serves as a demonstration of: # - creation and manipulation of curves # - usage of OnCurve edges # - Sketch optimization (2D geometry) # * A word on trailing edges # NACA equations produce a blunt trailing edge if no correction is applied # and this 'feature' is exploited here so that a blocking where # thin boundary layer cells do not spread into the domain downstream. # This blocking will not work with sharp trailing edges. # Also, real-life geometry will not work with infinitely # sharp trailing edges (or people around them won't). # ** This is a default for 2D OpenFOAM cases FILE_NAME = "naca2414.dat" ANGLE_OF_ATTACK = 10 # in degrees CHORD = 0.5 # desired chord (provided the one from points is 1) OPTIMIZE = True # Set to False to skip optimization CELL_SIZE = 0.025 BL_THICKNESS = 0.001 # thickness of boundary layer cells C2C_EXPANSION = 1.1 # expansion ratio in boundary layer mesh = cb.Mesh() ### Load airfoil curve def get_curve(z: float) -> cb.SplineInterpolatedCurve: """Loads 2D points from a Selig file and converts it to 3D by adding a provided z-coordinate.""" raw_data = np.loadtxt(FILE_NAME, skiprows=1) * CHORD z_dimensions = np.ones((len(raw_data),)) * z points = np.hstack((raw_data, z_dimensions[:, None])) curve = cb.SplineInterpolatedCurve(points) curve.rotate(f.deg2rad(-ANGLE_OF_ATTACK), [0, 0, 1]) return curve foil_curve = get_curve(0) top_foil_curve = get_curve(1) ### Select approximate point positions: # refer to the airfoil.svg sketch for explanation # and indexes points = np.zeros((18, 3)) points[0] = [-CHORD / 2, 0, 0] points[1] = [0, CHORD / 2, 0] points[2] = [CHORD, CHORD / 2, 0] points[3] = [1.5 * CHORD, CHORD / 2, 0] points[4] = [1.5 * CHORD, CHORD / 4, 0] points[5] = [1.5 * CHORD, -CHORD / 4, 0] points[6] = [1.5 * CHORD, -CHORD / 2, 0] points[7] = [CHORD, -CHORD / 2, 0] points[8] = [0, -CHORD / 2, 0] # points 9...15 with point 12 being set first param_12 = foil_curve.get_closest_param(points[0]) curve_params = np.concatenate( (np.linspace(0, param_12, num=3, endpoint=False), [param_12], np.linspace(param_12, 1, num=4)[1:]) ) for i, t in enumerate(curve_params): points[i + 9] = foil_curve.get_point(t) # create a sketch on the bottom class AirfoilSketch(cb.MappedSketch): quads: ClassVar = [ [12, 11, 1, 0], # 0 [11, 10, 2, 1], # 1 [10, 9, 16, 2], # 2 [9, 15, 17, 16], # 3 [15, 14, 7, 17], # 4 [14, 13, 8, 7], # 5 [13, 12, 0, 8], # 6 [2, 16, 4, 3], # 7 [16, 17, 5, 4], # 8 [17, 7, 6, 5], # 9 ] def __init__(self, points): super().__init__(points, self.quads) # determine initial positions for points 16 and 17 # with smoothing smoother = cb.SketchSmoother(self) smoother.smooth() # Optimize: optimizer = cb.SketchOptimizer(self) pos = self.positions # Points that slide along the airfoil curve for i, point_index in enumerate((10, 11, 12, 13, 14)): initial_param = curve_params[i] optimizer.add_clamp(cb.CurveClamp(pos[point_index], foil_curve, initial_param)) # Points that move in their X-Y plane for i in (16, 17): optimizer.add_clamp(cb.PlaneClamp(pos[i], pos[i], [0, 0, 1])) # Points that move along domain edges def optimize_along_line(point_index, line_index_1, line_index_2): clamp = cb.LineClamp(pos[point_index], pos[line_index_1], pos[line_index_2]) optimizer.add_clamp(clamp) optimize_along_line(2, 1, 3) optimize_along_line(7, 8, 6) optimize_along_line(4, 3, 6) optimize_along_line(5, 3, 6) # point 0: on arc clamp = cb.RadialClamp(pos[0], [0, 0, 0], [0, 0, 1]) optimizer.add_clamp(clamp) optimizer.optimize(tolerance=1e-5, method="SLSQP", relax=True) # edges were added in super().__init__() but now positions have changed and # we have to adjust for that self.add_edges() def add_edges(self): for i in (0, 1, 2, 4, 5, 6): self.faces[i].add_edge(0, cb.OnCurve(foil_curve.copy())) for i in (0, 6): self.faces[i].add_edge(2, cb.Origin([0, 0, 0])) ### Create an extruded shape base = AirfoilSketch(points) shape = cb.ExtrudedShape(base, [0, 0, 1]) ### Set cell size/grading; # Keep in mind that not all blocks need exact specification as # chopping will propagate automatically through blocking shape.chop(2, count=1) # 1 cell in the 3rd dimension (2D domain) # keep consistent first cell thickness by using edge grading shape.operations[1].chop(1, start_size=BL_THICKNESS, c2c_expansion=C2C_EXPANSION, take="max", preserve="start_size") # This is guesswork! Can be solved with a different blocking (that will product more even block sizes), # a lot of math (check the cell size of block 3) or an automatic grader (TODO). shape.operations[8].chop(1, start_size=BL_THICKNESS, end_size=CELL_SIZE, preserve="end_size") ### Set patches shape.set_start_patch("topAndBottom") shape.set_end_patch("topAndBottom") for i in range(7): shape.operations[i].set_patch("front", "airfoil") mesh.modify_patch("airfoil", "wall") mesh.modify_patch("topAndBottom", "empty") mesh.set_default_patch("freestream", "patch") mesh.add(shape) # Chop remaining blocks with an automatic grader (see the comment at manual grading above) grader = cb.SimpleGrader(mesh, CELL_SIZE) grader.grade(take="max") ### Write the mesh mesh.write("../../case/system/blockMeshDict", debug_path="debug.vtk") ================================================ FILE: examples/complex/cyclone/README ================================================ An advanced example with optimization, custom shapes, sketches and regions. Work in progress, report bugs promptly! Usage 1. Edit dimensions and meshing parameters in parameters.py 2. Read through comments in cyclone.py. 3. Run python cyclone.py Comments - To see initial blocking without optimization, comment out the optimizer.optimize(...) line in cyclone.py. - To visualize blocks as they are constructed, call mesh.write() after the desired step (that is, added Region) and call sys.exit(), then open debug.vtk. - To (hopefully) see blocking better, you can try the Shrink filter in paraview, set it to 0.99 then view it as Wireframe. Under Display tab, tick Render Lines As Tubes and set Line Witdh to 5 or so. ================================================ FILE: examples/complex/cyclone/__init__.py ================================================ ================================================ FILE: examples/complex/cyclone/cyclone.py ================================================ #!/usr/bin/env python import os import numpy as np import parameters as params from geometry import geometry from regions.body import ChainSketch, Cone, LowerBody, UpperBody from regions.core import Core, Outlet from regions.fillaround import FillAround from regions.inlet import InletExtension, InletPipe from regions.inner_ring import InnerRing from regions.pipe import Pipe from regions.region import Region from regions.skirt import Skirt import classy_blocks as cb from classy_blocks.base.exceptions import UndefinedGradingsError from classy_blocks.util import functions as f mesh = cb.Mesh() # A Region is an independent part of the mesh, constructed from # various other entities but each Region has the same methods and properties. def add_regions(regions: list[Region]) -> None: for region in regions: for element in region.elements: mesh.add(element) region.project() region.set_patches() # First (bad) guess at blocking: inlet is a semicylinder # with top faces snapped to body, then the two faces of core # are moved towards the center inlet = InletPipe() # Skirt contains 4 blocks, extruded from displaced inlet top faces skirt = Skirt(inlet.inlet.shell) # Skirt and FillAround form a complete outer ring fillaround = FillAround(skirt.lofts[0], skirt.lofts[-1]) # Inner ring adds another layer of blocks inside the above ring. inner_ring = InnerRing([*inlet.inner_lofts, *skirt.elements, *fillaround.elements]) optimize_regions = [inlet, skirt, fillaround, inner_ring] add_regions(optimize_regions) # This bad blocking needs to be improved before # making further blocks. It is done on a semi-finished mesh: mesh.assemble() # Now coincident points have been merged into Vertices and each got its own index. # write out the current blocking and get vertex numbers from paraview try: mesh.write("...", debug_path="pre-optimization.vtk") except UndefinedGradingsError: pass # We'll redistribute (and fix) inner ring points evenly or optimization # will find a better solution with a thin block in the vindexes = [68, 69, 70, 84, 82, 80, 78, 76, 74, 71, 67, 66] current_angles = [f.to_polar(mesh.vertices[i].position, axis="z")[1] for i in vindexes] angle_offset = current_angles[0] uniform_angles = np.linspace(2 * np.pi, 0, num=len(current_angles), endpoint=False) + angle_offset for i, vindex in enumerate(vindexes): position = mesh.vertices[vindex].position polar = f.to_polar(position, axis="z") polar[1] = uniform_angles[i] mesh.vertices[vindex].move_to(f.to_cartesian(polar, axis="z")) # correct points that created invalid cells neighbours = [mesh.vertices[i].position for i in (30, 57, 84, 70)] mesh.vertices[31].move_to(np.average(neighbours, axis=0)) neighbours = [mesh.vertices[i].position for i in (25, 67, 71, 33)] mesh.vertices[22].move_to(np.average(neighbours, axis=0)) # Now, optimize the bad blocks in the inlet. optimizer = cb.MeshOptimizer(mesh) optimizer.config.relaxation_iterations = 5 optimizer.config.method = "SLSQP" optimizer.config.abs_tol = 200 clamps = [] for region in optimize_regions: for clamp in region.get_clamps(mesh): optimizer.add_clamp(clamp) optimizer.optimize() # Now Block objects contain optimization results but those are not reflected in # user-created Operations. Mesh.backport() will copy the data back. mesh.backport() # We'll add new stuff so we don't need those half-finished Blocks. mesh.clear() # The optimized inlet is short to aid optimization; # it's time to extend it to specifications. inlet_extension = InletExtension(inlet) add_regions([inlet_extension]) # Upper body is an extension of inner and outer top rings # (around outlet pipe) upper_sketch = ChainSketch([skirt, fillaround, inner_ring]) upper = UpperBody(upper_sketch, geometry.l["upper"]) # Lower body begins where outlet pipe ends; # it extends the above rings plus adds pipe thickness and core lower_sketch = ChainSketch([upper]) lower = LowerBody(lower_sketch, geometry.l["lower"]) lower.elements[0].chop(2, start_size=params.BULK_SIZE) pipe = Pipe(lower) add_regions([upper, lower, pipe]) # Core is a 9-block-core cylinder, created from 12 points of pipe ring. # Again, instead of finding those 12 points (which are difficult to find and sort) # it is easier to re-assemble the mesh and get the points from debug.vtk in ParaView. mesh.assemble() vindexes = [173, 169, 170, 179, 180, 182, 184, 186, 188, 190, 177, 175] vindexes.reverse() points = [mesh.vertices[i].position for i in vindexes] mesh.clear() # Again, we'll add new stuff right away core = Core(points) cone_sketch = ChainSketch([lower, pipe, core]) cone = Cone(cone_sketch) outlet = Outlet(core.cylinder) add_regions([core, cone, outlet]) # What's left is to mirror the inlet and upper rings to form a round inlet; # Operation.mirror() is what we need def mirror_region(region: Region): mirrored = [] for element in region.elements: mirror = element.copy().mirror([0, 0, 1], [0, 0, 0]) mesh.add(mirror) mirrored.append(mirror) return mirrored fillaround_mirror = mirror_region(fillaround) inner_ring_mirror = mirror_region(inner_ring) mirror_region(skirt) mirror_region(inlet) mirror_region(inlet_extension) mesh.set_default_patch("walls", "wall") grader = cb.InflationGrader(mesh, params.BL_THICKNESS, params.BULK_SIZE, c2c_expansion=params.C2C_EXPANSION) grader.grade() mesh.add_geometry(geometry.surfaces) mesh.settings.scale = params.MESH_SCALE mesh.write(os.path.join("..", "..", "case", "system", "blockMeshDict"), debug_path="debug.vtk") ================================================ FILE: examples/complex/cyclone/geometry.py ================================================ import dataclasses from parameters import ( D_BODY, D_CONE, D_INLET, D_OUTLET, DIM_SCALE, L_BODY, L_CONE, L_INLET, L_OUTLET_IN, L_OUTLET_OUT, T_PIPE, ) from classy_blocks.base.exceptions import GeometryConstraintError from classy_blocks.cbtyping import NPPointType from classy_blocks.util import functions as f from classy_blocks.util.constants import vector_format as fvect @dataclasses.dataclass class Geometry: """Holds user-provided parameters and conversions to SI units; simple relations and shortcuts for easier construction""" def __post_init__(self): # constraints check if self.r["inlet"] >= self.r["body"] / 2: raise GeometryConstraintError("Inlet must be smaller than body/2") if self.z["skirt"] <= self.z["upper"]: raise GeometryConstraintError("Outlet inside is too short") @property def r(self) -> dict[str, float]: """Radii as defined in parameters""" return { "inlet": DIM_SCALE * D_INLET / 2, "outlet": DIM_SCALE * D_OUTLET / 2, "body": DIM_SCALE * D_BODY / 2, "pipe": DIM_SCALE * (D_OUTLET / 2 + T_PIPE), "cone": DIM_SCALE * D_CONE / 2, } @property def l(self) -> dict["str", "float"]: # noqa: E743 """Lengths that matter""" skirt = 0.1 * self.r["inlet"] l_body = DIM_SCALE * L_BODY - skirt - self.r["inlet"] l_outlet_in = DIM_SCALE * L_OUTLET_IN upper = l_outlet_in - self.r["inlet"] - skirt return { "inlet": DIM_SCALE * L_INLET, "outlet": DIM_SCALE * (L_OUTLET_IN + L_OUTLET_OUT), "skirt": skirt, "upper": upper, "lower": l_body - upper, "body": l_body, "cone": DIM_SCALE * L_CONE, "pipe": T_PIPE * DIM_SCALE, # pipe thickness } @property def inlet(self) -> list[NPPointType]: point_2 = f.vector(0, -self.r["body"] + self.r["inlet"], 0) point_0 = point_2 + f.vector(-self.l["inlet"], 0, 0) point_1 = point_2 + f.vector(-self.r["body"] * 1.1, 0, 0) return [point_0, point_1, point_2] @property def z(self) -> dict[str, float]: """z-coordinates of cyclone parts""" z_in_sk = self.r["inlet"] + self.l["skirt"] return { "skirt": -z_in_sk, "upper": -z_in_sk - self.l["upper"], "lower": -self.l["body"] - z_in_sk, "cone": -self.l["body"] - self.l["cone"] - z_in_sk, } @property def surfaces(self): """Returns definitions of searchable geometries for projections""" delta_in = f.vector(-2 * self.l["inlet"], 0, 0) p_body = f.vector(0, 0, -2 * self.z["cone"]) def cylinder(name, point_1, point_2, radius): return { name: [ "type searchableCylinder", f"point1 {fvect(point_1)}", f"point2 {fvect(point_2)}", f"radius {radius}", ], } return { **cylinder("inlet", self.inlet[0] - delta_in, self.inlet[2] + delta_in, self.r["inlet"]), **cylinder("body", p_body, -p_body, self.r["body"]), **cylinder("outlet", p_body, -p_body, self.r["outlet"]), **cylinder("pipe", p_body, -p_body, self.r["pipe"]), "cone": [ "type searchableCone", f"point1 {fvect([0, 0, self.z['lower']])}", f"radius1 {self.r['body']}", "innerRadius1 0", f"point2 {fvect([0, 0, self.z['cone']])}", f"radius2 {self.r['cone']}", "innerRadius2 0", ], } geometry = Geometry() ================================================ FILE: examples/complex/cyclone/parameters.py ================================================ ### Geometry # See docs/geometry.svg for a quick sketch # Diameter and length of inlet pipe [mm] D_INLET = 120 L_INLET = 500 # Body diameter and length D_BODY = 300 L_BODY = 500 # Outlet pipe inner diameter and length; # the 'in' part is inside body, out is extended away from the top surface. # Keep in mind that L_OUTLET_IN must be greater than D_INLET D_OUTLET = 140 L_OUTLET_IN = 140 L_OUTLET_OUT = 300 # outlet pipe wall thickness T_PIPE = 10 # Length and end diameter of the conical section L_CONE = 400 D_CONE = 120 DIM_SCALE = 1 # multiply all above dimensions with this MESH_SCALE = 0.001 # goes into blockMeshDict.scale # Mesh # (use same dimensions as for geometry) BULK_SIZE = DIM_SCALE * 10 BL_THICKNESS = DIM_SCALE * 0.5 C2C_EXPANSION = 1.2 ================================================ FILE: examples/complex/cyclone/regions/__init__.py ================================================ ================================================ FILE: examples/complex/cyclone/regions/body.py ================================================ from collections.abc import Sequence import parameters as params from geometry import geometry as geo from regions.region import Region import classy_blocks as cb from classy_blocks.base.transforms import Scaling, Translation from classy_blocks.construct.flat.sketch import Sketch from classy_blocks.construct.operations.operation import Operation from classy_blocks.construct.shapes.round import RoundSolidShape from classy_blocks.util import functions as f from classy_blocks.util.constants import TOL class ChainSketch(Sketch): """Collects bottom-most faces of given regions and creates a sketch from them""" @property def grid(self): return [self.faces] @staticmethod def _get_faces(ops: Sequence[Operation]) -> list[cb.Face]: """Skims faces from given operations and reorders them so that they are ready for extruding or whatever""" faces: list[cb.Face] = [] for operation in ops: # TODO: copy edges and other data too face = operation.get_closest_face([0, 0, 10 * geo.z["cone"]]) # skip faces, made from inlet core blocks if face.center[2] > 0.99 * (geo.z["skirt"]): continue # reorient if face.normal[2] > 0: face.invert() faces.append(face) return faces @staticmethod def _add_arcs(faces: list[cb.Face]) -> None: """Adds arc edges between outermost points of outermost faces to create round shape""" # add arcs to outermost radius (body and cone) max_radius = 0 for face in faces: radii = [f.to_polar(point, axis="z")[0] for point in face.point_array] max_radius = max(max_radius, max(radii)) for face in faces: for i in range(4): polar_1 = f.to_polar(face.point_array[i], axis="z") polar_2 = f.to_polar(face.point_array[(i + 1) % 4], axis="z") if abs(polar_1[0] - max_radius) < 2 * TOL and abs(polar_2[0] - max_radius) < 2 * TOL: face.add_edge(i, cb.Origin([0, 0, polar_1[2]])) def __init__(self, regions: list[Region]): # gather faces ops: Sequence[Operation] = [] for region in regions: ops += region.elements faces = self._get_faces(ops) self._add_arcs(faces) self._faces = faces @property def faces(self): return self._faces @property def center(self): return f.vector(0, 0, self.faces[0].center[2]) @property def n_segments(self): return 12 class BodyShape(RoundSolidShape): @property def shell(self): return [] @property def core(self): return [] class UpperBody(Region): def __init__(self, sketch: ChainSketch, length: float): self.sketch = sketch self.body = BodyShape(self.sketch, [Translation([0, 0, -length])]) def chop(self): self.elements[0].chop(2, start_size=params.BULK_SIZE) @property def elements(self): return self.body.operations class LowerBody(UpperBody): pass class Cone(UpperBody): def __init__(self, sketch: ChainSketch): self.sketch = sketch self.body = BodyShape( self.sketch, [ Translation([0, 0, -self.geo.l["cone"]]), Scaling(self.geo.r["cone"] / self.geo.r["body"], f.vector(0, 0, self.geo.z["cone"])), ], ) ================================================ FILE: examples/complex/cyclone/regions/core.py ================================================ from collections.abc import Sequence from typing import ClassVar import numpy as np import parameters as params from regions.region import Region import classy_blocks as cb from classy_blocks.base.transforms import Transformation, Translation from classy_blocks.cbtyping import NPVectorType, PointListType, PointType from classy_blocks.construct.point import Point from classy_blocks.construct.shapes.round import RoundSolidShape class NineCoreDisk(cb.MappedSketch): """A disk that has 3x3 inside quads and 12 outer; see docs/cylinder.svg for a sketch""" quads: ClassVar = [ # core [0, 1, 2, 3], # 0 # layer 1 [0, 15, 4, 5], # 1 [0, 5, 6, 1], # 2 [1, 6, 7, 8], # 3 [1, 8, 9, 2], # 4 [2, 9, 10, 11], # 5 [2, 11, 12, 3], # 6 [3, 12, 13, 14], # 7 [3, 14, 15, 0], # 8 # layer 2 [4, 16, 17, 5], # 9 [5, 17, 18, 6], # 10 [6, 18, 19, 7], # 11 [7, 19, 20, 8], # 12 [8, 20, 21, 9], # 13 [9, 21, 22, 10], # 14 [10, 22, 23, 11], # 15 [11, 23, 24, 12], # 16 [12, 24, 25, 13], # 17 [13, 25, 26, 14], # 18 [14, 26, 27, 15], # 19 [15, 27, 16, 4], # 20 ] def __init__(self, perimeter: PointListType, center_point: PointType): center_point = np.asarray(center_point) # inner points will be determined with smoothing; # a good enough starting estimate is the center # (anything in the same plane as other points) outer_points = np.asarray(perimeter) inner_points = np.ones((16, 3)) * np.average(outer_points, axis=0) positions = np.concatenate((inner_points, outer_points)) self.center_point = Point(center_point) super().__init__(positions, self.quads) # smooth the inner_points (which are all invalid) into position smoother = cb.SketchSmoother(self) smoother.smooth() @property def core(self) -> list[cb.Face]: return self.faces[:9] @property def shell(self) -> list[cb.Face]: return self.faces[9:] def add_edges(self): for face in self.shell: face.add_edge(1, cb.Origin(self.center_point.position)) @property def parts(self): return [*super().parts, self.center_point] @property def center(self): return self.center_point.position @property def grid(self): return [[self.faces[0]], self.faces[1:9], self.faces[9:]] @property def normal(self) -> NPVectorType: return self.faces[0].normal @property def n_segments(self): return 12 class DozenBlockCylinder(RoundSolidShape): sketch_class = NineCoreDisk def __init__(self, perimeter: PointListType, center_point: PointType, length: float): sketch = NineCoreDisk(perimeter, center_point) transforms: Sequence[Transformation] = [Translation(sketch.normal * length)] super().__init__(sketch, transforms) def chop_radial(self, **kwargs): self.shell[0].chop(0, **kwargs) class Core(Region): def __init__(self, points: PointListType): self.cylinder = DozenBlockCylinder(points, [0, 0, self.geo.z["upper"]], self.geo.l["lower"]) def chop(self): self.cylinder.chop_radial(end_size=params.BL_THICKNESS, c2c_expansion=1 / params.C2C_EXPANSION) self.cylinder.chop_tangential(start_size=params.BULK_SIZE * self.geo.r["outlet"] / self.geo.r["body"]) @property def elements(self): return self.cylinder.operations class Outlet(Region): def __init__(self, core_cylinder: DozenBlockCylinder): self.operations: list[cb.Operation] = [ cb.Extrude(op.bottom_face.copy().translate([0, 0, self.geo.l["outlet"]]), [0, 0, -self.geo.l["outlet"]]) for op in core_cylinder.operations ] def chop(self): self.elements[0].chop(2, start_size=params.BULK_SIZE) @property def elements(self): return self.operations def set_patches(self): for operation in self.elements: operation.set_patch("bottom", "outlet") ================================================ FILE: examples/complex/cyclone/regions/fillaround.py ================================================ from regions.region import Region import classy_blocks as cb from classy_blocks.util import functions as f class FillAround(Region): """A ring that leaves out lofts that interfere with loft_1 and loft_2, then connects them to form a complete 'ring'.""" def __init__(self, loft_1: cb.Loft, loft_2: cb.Loft): self.loft_1 = loft_1 self.loft_2 = loft_2 center = [0, 0, loft_1.center[2]] inner_face = loft_1.get_closest_face(center) outer_face = loft_1.get_closest_face(100 * loft_1.center) bottom_face = loft_1.get_closest_face([0, 0, -100]) top_face = loft_1.get_closest_face([0, 0, 100]) z_bottom = min([p[2] for p in bottom_face.point_array]) z_top = max([p[2] for p in top_face.point_array]) r_inner = min([f.to_polar(p)[0] for p in inner_face.point_array]) r_outer = max([f.to_polar(p)[0] for p in outer_face.point_array]) # if one takes 11 segments, then after omitting first and last 3, # 12 blocks are obtained together with skirt and other pieces ring = cb.ExtrudedRing([0, 0, z_bottom], [0, 0, z_top], [0, -r_outer, z_bottom], r_inner, n_segments=11) shell = ring.shell[1:-4] # connect first and last lofts connector_1 = cb.Connector(loft_1, shell[0]) connector_2 = cb.Connector(shell[-1], loft_2) self._operations = [connector_1, *ring.shell[1:-4], connector_2] @property def elements(self): return self._operations @property def connector_1(self): return self._operations[0] @property def connector_2(self): return self._operations[-1] def project(self): self.connector_1.project_side("left", "body", True, True) self.connector_2.project_side("right", "body", True, True) ================================================ FILE: examples/complex/cyclone/regions/inlet.py ================================================ import numpy as np from regions.region import Region import classy_blocks as cb from classy_blocks.construct.point import Point from classy_blocks.util import functions as f # Helps with optimization of this tricky part cb.HalfDisk.core_ratio = 0.6 class InletPipe(Region): line_clamps = (20, 18, 16, 13, 12) plane_clamps = (0, 1, 8, 9, 5, 4, 11, 10) free_clamps = (3, 2, 6, 7) def __init__(self): self.inlet = self.create_inlet() def snap_point(self, point: Point) -> None: """Moves points on inlet's end face along x-axis so that they touch cyclone body""" # a.k.a. conform to that cylinder; those vertices will remain fixed y_pos = point.position[1] angle = np.arccos(y_pos / self.geo.r["body"]) x_pos = -self.geo.r["body"] * np.sin(angle) point.translate([x_pos, 0, 0]) def scale_point(self, point: Point) -> None: """Move a point radially towards axis of cyclone by a fraction of body thickness at this spot; creates a cone from which shell will be built""" vector = f.unit_vector(point.position) vector[2] = 0 vector *= (self.geo.r["body"] - self.geo.r["pipe"]) * 0.3 point.translate(-vector) def create_inlet(self): inlet = cb.SemiCylinder( self.geo.inlet[1], self.geo.inlet[2], self.geo.inlet[1] - f.vector(0, self.geo.r["inlet"], 0) ) for op in inlet.operations: op.top_face.remove_edges() op.bottom_face.remove_edges() for point in op.top_face.points: # move points on inlet's end face so that they touch cyclone body, # a.k.a. conform to that cylinder; those vertices will remain fixed self.snap_point(point) for op in inlet.core: for point in op.top_face.points: self.scale_point(point) for op in inlet.shell: for i in (0, 3): self.scale_point(op.top_face.points[i]) return inlet @property def inner_lofts(self): """Lofts that will be used to create inner ring, that is, those that have tops facing z-axis""" return self.inlet.core @property def elements(self): return self.inlet.operations def project(self): for operation in self.inlet.shell: operation.project_side("right", "inlet", True, True) class InletExtension(Region): def __init__(self, inlet: InletPipe): self.extension = cb.SemiCylinder( self.geo.inlet[0], self.geo.inlet[1], self.geo.inlet[0] - f.vector(0, self.geo.r["inlet"], 0) ) for i, operation in enumerate(self.extension.operations): operation.top_face = inlet.elements[i].bottom_face @property def elements(self): return self.extension.operations def set_patches(self): self.extension.set_start_patch("inlet") ================================================ FILE: examples/complex/cyclone/regions/inner_ring.py ================================================ import numpy as np from regions.region import Region import classy_blocks as cb from classy_blocks.util import functions as f class InnerRing(Region): """A ring, created by extruding innermost faces of given shapes""" radial_clamps = (60, 61, 62, 63, 64, 65) def _move_to_radius(self, point): polar = f.to_polar(point, axis="z") polar[0] = self.geo.r["pipe"] return f.to_cartesian(polar, axis="z") def _move_to_angle(self, point, angle): polar = f.to_polar(point, axis="z") polar[1] = angle return f.to_cartesian(polar, axis="z") def _reorient_face(self, face: cb.Face) -> None: # find the point with lowest z and lowest angle and # reorient face so that it starts with it # ACHTUNG! Not the prettiest piece of code. # TODO: Prettify z_min = 1e12 angle_min = 1e12 i_point = 5 # a.k.a. invalid for i, point in enumerate(face.point_array): polar = f.to_polar(point, axis="z") polar[1] += 10 if polar[2] < z_min and polar[1] < angle_min: i_point = i angle_min = polar[1] z_min = polar[2] face.reorient(face.point_array[i_point]) location = face.center normal = face.normal if np.dot(location, normal) > 0: face.invert() def __init__(self, lofts: list[cb.Loft]): center_point = f.vector(0, 0, self.geo.z["skirt"]) outer_faces = [loft.get_closest_face(center_point) for loft in lofts] for face in outer_faces: self._reorient_face(face) inner_faces = [face.copy() for face in outer_faces] for face in inner_faces: for point in face.points: point.move_to(self._move_to_radius(point.position)) self.lofts = [cb.Loft(outer_faces[i], inner_faces[i]) for i in range(len(inner_faces))] @property def elements(self): return self.lofts def project(self): for element in self.elements: element.project_side("top", "pipe", True, True) ================================================ FILE: examples/complex/cyclone/regions/pipe.py ================================================ import numpy as np from regions.region import Region import classy_blocks as cb from classy_blocks.cbtyping import NPPointType from classy_blocks.util import functions as f class Pipe(Region): def __init__(self, outer: Region): self.outer = outer faces: list[cb.Face] = [] for operation in outer.elements: face = operation.get_closest_face(self.center) # filter faces that are from outer operations for point in face.point_array: if f.to_polar(point, axis="z")[0] > 1.01 * self.geo.r["pipe"]: break else: faces.append(face) for face in faces: if np.dot(face.normal, face.center - self.center) > 0: face.invert() self.shell = cb.Shell(faces, self.geo.l["pipe"]) # snap all points on top faces to outlet cylinder # and bottom faces to 'pipe' cylinder for operation in self.shell.operations: face = operation.top_face for point in face.points: polar = f.to_polar(point.position, axis="z") polar[0] = self.geo.r["outlet"] point.move_to(f.to_cartesian(polar, axis="z")) operation.project_side("bottom", "pipe", edges=True, points=True) @property def center(self) -> NPPointType: center_z = self.outer.elements[0].center[2] return f.vector(0, 0, center_z) @property def elements(self): return self.shell.operations def project(self): for operation in self.elements: operation.project_side("top", "outlet", True, True) ================================================ FILE: examples/complex/cyclone/regions/region.py ================================================ import abc from geometry import geometry import classy_blocks as cb from classy_blocks.construct.operations.operation import Operation from classy_blocks.util import functions as f class Region(abc.ABC): """A logical unit of the mesh that can be constructed independently""" line_clamps: tuple[int, ...] = () radial_clamps: tuple[int, ...] = () plane_clamps: tuple[int, ...] = () free_clamps: tuple[int, ...] = () geo = geometry @property @abc.abstractmethod def elements(self) -> list[Operation]: """Entities of any type to be added to the mesh""" def get_line_clamps(self, mesh): clamps: set[cb.ClampBase] = set() for index in self.line_clamps: vertex = mesh.vertices[index] delta_x = f.vector(self.geo.r["inlet"] / 2, 0, 0) clamp = cb.LineClamp(vertex.position, vertex.position, vertex.position + delta_x, (-100, 100)) clamps.add(clamp) return clamps def get_free_clamps(self, mesh: cb.Mesh) -> set[cb.ClampBase]: clamps: set[cb.ClampBase] = set() for index in self.free_clamps: vertex = mesh.vertices[index] clamps.add(cb.FreeClamp(vertex.position)) return clamps def get_plane_clamps(self, mesh: cb.Mesh) -> set[cb.ClampBase]: clamps: set[cb.ClampBase] = set() for index in self.plane_clamps: vertex = mesh.vertices[index] clamp = cb.PlaneClamp(vertex.position, vertex.position, [0, 0, 1]) clamps.add(clamp) return clamps def get_radial_clamps(self, mesh: cb.Mesh) -> set[cb.ClampBase]: clamps: set[cb.ClampBase] = set() for index in self.radial_clamps: vertex = mesh.vertices[index] clamp = cb.RadialClamp(vertex.position, [0, 0, 0], [0, 0, 1]) clamps.add(clamp) return clamps def get_clamps(self, mesh: cb.Mesh) -> set[cb.ClampBase]: """Returns a list of clamps to be used for mesh optimization""" clamps = self.get_line_clamps(mesh) clamps.update(self.get_radial_clamps(mesh)) clamps.update(self.get_plane_clamps(mesh)) clamps.update(self.get_free_clamps(mesh)) return clamps def project(self) -> None: # noqa: B027 """Projections to geometry, if needed""" def set_patches(self): # noqa: B027 """Set pathes, if appropriate""" ================================================ FILE: examples/complex/cyclone/regions/skirt.py ================================================ from regions.region import Region import classy_blocks as cb class Skirt(Region): """A region that connects inlet pipe's top faces to a ring on the outside of cyclone""" radial_clamps = (28, 23, 24, 30, 83, 81, 72, 66, 68, 69, 70, 84, 82, 80, 78, 76, 74, 71, 67) plane_clamps = (22, 25, 27, 29, 31, 34, 59, 51, 55, 47, 43, 39, 33, 37, 41, 45, 49, 53, 57) def __init__(self, inlet_shell: list[cb.Loft]): self.inlet_shell = inlet_shell # create 4 lofts, starting from inlet_shell's end faces, to a # plane, normal to z-axis z_coord = -self.geo.r["inlet"] - self.geo.l["skirt"] top_faces = [loft.top_face for loft in self.inlet_shell] bottom_faces = [face.copy() for face in top_faces] for face in bottom_faces: for point in face.points: point.position[2] = z_coord self.lofts = [cb.Loft(top_faces[i], bottom_faces[i]) for i in range(4)] @property def elements(self): return self.lofts def project(self): for operation in self.elements: operation.project_side("right", "body", True, True) ================================================ FILE: examples/complex/gear/gear.py ================================================ #!/usr/bin/env python import os import time import numpy as np import scipy.optimize from involute_gear import InvoluteGear from tooth import ToothSketch import classy_blocks as cb from classy_blocks.util import functions as f time_start = time.time() mesh = cb.Mesh() # parameters RADIUS_EXPANSION = 1.1 # create an interpolated curve that represents a gear tooth fillet = 0.1 # Must be more than zero! gear = InvoluteGear(fillet=fillet, arc_step_size=0.1, max_steps=1000, teeth=15) tng_points = gear.generate_tooth_and_gap() z_coords = np.zeros(len(tng_points[0])) tng_points = np.stack((tng_points[0], tng_points[1], z_coords)).T tng_points = np.flip(tng_points, axis=0) # add start and end points exactly on the 2pi/teeth start_point = f.to_polar(tng_points[0], axis="z") start_point[1] = np.pi / gear.teeth start_point = f.to_cartesian(start_point) end_point = f.to_polar(tng_points[-1], axis="z") end_point[1] = -np.pi / gear.teeth end_point = f.to_cartesian(end_point) tng_points = np.concatenate(([start_point], tng_points, [end_point])) tooth_curve = cb.LinearInterpolatedCurve(tng_points) gear_params = np.linspace(0, 1, num=8) # fix points 1 and 3: # 1 is on radius gear.root_radius + fillet/2 def frad(t, radius): return f.norm(tooth_curve.get_point(t)) - radius gear_params[1] = scipy.optimize.brentq(lambda t: frad(t, gear.root_radius + fillet / 2), 0, 0.25) # 3 is on radius gear.outer_radius - fillet/2 gear_params[3] = scipy.optimize.brentq(lambda t: frad(t, gear.outer_radius - fillet / 2), 0.25, 0.5) gear_params[6] = 1 - gear_params[1] gear_params[4] = 1 - gear_params[3] gear_params[2] = (gear_params[1] + gear_params[3]) / 2 gear_params[5] = (gear_params[4] + gear_params[6]) / 2 gear_points = np.array([tooth_curve.get_point(t) for t in gear_params]) outer_radius = f.norm(gear_points[3] * RADIUS_EXPANSION) p11_polar = f.to_polar(gear_points[-1], axis="z") p14_polar = f.to_polar(gear_points[0], axis="z") angles = np.linspace(p11_polar[1], p14_polar[1], num=4) tangential_points = np.array([f.to_cartesian([outer_radius, angle, 0]) for angle in angles]) radial_points_1 = np.linspace(gear_points[-1], tangential_points[0], axis=0, num=5)[1:-1] radial_points_2 = np.linspace(tangential_points[-1], gear_points[0], axis=0, num=5)[1:-1] outer_points = np.concatenate((gear_points, radial_points_1, tangential_points, radial_points_2)) inner_points = np.zeros((6, 3)) positions = np.concatenate((outer_points, inner_points)) # At this point, a smoother would reliably # produce almost the best blocking if this was a convex sketch. # Alas, this is a severely concave case so smoothing will produce # degenerate quads which even optimizers won't be able to fix. # It's best to manually position points, then optimize the sketch. def mirror(target, source): # once a position is obtained, the mirrored counterpart is also determined positions[target] = [positions[source][0], -positions[source][1], 0] # fix points 18 and 23 because optimization doesn't 'see' curved edges # and produces high non-orthogonality dir_0 = f.unit_vector(gear_points[0] - gear_points[1]) dir_2 = f.unit_vector(gear_points[2] - gear_points[1]) dir_18 = f.unit_vector(dir_0 + dir_2) positions[18] = gear_points[1] + dir_18 * f.norm(gear_points[0] - gear_points[1]) / 2 mirror(23, 18) positions[17] = positions[0] + f.unit_vector(positions[17] - positions[0]) * f.norm(positions[18] - positions[1]) mirror(8, 17) # other points are somewhere between... def midpoint(target, left, right): positions[target] = (positions[left] + positions[right]) / 2 midpoint(19, 2, 16) midpoint(20, 3, 13) # and their mirrored counterparts mirror(22, 19) mirror(21, 20) sketch = ToothSketch(positions, tooth_curve) # Optimize the sketch: optimizer = cb.SketchOptimizer(sketch) # point 2 is on gear curve optimizer.add_clamp(cb.CurveClamp(positions[2], tooth_curve)) # point 13 is movable radially optimizer.add_clamp(cb.RadialClamp(positions[13], [0, 0, 0], [0, 0, 1])) # 15-17 move along a line for i in (15, 16, 17): optimizer.add_clamp(cb.LineClamp(positions[i], gear_points[0], tangential_points[-1])) # freely movable points (on sketch plane) # TODO: easier clamp definition for sketch optimization for i in (19, 20): optimizer.add_clamp(cb.PlaneClamp(sketch.positions[i], sketch.positions[i], sketch.normal)) # Links! symmetry_pairs = [ (2, 5), (19, 22), (20, 21), (17, 8), (16, 9), (15, 10), ] for pair in symmetry_pairs: optimizer.add_link(cb.SymmetryLink(positions[pair[0]], positions[pair[1]], f.vector(0, 1, 0), f.vector(0, 0, 0))) optimizer.optimize() stack = cb.TransformedStack( sketch, [cb.Translation([0, 0, 4]), cb.Rotation(sketch.normal, 10 * np.pi / 180, [0, 0, 0])], 2, [cb.Translation([0, 0, 2]), cb.Rotation(sketch.normal, 5 * np.pi / 180, [0, 0, 0])], ) # TODO: this be mighty clumsy; unclumsify bulk_size = 0.1 wall_size = 0.01 stack.shapes[0].chop(0, start_size=bulk_size) stack.shapes[0].chop(1, start_size=wall_size, end_size=bulk_size / 2) stack.shapes[0].operations[10].chop(1, start_size=bulk_size) stack.chop(count=8) # patches for shape in stack.shapes: for i in range(7): shape.operations[i].set_patch("front", "gear") for i in (9, 10, 11): shape.operations[i].set_patch("front", "outer") for operation in stack.shapes[0].operations: operation.set_patch("bottom", "bottom") for operation in stack.shapes[-1].operations: operation.set_patch("top", "top") mesh.add(stack) for i, angle in enumerate(np.linspace(0, 2 * np.pi, num=gear.teeth, endpoint=False)[1:]): print(f"Adding tooth {i + 2}") mesh.add(stack.copy().rotate(angle, [0, 0, 1], [0, 0, 0])) print("Writing mesh...") mesh.write(os.path.join("..", "..", "case", "system", "blockMeshDict"), debug_path="debug.vtk") time_end = time.time() print(f"Elapsed time: {time_end - time_start}s") ================================================ FILE: examples/complex/gear/involute_gear.py ================================================ """A copy of py_gear_gen (https://github.com/heartworm/py_gear_gen), slightly modified to fit in classy_blocks for the gear pump example""" import numpy as np from classy_blocks.util import functions as f class DimensionError(Exception): pass def rotation_matrix(theta): return np.array([[np.cos(theta), -np.sin(theta)], [np.sin(theta), np.cos(theta)]]) def flip_matrix(h, v): return [[-1 if h else 1, 0], [0, -1 if v else 1]] def polar_to_cart(*coords): if len(coords) == 1: coords = coords[0] r, ang = coords return r * np.cos(ang), r * np.sin(ang) def cart_to_polar(*coords): if len(coords) == 1: coords = coords[0] x, y = coords return np.sqrt(x * x + y * y), np.arctan2(y, x) class InvoluteGear: def __init__( self, module=1, teeth=30, pressure_angle_deg=20.0, fillet=0.0, backlash=0.0, max_steps=100, arc_step_size=0.1, reduction_tolerance_deg=0.0, dedendum_factor=1.157, addendum_factor=1.0, ring=False, ): """ Construct an involute gear, ready for generation using one of the generation methods. :param module: The 'module' of the gear. (Diameter / Teeth) :param teeth: How many teeth in the desired gear. :param pressure_angle_deg: The pressure angle of the gear in DEGREES. :param fillet: The radius of the fillet connecting a tooth to the root circle. NOT WORKING in ring gear. :param backlash: The circumfrential play between teeth, if meshed with another gear of the same backlash held stationary :param max_steps: Maximum steps allowed to generate the involute profile. Higher is more accurate. :param arc_step_size: The step size used for generating arcs. :param ring: True if this is a ring (internal) gear, otherwise False. """ pressure_angle = f.deg2rad(pressure_angle_deg) self.reduction_tolerance = f.deg2rad(reduction_tolerance_deg) self.module = module self.teeth = teeth self.pressure_angle = pressure_angle # Addendum is the height above the pitch circle that the tooth extends to self.addendum = addendum_factor * module # Dedendum is the depth below the pitch circle the root extends to. 1.157 is a std value allowing for clearance. self.dedendum = dedendum_factor * module # If the gear is a ring gear, then the clearance needs to be on the other side if ring: temp = self.addendum self.addendum = self.dedendum self.dedendum = temp # The radius of the pitch circle self.pitch_radius = (module * teeth) / 2 # The radius of the base circle, used to generate the involute curve self.base_radius = np.cos(pressure_angle) * self.pitch_radius # The radius of the gear's extremities self.outer_radius = self.pitch_radius + self.addendum # The radius of the gaps between the teeth self.root_radius = self.pitch_radius - self.dedendum # The radius of the fillet circle connecting the tooth to the root circle self.fillet_radius = fillet if not ring else 0 # The angular width of a tooth and a gap. 360 degrees divided by the number of teeth self.theta_tooth_and_gap = np.pi * 2 / teeth # Converting the circumfrential backlash into an angle angular_backlash = backlash / 2 / self.pitch_radius # The angular width of the tooth at the pitch circle minus backlash, not taking the involute into account self.theta_tooth = self.theta_tooth_and_gap / 2 + (-angular_backlash if not ring else angular_backlash) # Where the involute profile intersects the pitch circle, found on iteration. self.theta_pitch_intersect = None # The angular width of the full tooth, at the root circle self.theta_full_tooth = 0 self.max_steps = max_steps self.arc_step_size = arc_step_size """ Reduces a line of many points to less points depending on the allowed angle tolerance """ def reduce_polyline(self, polyline): vertices = [[], []] last_vertex = [polyline[0][0], polyline[1][0]] # Look through all vertices except start and end vertex # Calculate by how much the lines before and after the vertex # deviate from a straight path. # If the deviation angle exceeds the specification, store it for vertex_idx in range(1, len(polyline[0]) - 1): next_slope = np.arctan2( polyline[1][vertex_idx + 1] - polyline[1][vertex_idx + 0], polyline[0][vertex_idx + 1] - polyline[0][vertex_idx + 0], ) prev_slope = np.arctan2( polyline[1][vertex_idx - 0] - last_vertex[1], polyline[0][vertex_idx - 0] - last_vertex[0] ) deviation_angle = abs(prev_slope - next_slope) if deviation_angle > self.reduction_tolerance: vertices[0] += [polyline[0][vertex_idx]] vertices[1] += [polyline[1][vertex_idx]] last_vertex = [polyline[0][vertex_idx], polyline[1][vertex_idx]] # Return vertices along with first and last point of the original polyline return np.array( [ np.concatenate([[polyline[0][0]], vertices[0], [polyline[0][-1]]]), np.concatenate([[polyline[1][0]], vertices[1], [polyline[1][-1]]]), ] ) def generate_half_tooth(self): """ Generate half an involute profile, ready to be mirrored in order to create one symmetrical involute tooth :return: A numpy array, of the format [[x1, x2, ... , xn], [y1, y2, ... , yn]] """ # Theta is the angle around the circle, however PHI is simply a parameter for iteratively building the involute phis = np.linspace(0, np.pi, self.max_steps) points = [] reached_limit = False self.theta_pitch_intersect = None for phi in phis: x = (self.base_radius * np.cos(phi)) + (phi * self.base_radius * np.sin(phi)) y = (self.base_radius * np.sin(phi)) - (phi * self.base_radius * np.cos(phi)) point = (x, y) dist, theta = cart_to_polar(point) if self.theta_pitch_intersect is None and dist >= self.pitch_radius: self.theta_pitch_intersect = theta self.theta_full_tooth = self.theta_pitch_intersect * 2 + self.theta_tooth elif self.theta_pitch_intersect is not None and theta >= self.theta_full_tooth / 2: reached_limit = True break if dist >= self.outer_radius: points.append(polar_to_cart((self.outer_radius, theta))) elif dist <= self.root_radius: points.append(polar_to_cart((self.root_radius, theta))) else: points.append((x, y)) if not reached_limit: raise Exception("Couldn't complete tooth profile.") return np.transpose(points) def generate_half_root(self): """ Generate half of the gap between teeth, for the first tooth :return: A numpy array, of the format [[x1, x2, ... , xn], [y1, y2, ... , yn]] """ root_arc_length = (self.theta_tooth_and_gap - self.theta_full_tooth) * self.root_radius points_root = [] for theta in np.arange( self.theta_full_tooth, self.theta_tooth_and_gap / 2 + self.theta_full_tooth / 2, self.arc_step_size / self.root_radius, ): # The current circumfrential position we are in the root arc, starting from 0 arc_position = (theta - self.theta_full_tooth) * self.root_radius # If we are in the extemities of the root arc (defined by fillet_radius), then we are in a fillet in_fillet = min((root_arc_length - arc_position), arc_position) < self.fillet_radius r = self.root_radius if in_fillet: # Add a circular profile onto the normal root radius to form the fillet. # High near the edges, small towards the centre # The min() function handles the situation where the fillet size is massive and overlaps itself circle_pos = min(arc_position, (root_arc_length - arc_position)) r = r + ( self.fillet_radius - np.sqrt(pow(self.fillet_radius, 2) - pow(self.fillet_radius - circle_pos, 2)) ) points_root.append(polar_to_cart((r, theta))) return np.transpose(points_root) def generate_roots(self): """ Generate both roots on either side of the first tooth :return: A numpy array, of the format [ [[x01, x02, ... , x0n], [y01, y02, ... , y0n]], [[x11, x12, ... , x1n], [y11, y12, ... , y1n]] ] """ self.half_root = self.generate_half_root() self.half_root = np.dot(rotation_matrix(-self.theta_full_tooth / 2), self.half_root) points_second_half = np.dot(flip_matrix(False, True), self.half_root) points_second_half = np.flip(points_second_half, 1) self.roots = [points_second_half, self.half_root] # Generate a second set of point-reduced root self.half_root_reduced = self.reduce_polyline(self.half_root) points_second_half = np.dot(flip_matrix(False, True), self.half_root_reduced) points_second_half = np.flip(points_second_half, 1) self.roots_reduced = [points_second_half, self.half_root_reduced] return self.roots_reduced def generate_tooth(self): """ Generate only one involute tooth, without an accompanying tooth gap :return: A numpy array, of the format [[x1, x2, ... , xn], [y1, y2, ... , yn]] """ self.half_tooth = self.generate_half_tooth() self.half_tooth = np.dot(rotation_matrix(-self.theta_full_tooth / 2), self.half_tooth) points_second_half = np.dot(flip_matrix(False, True), self.half_tooth) points_second_half = np.flip(points_second_half, 1) self.tooth = np.concatenate((self.half_tooth, points_second_half), axis=1) # Generate a second set of point-reduced teeth self.half_tooth_reduced = self.reduce_polyline(self.half_tooth) points_second_half = np.dot(flip_matrix(False, True), self.half_tooth_reduced) points_second_half = np.flip(points_second_half, 1) self.tooth_reduced = np.concatenate((self.half_tooth_reduced, points_second_half), axis=1) return self.tooth_reduced def generate_tooth_and_gap(self): """ Generate only one tooth and one root profile, ready to be duplicated by rotating around the gear center :return: A numpy array, of the format [[x1, x2, ... , xn], [y1, y2, ... , yn]] """ points_tooth = self.generate_tooth() points_roots = self.generate_roots() self.tooth_and_gap = np.concatenate((points_roots[0], points_tooth, points_roots[1]), axis=1) return self.tooth_and_gap def generate_gear(self): """ Generate the gear profile, and return a sequence of co-ordinates representing the outline of the gear :return: A numpy array, of the format [[x1, x2, ... , xn], [y1, y2, ... , yn]] """ points_tooth_and_gap = self.generate_tooth_and_gap() points_teeth = [ np.dot(rotation_matrix(self.theta_tooth_and_gap * n), points_tooth_and_gap) for n in range(self.teeth) ] points_gear = np.concatenate(points_teeth, axis=1) return points_gear def get_point_list(self): """ Generate the gear profile, and return a sequence of co-ordinates representing the outline of the gear :return: A numpy array, of the format [[x1, y2], [x2, y2], ... , [xn, yn]] """ gear = self.generate_gear() return np.transpose(gear) ================================================ FILE: examples/complex/gear/tooth.py ================================================ from typing import ClassVar import classy_blocks as cb class ToothSketch(cb.MappedSketch): quads: ClassVar = [ # layers on tooth wall [0, 1, 18, 17], # 0 [1, 2, 19, 18], # 1 [2, 3, 20, 19], # 2 [3, 4, 21, 20], # 3 [4, 5, 22, 21], # 4 [5, 6, 23, 22], # 5 [6, 7, 8, 23], # 6 # surrounding blocks [8, 9, 22, 23], # 7 [9, 10, 21, 22], # 8 [11, 12, 21, 10], # 9 [12, 13, 20, 21], # 10 [13, 14, 15, 20], # 11 [15, 16, 19, 20], # 12 [16, 17, 18, 19], # 13 ] # In x-direction, only one half needs to be chopped, the other half will be # copied from teeth on the other side. # In y-direction, block 10 should also be in the list # except that it doesn't need boundary layers that will be created for blocks on the tooth. # It will be chopped manually. chops: ClassVar = [ [1, 2, 9, 10, 11], [0], ] def __init__(self, positions, curve: cb.LinearInterpolatedCurve): self.curve = curve super().__init__(positions, self.quads) def add_edges(self) -> None: for i in range(7): # If all edges refer to the same curve, it will be # transformed N-times, N being the number of entities # where that curve is used. Therefore copy it # so that each edge will have its own object to work with. self.faces[i].add_edge(0, cb.OnCurve(self.curve.copy())) for i in (9, 10, 11): self.faces[i].add_edge(0, cb.Origin([0, 0, 0])) ================================================ FILE: examples/complex/heater/heater.py ================================================ import os from typing import List import numpy as np import parameters as p import classy_blocks as cb from classy_blocks.construct.flat.sketch import Sketch mesh = cb.Mesh() # some shortcuts xlv = p.xlv ylv = p.ylv zlv = p.zlv def set_cell_zones(shape: cb.Shape): solid_indexes = list(range(5)) fluid_indexes = list(range(5, 9)) for i in solid_indexes: shape.operations[i].set_cell_zone("solid") for i in fluid_indexes: shape.operations[i].set_cell_zone("fluid") # Cross-section of heater and fluid around it cb.WrappedDisk.chops[0] = [1] # the solid part, chop the fluid zone manually heater_xs = cb.WrappedDisk(p.heater_start_point, p.wrapping_corner_point, p.heater_diameter / 2, [1, 0, 0]) # The straight part of the heater, part 1: bottom straight_1 = cb.ExtrudedShape(heater_xs, p.heater_length) straight_1.chop(0, start_size=p.solid_cell_size) straight_1.chop(1, start_size=p.solid_cell_size) straight_1.chop(2, start_size=p.solid_cell_size) straight_1.operations[5].chop(0, start_size=p.first_cell_size, c2c_expansion=p.c2c_expansion) set_cell_zones(straight_1) mesh.add(straight_1) # The curved part of heater (and fluid around it); constructed from 4 revolves heater_arch = cb.RevolvedStack(straight_1.sketch_2, np.pi, [0, 0, 1], [0, 0, 0], 4) heater_arch.chop(start_size=p.solid_cell_size, take="min") for shape in heater_arch.shapes: set_cell_zones(shape) mesh.add(heater_arch) # The straight part of heater, part 2: after the arch straight_2 = cb.ExtrudedShape(heater_arch.shapes[-1].sketch_2, p.heater_length) set_cell_zones(straight_2) mesh.add(straight_2) # The arch creates a semi-cylindrical void; fill it with a semi-cylinder, of course arch_fill = cb.SemiCylinder([0, 0, zlv[0]], [0, 0, zlv[1]], [xlv[1], ylv[4], zlv[0]]) mesh.add(arch_fill) arch_fill.chop_radial(start_size=p.solid_cell_size) # A custom sketch that takes the closest faces from given operations; # They will definitely be wrongly oriented but we'll sort that out later class NearestSketch(Sketch): def __init__(self, operations: List[cb.Operation], far_point): far_point = np.array(far_point) self._faces = [op.get_closest_face(far_point) for op in operations] @property def faces(self): return self._faces @property def grid(self): return [self.faces] @property def center(self): return np.average([face.center for face in self.faces], axis=0) cylinder_xs = NearestSketch([arch_fill.operations[i] for i in (0, 1, 2, 5)], [-2 * p.heater_length, 0, 0]) pipe_fill = cb.ExtrudedShape(cylinder_xs, [-p.heater_length, 0, 0]) # reorient the operations in the shape reorienter = cb.ViewpointReorienter([-2 * p.heater_length, 0, 0], [0, p.heater_length, 0]) for operation in pipe_fill.operations: reorienter.reorient(operation) mesh.add(pipe_fill) # Domain: offset outermost faces of outermost operations offset_shapes = [straight_1, *heater_arch.shapes, straight_2] offset_ops = [shape.grid[2][2] for shape in offset_shapes] offset_faces = [op.get_face("right") for op in offset_ops] domain_shell = cb.Shell(offset_faces, ylv[1] - ylv[0]) domain_shell.operations[1].chop(2, start_size=2 * p.fluid_cell_size, end_size=10 * p.fluid_cell_size) mesh.add(domain_shell) # The offset faces create a domain that is not rectangular. # Assemble the mesh and move vertices, then backport the changes so that # they will be reflected in the offset shapes. mesh.assemble() # Vertex indexes are taken from debug.vtk file written right here. # This is the quickest and simplest method and works as long as blocking # doesn't change. If there was a parameter that controlled number of blocks # (for instance, number of levels in the arch stack), then # we'd have to obtain vertices programatically using GeometricFinder or similar. for index in (106, 107): mesh.vertices[index].position[0] = xlv[7] mesh.vertices[index].position[1] = ylv[0] for index in (110, 111): mesh.vertices[index].position[0] = xlv[7] mesh.vertices[index].position[1] = -ylv[0] mesh.backport() mesh.clear() # Add blocks to fluid zone (those that haven't been added yet) for operation in [*arch_fill.operations, *pipe_fill.operations, *domain_shell.operations]: operation.set_cell_zone("fluid") mesh.write(os.path.join("..", "..", "case", "system", "blockMeshDict"), debug_path="debug.vtk") ================================================ FILE: examples/complex/heater/parameters.py ================================================ from classy_blocks.util import functions as f # geometry heater_diameter = 10 heater_length = 50 bend_radius = 3 * heater_diameter domain_size = 50 # cell sizing solid_cell_size = 1 fluid_cell_size = 0.5 first_cell_size = 0.05 c2c_expansion = 1.2 # mesh parameters (do not edit) wrapping_height = (bend_radius + heater_diameter / 2) / 2 # coordinates, decomposed into grid, a.k.a. 'levels'; # see heater.svg for explanation xlv = [ -heater_length, 0, bend_radius - wrapping_height / 2, bend_radius - heater_diameter / 2, bend_radius, bend_radius + heater_diameter / 2, bend_radius + wrapping_height / 2, domain_size, ] ylv = [ -domain_size, -bend_radius - wrapping_height / 2, -bend_radius - heater_diameter / 2, -bend_radius, -bend_radius + wrapping_height / 2, ] zlv = [-wrapping_height / 2, wrapping_height / 2] heater_start_point = f.vector(xlv[0], ylv[3], 0) wrapping_corner_point = heater_start_point + f.vector(0, wrapping_height / 2, -wrapping_height / 2) ================================================ FILE: examples/complex/karman.py ================================================ import os import classy_blocks as cb cylinder_diameter = 20e-3 # [m] ring_thickness = 5e-3 # [m] # domain size domain_height = 0.05 # [m] (increase for "proper" simulation) upstream_length = 0.03 # [m] downstream_length = 0.05 # [m] # size to roughly match cells outside ring cell_size = 0.3 * ring_thickness bl_thickness = 1e-4 c2c_expansion = 1.2 # cell-to-cell expansion ratio # it's a 2-dimensional case z = 0.01 mesh = cb.Mesh() # a layer of cells on the cylinder d = 2**0.5 / 2 outer_point = d * (cylinder_diameter / 2 + ring_thickness) wall_ring = cb.ExtrudedRing( [0, 0, 0], [0, 0, z], [outer_point, outer_point, 0], cylinder_diameter / 2, n_segments=4, # the default is 8 but here it makes no sense to have more than 4 ) wall_ring.chop_axial(count=1) wall_ring.chop_tangential(start_size=cell_size) wall_ring.chop_radial(start_size=bl_thickness, c2c_expansion=c2c_expansion) wall_ring.set_inner_patch("cylinder") mesh.add(wall_ring) # boxes that fill up the whole domain def make_box(p1, p2, size_axes, patches): box = cb.Box([p1[0], p1[1], 0], [p2[0], p2[1], z]) for axis in size_axes: box.chop(axis, start_size=cell_size) for side, name in patches.items(): box.set_patch(side, name) mesh.add(box) # top 3 boxes make_box( [-upstream_length, outer_point], [-outer_point, domain_height / 2], [0, 1], {"back": "upper_wall", "left": "inlet"} ) make_box([-outer_point, outer_point], [outer_point, domain_height / 2], [], {"back": "upper_wall"}) make_box( [outer_point, outer_point], [downstream_length, domain_height / 2], [0, 1], {"back": "upper_wall", "right": "outlet"}, ) # left and right of the cylinder make_box([-upstream_length, -outer_point], [-outer_point, outer_point], [], {"left": "inlet"}) make_box([outer_point, -outer_point], [downstream_length, outer_point], [], {"right": "outlet"}) # bottom 3 boxes make_box( [-upstream_length, -domain_height / 2], [-outer_point, -outer_point], [0, 1], {"front": "lower_wall", "left": "inlet"}, ) make_box([-outer_point, -domain_height / 2], [outer_point, -outer_point], [], {"front": "lower_wall"}) make_box( [outer_point, -domain_height / 2], [downstream_length, -outer_point], [0, 1], {"front": "lower_wall", "right": "outlet"}, ) mesh.write(os.path.join("..", "case", "system", "blockMeshDict"), debug_path="debug.vtk") ================================================ FILE: examples/complex/plate/parameters.py ================================================ # plate properties pl_thickness = 0.3 pl_length = 5 # domain dm_upstream = 5 dm_height = 5 z = 1 # cell sizing cell_size = 0.2 first_cell_thickness = 0.01 c2c_expansion = 1.1 bl_thickness = 1.5 # cumulative thickness of inflation layers max_ar = 10 ================================================ FILE: examples/complex/plate/plate.py ================================================ #!/usr/bin/env python import os import parameters as ps import classy_blocks as cb from classy_blocks.util import functions as f # Example: flow over a flat plate of points = [ [-ps.pl_thickness, 0, 0], # 0 [0, 0, 0], # 1 (set later) [0, ps.pl_thickness, 0], # 2 [ps.pl_length, ps.pl_thickness, 0], # 3 [ps.pl_length, 0, 0], # 4 [-ps.bl_thickness - ps.pl_thickness, 0, 0], # 5 [0, 0, 0], # 6 (set later) [0, ps.pl_thickness + ps.bl_thickness, 0], # 7 [ps.pl_length, ps.pl_thickness + ps.bl_thickness, 0], # 8 [-ps.dm_upstream, 0, 0], # 9 [0, 0, 0], # 10 (later) [-ps.dm_upstream, ps.dm_height, 0], # 11 [0, 0, 0], # 12 (l8r) [0, ps.dm_height, 0], # 13 [ps.pl_length, ps.dm_height, 0], # 14 ] points[1] = f.rotate(points[0], -f.deg2rad(45), [0, 0, 1], [0, 0, 0]) points[6] = f.rotate(points[5], -f.deg2rad(45), [0, 0, 1], [0, 0, 0]) points[10] = [points[9][0], points[6][1], 0] points[12] = [points[6][0], points[11][1], 0] mesh = cb.Mesh() def make_extrude(indexes): face = cb.Face([points[i] for i in indexes]) op = cb.Extrude(face, ps.z) op.chop(2, count=1) mesh.add(op) return op extrudes = [ make_extrude([0, 1, 6, 5]), make_extrude([1, 2, 7, 6]), make_extrude([2, 3, 8, 7]), make_extrude([9, 5, 6, 10]), make_extrude([10, 6, 12, 11]), make_extrude([6, 7, 13, 12]), make_extrude([7, 8, 14, 13]), ] for index in (0, 1): for corner in (0, 2): extrudes[index].bottom_face.add_edge(corner, cb.Origin([0, 0, 0])) extrudes[index].top_face.add_edge(corner, cb.Origin([0, 0, ps.z])) for index in (0, 1): extrudes[index].chop(0, start_size=ps.cell_size) extrudes[2].chop(1, start_size=ps.first_cell_thickness, c2c_expansion=ps.c2c_expansion) extrudes[2].chop(0, start_size=ps.cell_size, end_size=ps.cell_size * ps.max_ar) extrudes[3].chop(0, start_size=ps.cell_size) extrudes[5].chop(1, start_size=ps.cell_size) mesh.write(os.path.join("..", "..", "case", "system", "blockMeshDict"), debug_path="debug.vtk") ================================================ FILE: examples/modification/move_vertex.py ================================================ import os import classy_blocks as cb mesh = cb.Mesh() cylinder = cb.Cylinder([0, 0, 0], [1, 0, 0], [0, 1, 0]) cylinder.chop_axial(count=10) cylinder.chop_radial(count=5) cylinder.chop_tangential(count=8) mesh.add(cylinder) mesh.assemble() finder = cb.GeometricFinder(mesh) vertex = next(iter(finder.find_in_sphere([1, 0, 0]))) vertex.translate([0.4, 0, 0]) mesh.set_default_patch("walls", "wall") mesh.write(os.path.join("..", "case", "system", "blockMeshDict"), debug_path="debug.vtk") ================================================ FILE: examples/operation/box.py ================================================ import os import classy_blocks as cb box = cb.Box([-1, -2, -4], [4, 2, 1]) # direction of corners 0-1 box.chop(0, length_ratio=0.5, start_size=0.02, c2c_expansion=1.2) box.chop(0, length_ratio=0.5, end_size=0.02, c2c_expansion=1 / 1.2) # direction of corners 1-2 box.chop(1, length_ratio=0.5, start_size=0.02, c2c_expansion=1.2) box.chop(1, length_ratio=0.5, end_size=0.02, c2c_expansion=1 / 1.2) # extrude direction box.chop(2, c2c_expansion=1, count=20) mesh = cb.Mesh() mesh.add(box) mesh.set_default_patch("walls", "wall") mesh.write(os.path.join("..", "case", "system", "blockMeshDict")) ================================================ FILE: examples/operation/channel.py ================================================ #!/usr/bin/env python import os import numpy as np import classy_blocks as cb # A channel with a 90-degred bend, square cross-section, low-Re # Channel sizing SCALE = 0.01 WIDTH = 8 HEIGHT = 6 LENGTH_1 = 30 INNER_RADIUS = 5 LENGTH_2 = 15 # Cell sizing CELL_SIZE = 1 BL_THICKNESS = 0.1 C2C_EXPANSION = 1.2 ### Construction mesh = cb.Mesh() left_block = cb.Box([0, 0, 0], [LENGTH_1, HEIGHT, WIDTH]) left_block.chop(0, start_size=CELL_SIZE) left_block.chop(1, length_ratio=0.5, start_size=BL_THICKNESS, c2c_expansion=C2C_EXPANSION) left_block.chop(1, length_ratio=0.5, end_size=BL_THICKNESS, c2c_expansion=1 / C2C_EXPANSION) left_block.chop(2, length_ratio=0.5, start_size=BL_THICKNESS, c2c_expansion=C2C_EXPANSION) left_block.chop(2, length_ratio=0.5, end_size=BL_THICKNESS, c2c_expansion=1 / C2C_EXPANSION) left_block.set_patch("left", "inlet") mesh.add(left_block) revolve_face = left_block.get_face("right") elbow = cb.Revolve(revolve_face, np.pi / 2, [0, -1, 0], [LENGTH_1, 0, HEIGHT + INNER_RADIUS]) elbow.chop(2, start_size=CELL_SIZE) mesh.add(elbow) top_face = elbow.get_face("top") right_block = cb.Extrude(top_face, LENGTH_2) right_block.chop(2, start_size=CELL_SIZE) right_block.set_patch("top", "outlet") mesh.add(right_block) mesh.set_default_patch("walls", "wall") mesh.settings.scale = SCALE mesh.write(os.path.join("..", "case", "system", "blockMeshDict")) ================================================ FILE: examples/operation/connector.py ================================================ import os import numpy as np import classy_blocks as cb box_1 = cb.Box([-1, -1, -1], [1, 1, 1]) box_2 = box_1.copy().rotate(np.pi / 4, [1, 1, 1], [0, 0, 0]).translate([4, 2, 0]) for i in range(3): box_1.chop(i, count=10) box_2.chop(i, count=10) connector = cb.Connector(box_1, box_2) connector.chop(2, count=10) mesh = cb.Mesh() mesh.add(box_1) mesh.add(box_2) mesh.add(connector) mesh.set_default_patch("walls", "wall") mesh.write(os.path.join("..", "case", "system", "blockMeshDict"), debug_path="debug.vtk") ================================================ FILE: examples/operation/extrude.py ================================================ import os import classy_blocks as cb base = cb.Face([[0, 0, 0], [1, 0, 0], [1, 1, 0], [0, 1, 0]], [cb.Arc([0.5, -0.2, 0]), None, None, None]) extrude = cb.Extrude(base, [0.5, 0.5, 3]) # direction of corners 0-1 extrude.chop(0, length_ratio=0.5, start_size=0.02, c2c_expansion=1.2) extrude.chop(0, length_ratio=0.5, end_size=0.02, c2c_expansion=1 / 1.2) # direction of corners 1-2 extrude.chop(1, length_ratio=0.5, start_size=0.02, c2c_expansion=1.2) extrude.chop(1, length_ratio=0.5, end_size=0.02, c2c_expansion=1 / 1.2) # extrude direction extrude.chop(2, c2c_expansion=1, count=20) extrude.set_patch("bottom", "wall") extrude.set_patch("top", "wall") mesh = cb.Mesh() mesh.add(extrude) mesh.set_default_patch("air", "patch") mesh.modify_patch("wall", "wall") mesh.write(os.path.join("..", "case", "system", "blockMeshDict")) ================================================ FILE: examples/operation/loft.py ================================================ import os import classy_blocks as cb # Example geometry using Loft: bottom_face = cb.Face( # 4 points for face corners [[0, 0, 0], [1, 0, 0], [1, 1, 0], [0, 1, 0]], # edges: arc between 0-1, line between 1-2, arc between 2-3, line between 3-0 [cb.Arc([0.5, -0.2, 0]), None, cb.Arc([0.5, 1.2, 0]), None], ) top_face = cb.Face( [[0, 0, 2], [1, 0, 2], [1, 1, 2], [0, 1, 2]], [None, cb.Arc([1.2, 0.5, 2]), None, cb.Arc([-0.2, 0.5, 2])] ) loft = cb.Loft(bottom_face, top_face) loft.add_side_edge(0, cb.PolyLine([[0.1, 0.1, 0.5], [0.15, 0.15, 1.0], [0.1, 0.1, 1.5]])) # corners 0 - 4 loft.add_side_edge(1, cb.Arc([0.9, 0.1, 1])) # 1 - 5 loft.add_side_edge(2, cb.Arc([0.9, 0.9, 1])) # 2 - 6 loft.add_side_edge(3, cb.Arc([0.1, 0.9, 1])) # 3 - 7 loft.chop(0, start_size=0.05, c2c_expansion=1.2) loft.chop(1, count=20) loft.chop(2, count=30) mesh = cb.Mesh() mesh.add(loft) mesh.set_default_patch("walls", "wall") mesh.write(os.path.join("..", "case", "system", "blockMeshDict")) ================================================ FILE: examples/operation/revolve.py ================================================ import os import classy_blocks as cb from classy_blocks.util import functions as f base = cb.Face([[0, 0, 0], [1, 0, 0], [1, 1, 0], [0, 1, 0]], [cb.Arc([0.5, -0.2, 0]), None, None, None]) revolve = cb.Revolve(base, f.deg2rad(60), [0, -1, 0], [-2, 0, 0]) # a shortcut for setting count only revolve.chop(0, count=10) revolve.chop(1, count=10) revolve.chop(2, count=30) mesh = cb.Mesh() mesh.add(revolve) mesh.set_default_patch("walls", "wall") mesh.write(os.path.join("..", "case", "system", "blockMeshDict")) ================================================ FILE: examples/operation/wedge.py ================================================ import os import classy_blocks as cb mesh = cb.Mesh() # a face with a single bump; base = cb.Face( # points [[0, 0, 0], [1, 0, 0], [1, 0.2, 0], [0, 0.2, 0]], # edges [None, None, cb.Spline([[0.75, 0.15, 0], [0.50, 0.20, 0], [0.25, 0.25, 0]]), None], ) # create a wedge, then copy it along x-axis, # representing an annular seal with grooves wedge = cb.Wedge(base) wedge.set_outer_patch("static_wall") wedge.set_inner_patch("rotating_walls") wedge.chop(0, count=30) wedges = [wedge.copy().translate([x, 0, 0]) for x in range(1, 6)] wedges[0].set_patch("left", "inlet") wedges[-1].set_patch("right", "outlet") # this will be copied to all next blocks wedges[0].chop(1, end_size=0.01, c2c_expansion=1 / 1.2) # Once an entity is added to the mesh, # its modifications will not be reflected there; # adding is the last thing to do for op in wedges: mesh.add(op) # change patch types and whatnot mesh.modify_patch("static_wall", "wall") mesh.modify_patch("rotating_walls", "wall") mesh.modify_patch("wedge_front", "wedge") mesh.modify_patch("wedge_back", "wedge") mesh.write(os.path.join("..", "case", "system", "blockMeshDict")) ================================================ FILE: examples/optimization/diffuser_free.py ================================================ import os import classy_blocks as cb mesh = cb.Mesh() size = 0.1 # Create a rapidly expanding diffuser that will cause high non-orthogonality # at the beginning of the contraction; then, move inner vertices so that # this problem is avoided small_pipe = cb.Cylinder([0, 0, 0], [2, 0, 0], [0, 1, 0]) small_pipe.chop_axial(start_size=size) small_pipe.chop_radial(start_size=size) small_pipe.chop_tangential(start_size=size) mesh.add(small_pipe) diffuser = cb.Frustum.chain(small_pipe, 0.5, 2) diffuser.chop_axial(start_size=size) mesh.add(diffuser) big_pipe = cb.Cylinder.chain(diffuser, 5) big_pipe.chop_axial(start_size=size) mesh.add(big_pipe) mesh.set_default_patch("walls", "wall") # Internal edges in Cylinders are Splines by default; # In this case their endpoints will be moved ('optimized') but not # the points in between; this will create bad cells. Either re-define these edges after optimization # or remove them altogether. diffuser.remove_inner_edges() small_pipe.remove_inner_edges() big_pipe.remove_inner_edges() # Assemble the mesh before making changes mesh.assemble() # Find inside vertices (start and stop surfaces of cylinders and frustum); finder = cb.RoundSolidFinder(mesh, diffuser) inner_vertices = finder.find_core(True) inner_vertices.update(finder.find_core(False)) # Release those vertices so that optimization can find a better position for them optimizer = cb.MeshOptimizer(mesh) for vertex in inner_vertices: clamp = cb.FreeClamp(vertex.position) optimizer.add_clamp(clamp) optimizer.optimize() mesh.write(os.path.join("..", "case", "system", "blockMeshDict"), debug_path="debug.vtk") ================================================ FILE: examples/optimization/diffuser_line.py ================================================ import os import classy_blocks as cb from classy_blocks.util import functions as f mesh = cb.Mesh() size = 0.1 # The setup is the same as the diffuser_free example # except that here vertices are only allowed to move # axially in a straight line. # See diffuser_free for comments. small_pipe = cb.Cylinder([0, 0, 0], [2, 0, 0], [0, 1, 0]) small_pipe.chop_axial(start_size=size) small_pipe.chop_radial(start_size=size) small_pipe.chop_tangential(start_size=size) mesh.add(small_pipe) diffuser = cb.Frustum.chain(small_pipe, 0.5, 2) diffuser.chop_axial(start_size=size) mesh.add(diffuser) big_pipe = cb.Cylinder.chain(diffuser, 5) big_pipe.chop_axial(start_size=size) mesh.add(big_pipe) mesh.set_default_patch("walls", "wall") diffuser.remove_inner_edges() small_pipe.remove_inner_edges() big_pipe.remove_inner_edges() mesh.assemble() finder = cb.RoundSolidFinder(mesh, diffuser) inner_vertices = finder.find_core(True) inner_vertices.update(finder.find_core(False)) optimizer = cb.MeshOptimizer(mesh) for vertex in inner_vertices: clamp = cb.LineClamp(vertex.position, vertex.position, vertex.position + f.vector(1, 0, 0)) optimizer.add_clamp(clamp) optimizer.optimize() mesh.write(os.path.join("..", "case", "system", "blockMeshDict"), debug_path="debug.vtk") ================================================ FILE: examples/optimization/duct.py ================================================ # An example where a Shape is optimized *before* it is added to mesh, using ShapeOptimizer import os import classy_blocks as cb from classy_blocks.base.exceptions import ClampExistsError mesh = cb.Mesh() start_sketch = cb.SplineDisk([0, 0, 0], [2, 0, 0], [0, 1, 0], 0, 0) end_sketch = cb.SplineDisk([0, 0, 0], [1, 0, 0], [0, 2, 0], 0, 0).translate([0, 0, 1]) shape = cb.LoftedShape(start_sketch, end_sketch) optimizer = cb.ShapeOptimizer(shape.operations) for operation in shape.operations[:4]: # remove edges because inner splines will ruin the day # TODO: make edges move with points too operation.top_face.remove_edges() operation.bottom_face.remove_edges() for point in operation.point_array: try: optimizer.add_clamp(cb.FreeClamp(point)) except ClampExistsError: pass optimizer.optimize(tolerance=0.01) mesh.add(shape) grader = cb.SimpleGrader(mesh, 0.05) grader.grade() mesh.set_default_patch("walls", "wall") mesh.write(os.path.join("..", "case", "system", "blockMeshDict"), debug_path="debug.vtk") ================================================ FILE: examples/optimization/simple.py ================================================ import os import classy_blocks as cb from classy_blocks.optimize.clamps.free import FreeClamp from classy_blocks.optimize.optimizer import MeshOptimizer mesh = cb.Mesh() # generate a cube, consisting of 2x2x2 smaller cubes for x in (-1, 0): for y in (-1, 0): for z in (-1, 0): box = cb.Box([x, y, z], [x + 1, y + 1, z + 1]) for axis in range(3): box.chop(axis, count=10) mesh.add(box) mesh.set_default_patch("walls", "wall") mesh.assemble() # move the middle vertex to a sub-optimal position finder = cb.GeometricFinder(mesh) mid_vertex = next(iter(finder.find_in_sphere([0, 0, 0]))) mid_vertex.translate([0.6, 0.6, 0.6]) # find a better spot for the above point using automatic optimization optimizer = MeshOptimizer(mesh) # define which vertices can move during optimization, and in which DoF mid_clamp = FreeClamp(mid_vertex.position) optimizer.add_clamp(mid_clamp) optimizer.optimize() mesh.write(os.path.join("..", "case", "system", "blockMeshDict"), debug_path="debug.vtk") ================================================ FILE: examples/shape/custom.py ================================================ import os from typing import ClassVar import numpy as np import classy_blocks as cb from classy_blocks.cbtyping import PointType from classy_blocks.util import functions as f # an example with a custom sketch, yielding a custom shape (square with rounded corners); # see custom.svg for a sketch of blocking scheme mesh = cb.Mesh() class RoundSquare(cb.MappedSketch): quads: ClassVar = [ [20, 0, 1, 12], [12, 1, 2, 13], [13, 2, 3, 21], [21, 20, 12, 13], [21, 3, 4, 14], [14, 4, 5, 15], [15, 5, 6, 22], [22, 21, 14, 15], [22, 6, 7, 16], [16, 7, 8, 17], [17, 8, 9, 23], [23, 22, 16, 17], [23, 9, 10, 18], [18, 10, 11, 19], [19, 11, 0, 20], [20, 23, 18, 19], [21, 22, 23, 20], ] chops: ClassVar = [[0], [0, 1, 4, 5, 8, 12]] def __init__(self, center: PointType, side: float, corner_round: float): center = np.array(center) points = [ center + f.vector(side / 2, 0, 0), center + f.vector(side / 2, side / 2 - side * corner_round / 2, 0), center + f.vector(side / 2 - side * corner_round / 2, side / 2, 0), ] outer_points = [] angles = np.linspace(0, 2 * np.pi, num=4, endpoint=False) for a in angles: for i in range(3): outer_points.append(f.rotate(points[i], a, [0, 0, 1], center)) inner_points = np.ones((12, 3)) * center super().__init__(np.concatenate((outer_points, inner_points), axis=0), RoundSquare.quads) def add_edges(self) -> None: for i in (1, 5, 9, 13): self.faces[i].add_edge(1, cb.Angle(np.pi / 2, [0, 0, 1])) base = RoundSquare([0, 0, 0], 1, 0.5) smoother = cb.SketchSmoother(base) smoother.smooth() shape = cb.ExtrudedShape(base, 1) shape.chop(0, start_size=0.05) shape.chop(1, start_size=0.05) shape.chop(2, count=5) mesh.add(shape) mesh.assemble() grader = cb.SimpleGrader(mesh, 0.03, take="max") grader.grade() mesh.set_default_patch("walls", "wall") mesh.write(os.path.join("..", "case", "system", "blockMeshDict"), debug_path="debug.vtk") ================================================ FILE: examples/shape/cylinder.py ================================================ import os import classy_blocks as cb mesh = cb.Mesh() axis_point_1 = [0, 0, 0] axis_point_2 = [0, 0, 1] radius_point_1 = [1, 0, 0] cylinder = cb.Cylinder(axis_point_1, axis_point_2, radius_point_1) cylinder.set_start_patch("inlet") cylinder.set_end_patch("outlet") cylinder.set_outer_patch("walls") # If curved core edges get in the way (when moving vertices, optimization, ...), # remove them with this method: cylinder.remove_inner_edges(start=False, end=True) # Chop and grade bl_thickness = 1e-3 core_size = 0.1 cylinder.chop_axial(count=30) cylinder.chop_radial(start_size=core_size, end_size=bl_thickness) cylinder.chop_tangential(start_size=core_size) cylinder.set_start_patch("walls") cylinder.set_end_patch("walls") mesh.add(cylinder) mesh.modify_patch("walls", "wall") mesh.write(os.path.join("..", "case", "system", "blockMeshDict"), debug_path="debug.vtk") ================================================ FILE: examples/shape/elbow.py ================================================ import os import numpy as np import classy_blocks as cb mesh = cb.Mesh() radius_1 = 1 center_point_1 = [0.0, 0.0, 0.0] radius_point_1 = [radius_1, 0.0, 0.0] normal_1 = [0.0, 1.0, 0.0] sweep_angle = -np.pi / 3 arc_center = [2.0, 0.0, 0.0] rotation_axis = [0.0, 0.0, 1.0] radius_2 = 0.4 boundary_size = 0.01 core_size = 0.08 elbow = cb.Elbow(center_point_1, radius_point_1, normal_1, sweep_angle, arc_center, rotation_axis, radius_2) elbow.set_start_patch("inlet") elbow.set_outer_patch("walls") elbow.set_end_patch("outlet") # counts and gradings elbow.chop_tangential(start_size=core_size) elbow.chop_radial(start_size=core_size, end_size=boundary_size) elbow.chop_axial(start_size=2 * core_size) mesh.add(elbow) mesh.write(os.path.join("..", "case", "system", "blockMeshDict"), debug_path="debug.vtk") ================================================ FILE: examples/shape/extruded_ring.py ================================================ import os import classy_blocks as cb mesh = cb.Mesh() pipe_wall = cb.ExtrudedRing( [0, 0, 0], [2, 2, 0], [-0.707, 0.707, 0], 0.8, # axis_point_1 # axis_point_2 # outer_radius # inner_radius ) core_size = 0.1 bl_thickness = 0.005 pipe_wall.chop_axial(start_size=core_size) pipe_wall.chop_tangential(start_size=core_size) # chop radially twice to get boundary layer cells on both sides; pipe_wall.chop_radial(length_ratio=0.5, start_size=bl_thickness, c2c_expansion=1.2) pipe_wall.chop_radial(length_ratio=0.5, end_size=bl_thickness, c2c_expansion=1 / 1.2) pipe_wall.set_start_patch("inlet") pipe_wall.set_end_patch("outlet") pipe_wall.set_inner_patch("inner_wall") pipe_wall.set_outer_patch("outer_wall") mesh.add(pipe_wall) mesh.write(os.path.join("..", "case", "system", "blockMeshDict"), debug_path="debug.vtk") ================================================ FILE: examples/shape/frustum.py ================================================ import os import numpy as np import classy_blocks as cb mesh = cb.Mesh() axis_point_1 = [0.0, 0.0, 0.0] axis_point_2 = [2.0, 0, 0.0] radius_point_1 = [0.0, 0.0, 2.0] radius_2 = 0.5 bl_thickness = 0.01 core_size = 0.1 # A note about radius_mid; # it can be used to create shapes of revolution with curved sides; # however, due to the way blockMesh face creation works, the result won't # be totally 'round'. # Also, in drastic cases, non-orthogonality at beginning/end face will be high # because of sharp edges; in those cases it is better to use RevolvedRing combined with # Cylinder/Frustum with non-flat start/end faces. frustum = cb.Frustum(axis_point_1, axis_point_2, radius_point_1, radius_2, radius_mid=1.1) cylinder = cb.Cylinder.chain(frustum, 6, start_face=True) pos = cylinder.sketch_1.positions pos[:9] += np.array([-0.3, 0, 0]) cylinder.sketch_1.update(pos) cylinder.sketch_1.add_edges() pos = frustum.sketch_1.positions pos[:9] += np.array([-0.3, 0, 0]) frustum.sketch_1.update(pos) frustum.sketch_1.add_edges() cylinder.set_end_patch("inlet") frustum.set_outer_patch("walls") cylinder.set_outer_patch("walls") frustum.set_end_patch("outlet") cylinder.chop_axial(count=90) frustum.chop_axial(count=30) frustum.chop_radial(start_size=core_size, end_size=bl_thickness) frustum.chop_tangential(start_size=core_size) mesh.add(cylinder) mesh.add(frustum) mesh.modify_patch("walls", "wall") mesh.write(os.path.join("..", "case", "system", "blockMeshDict"), debug_path="debug.vtk") ================================================ FILE: examples/shape/hemisphere.py ================================================ import os import classy_blocks as cb center = [0.0, 0.0, 0.0] radius_point = [0.0, 0.0, 1.0] normal = [0.0, 1.0, 0.0] cell_size = 0.1 bl_thickness = 0.01 sphere = cb.Hemisphere(center, radius_point, normal) sphere.chop_axial(start_size=cell_size) sphere.chop_radial(start_size=cell_size, end_size=bl_thickness) sphere.chop_tangential(start_size=cell_size) sphere.set_start_patch("atmosphere") # the 'flat' part of the hemisphere sphere.set_outer_patch("walls") mesh = cb.Mesh() mesh.add(sphere) mesh.modify_patch("walls", "wall") mesh.write(os.path.join("..", "case", "system", "blockMeshDict"), debug_path="debug.vtk") ================================================ FILE: examples/shape/one_core_cylinder.py ================================================ import os import classy_blocks as cb from classy_blocks.construct.flat.sketches.disk import OneCoreDisk from classy_blocks.util import functions as f # A simpler version of a Cylinder with only a single block in the middle; # easier to block but slightly worse cell quality; # currently there is no OneCoreCylinder shape so it has to be extruded; # two lines are needed instead of one. mesh = cb.Mesh() axis_point_1 = f.vector(0.0, 0.0, 0.0) axis_point_2 = f.vector(5.0, 5.0, 0.0) radius_point_1 = f.vector(0.0, 0.0, 2.0) one_core_disk = OneCoreDisk(axis_point_1, radius_point_1, axis_point_1 - axis_point_2) cylinder = cb.ExtrudedShape(one_core_disk, f.norm(axis_point_2 - axis_point_1)) cylinder.chop(0, count=5) cylinder.chop(1, count=10) cylinder.chop(2, count=20) mesh.add(cylinder) mesh.set_default_patch("walls", "wall") mesh.write(os.path.join("..", "case", "system", "blockMeshDict"), debug_path="debug.vtk") ================================================ FILE: examples/shape/quarter_cylinder.py ================================================ import os import classy_blocks as cb from classy_blocks.construct.flat.sketches.disk import QuarterDisk from classy_blocks.util import functions as f mesh = cb.Mesh() axis_point_1 = f.vector(0.0, 0.0, 0.0) axis_point_2 = f.vector(5.0, 5.0, 0.0) radius_point_1 = f.vector(0.0, 0.0, 2.0) # make a disk with small core block - yields particularly bad cell sizes with normal chops QuarterDisk.core_ratio = 0.4 quarter_disk = QuarterDisk(axis_point_1, radius_point_1, axis_point_1 - axis_point_2) quarter_cylinder = cb.ExtrudedShape(quarter_disk, f.norm(axis_point_2 - axis_point_1)) mesh.add(quarter_cylinder) # Use an automatic grader that will try to make cells in neighbouring blocks # as similar in size as possible grader = cb.SimpleGrader(mesh, 0.05) grader.grade() mesh.set_default_patch("walls", "wall") mesh.write(os.path.join("..", "case", "system", "blockMeshDict"), debug_path="debug.vtk") ================================================ FILE: examples/shape/revolved_ring.py ================================================ import os import classy_blocks as cb mesh = cb.Mesh() # points that define ring cross-section; # must be specified in the following order: # p3---___ # / ---p2 # / \ # p0---------------p1 # # 0---- -- ----- -- ----- -- ----- -- --->> axis xs_points = [ [0.1, 0.2, 0], [0.8, 0.1, 0], [0.7, 0.5, 0], [0.2, 0.5, 0], ] xs_edges = [None, None, cb.Arc([0.3, 0.55, 0]), None] # these must be consistent with points face = cb.Face(xs_points, xs_edges) pipe_wall = cb.RevolvedRing([0, 0, 0], [1, 0, 0], face) # axis_point_1 # axis_point_2, core_size = 0.05 pipe_wall.chop_axial(start_size=core_size) pipe_wall.chop_tangential(start_size=core_size) pipe_wall.chop_radial(count=10) pipe_wall.set_start_patch("inlet") pipe_wall.set_end_patch("outlet") pipe_wall.set_inner_patch("inner_wall") pipe_wall.set_outer_patch("outer_wall") mesh.add(pipe_wall) mesh.write(os.path.join("..", "case", "system", "blockMeshDict"), debug_path="debug.vtk") ================================================ FILE: examples/shape/shell.py ================================================ import os import classy_blocks as cb mesh = cb.Mesh() # Create a 7-block sphere by offsetting a box's faces. box = cb.Box([-0.5, -0.5, -0.5], [0.5, 0.5, 0.5]) for i in range(3): box.chop(i, count=10) mesh.add(box) # faces must point 'out' of the original block or # newly created blocks will be inside-out; offset_faces = [box.get_face(orient) for orient in ("bottom", "top", "left", "right", "front", "back")] for i in (0, 2, 4): offset_faces[i].invert() shell = cb.Shell(offset_faces, 0.5) shell.chop(count=10) for operation in shell.operations: operation.project_side("top", "sphere", edges=True, points=True) mesh.add(shell) mesh.add_geometry( { "sphere": [ "type searchableSphere", "centre (0 0 0)", "radius 1.5", ] } ) mesh.set_default_patch("walls", "wall") mesh.write(os.path.join("..", "case", "system", "blockMeshDict"), debug_path="debug.vtk") ================================================ FILE: examples/shape/splined/combined_example.py ================================================ import os import numpy as np import classy_blocks as cb center_point = np.asarray([0, 0, 0]) direction = np.asarray([1, 0, 0]) # Sketches can be completely circular. Here Arc are used to define the outer edges. circular_sketch_1 = cb.SplineDisk( center_point=center_point, corner_1_point=np.asarray([0, 1, 0]), corner_2_point=np.asarray([0, 0, 1]), side_1=0, side_2=0, ) # Sketches can be elliptical. The outer edges are defined by splines. # For high accuracy more spline points can be added using the key word n_outer_spline_points, in the initialization. elliptical_sketch_1 = cb.SplineDisk( center_point=center_point + direction * 1, corner_1_point=np.asarray([0, 1, 0]) + direction * 1, corner_2_point=np.asarray([0, 0, 1.2]) + direction * 1, side_1=0, side_2=0, n_outer_spline_points=50, ) elliptical_sketch_2 = cb.SplineDisk( center_point=center_point + direction * 2, corner_1_point=np.asarray([0, 1, 0]) + direction * 2, corner_2_point=np.asarray([0, 0, 1.5]) + direction * 2, side_1=0, side_2=0, ) elliptical_sketch_3 = cb.SplineDisk( center_point=center_point + direction * 3, corner_1_point=np.asarray([0, 1.1, 0]) + direction * 3, corner_2_point=np.asarray([0, 0, 2]) + direction * 3, side_1=0, side_2=0, ) # Ovals can be created by setting side_0 or side_1 different from 0. oval_sketch_1 = cb.SplineDisk( center_point=center_point + direction * 3.4, corner_1_point=np.asarray([0, 1.2, 0]) + direction * 3.4, corner_2_point=np.asarray([0, 0, 2.2]) + direction * 3.4, side_1=0.3, side_2=0.3, ) oval_sketch_2 = cb.SplineDisk( center_point=center_point + direction * 4, corner_1_point=np.asarray([0, 1.5, 0]) + direction * 4, corner_2_point=np.asarray([0, 0, 2.5]) + direction * 4, side_1=0.8, side_2=1.8, n_straight_spline_points=100, ) # It is also possible to create a hollow ring. # The ring is defined as the disk, # where a ring with same center, corner_1... as a disk will fit on the outside of the disk. oval_ring_1 = cb.SplineRing( center_point=oval_sketch_2.center, corner_1_point=oval_sketch_2.radius_1_point, corner_2_point=oval_sketch_2.radius_2_point, side_1=oval_sketch_2.side_1, side_2=oval_sketch_2.side_2, width_1=0.2, width_2=0.2, n_outer_spline_points=100, ) # Note it is possible to access the center and corners of a defined sketch. These are stable on transformation. oval_ring_2 = cb.SplineRing( center_point=oval_sketch_2.center + direction, corner_1_point=oval_sketch_2.radius_1_point + direction, corner_2_point=oval_sketch_2.radius_2_point + direction, side_1=0, side_2=0, width_1=0.2, width_2=0.2, n_outer_spline_points=100, ) # A lofted shape with a list of sketches as midSketch, # creates splines going through the midSketches in the Lofted direction. shape_1 = cb.LoftedShape(circular_sketch_1, elliptical_sketch_3, [elliptical_sketch_1, elliptical_sketch_2]) # A lofted shape with a only one of sketches as midSketch, # creates arches going through the midSketches in the Lofted direction. shape_2 = cb.LoftedShape(elliptical_sketch_3, oval_sketch_2, [oval_sketch_1]) # A lofted shape without midSketch, # creates straight lines in the Lofted direction. shape_3 = cb.LoftedShape(oval_ring_1, oval_ring_2) shape_1.chop(0, start_size=0.05) shape_1.chop(1, start_size=0.05) shape_1.chop(2, start_size=0.05) shape_2.chop(2, start_size=0.05) shape_3.chop(0, start_size=0.05) shape_3.chop(2, start_size=0.05) mesh = cb.Mesh() mesh.add(shape_1) mesh.add(shape_2) mesh.add(shape_3) mesh.write(os.path.join("..", "..", "case", "system", "blockMeshDict"), debug_path="debug.vtk") ================================================ FILE: examples/shape/splined/spline_ring.py ================================================ import os import classy_blocks as cb mesh = cb.Mesh() sketch = cb.SplineRing([0, 0, 0], [1, 0, 0], [0, 1.2, 0], 0.2, 0.3, 0.1, 0.2) ring = cb.ExtrudedShape(sketch, 1) for operation in ring.operations: for i in range(3): operation.chop(i, count=10) mesh.add(ring) mesh.write(os.path.join("..", "..", "case", "system", "blockMeshDict"), debug_path="debug.vtk") ================================================ FILE: examples/shape/splined/spline_ring_whole_half_quarter.py ================================================ import os import classy_blocks as cb mesh = cb.Mesh() quarter_ring_sketch = cb.QuarterSplineRing([0, 0, 0], [1.0, 0, 0], [0, 1.0, 0], 0.1, 0.2, 0.8, 0.8) half_ring_sketch = cb.HalfSplineRing([0, 0, 0], [1.0, 0, 0], [0, 1.0, 0], 0.1, 0.2, 0.8, 0.8) whole_ring_sketch = cb.SplineRing([0, 0, 0], [1.0, 0, 0], [0, 1.0, 0], 0.1, 0.2, 0.8, 0.8) sketches = [quarter_ring_sketch, half_ring_sketch, whole_ring_sketch] extruded_shapes = [] for i, sketch in enumerate(sketches): extruded_shapes.append(cb.ExtrudedShape(sketch, 1)) extruded_shapes[i].translate([0, 0, i * 3]) for operation in extruded_shapes[i].operations: for j in range(3): operation.chop(j, count=10) mesh.add(extruded_shapes[i]) mesh.write(os.path.join("..", "..", "case", "system", "blockMeshDict"), debug_path="debug.vtk") ================================================ FILE: examples/shape/splined/spline_round.py ================================================ import os import random import classy_blocks as cb from classy_blocks.construct.flat.sketches.spline_round import SplineDisk mesh = cb.Mesh() height = 2 edge_sketches = 4 sketch = SplineDisk([0, 0, 0], [2, 0, 0], [0, 1, 0], 1, 0.3) dz = height / (edge_sketches + 1) xs_sketches = [sketch.copy().translate([random.random() / 10, random.random() / 10, dz])] for _ in range(edge_sketches - 1): xs_sketches.append(xs_sketches[-1].copy().translate([random.random() / 10, random.random() / 10, dz])) extrude = cb.LoftedShape(sketch, sketch.copy().translate([0, 0, height]), xs_sketches) for axis in range(3): extrude.chop(axis, count=10) mesh.add(extrude) mesh.write(os.path.join("..", "..", "case", "system", "blockMeshDict"), debug_path="debug.vtk") ================================================ FILE: examples/shape/torus.py ================================================ import os import numpy as np import classy_blocks as cb mesh = cb.Mesh() # A torus; a simple demonstration of copying and transforming inner_radius = 0.3 outer_radius = 0.6 n_segments = 8 n_cells = 5 sweep_angle = 2 * np.pi / n_segments elbow = cb.Elbow( [inner_radius + (outer_radius - inner_radius) / 2, 0, 0], [inner_radius, 0, 0], [0, -1, 0], -sweep_angle, [0, 0, 0], [0, 0, 1], (outer_radius - inner_radius) / 2, ) # counts and gradings elbow.chop_tangential(count=n_cells) elbow.chop_radial(count=n_cells) elbow.chop_axial(count=n_cells) mesh.add(elbow) for i in range(1, n_segments): segment = elbow.copy().rotate(-i * sweep_angle, [0, 0, 1], [0, 0, 0]) mesh.add(segment) mesh.set_default_patch("walls", "wall") mesh.write(os.path.join("..", "case", "system", "blockMeshDict"), debug_path="debug.vtk") ================================================ FILE: examples/shape/wrapped_cylinder.py ================================================ import os import classy_blocks as cb from classy_blocks.construct.flat.sketches.disk import WrappedDisk # A simpler version of a Cylinder with only a single block in the middle; # easier to block but slightly worse cell quality; # currently there is no OneCoreCylinder shape so it has to be extruded; # two lines are needed instead of one. mesh = cb.Mesh() center_1 = (0.0, 0.0, 0.0) corner_point = (-2, -2, 0) radius = 1 height = 2 cell_size = 0.1 one_core_disk = WrappedDisk(center_1, corner_point, radius, [0, 0, 1]) cylinder = cb.ExtrudedShape(one_core_disk, height) cylinder.chop(0, start_size=cell_size) cylinder.chop(1, start_size=cell_size) cylinder.chop(2, start_size=cell_size) mesh.add(cylinder) mesh.set_default_patch("walls", "wall") mesh.write(os.path.join("..", "case", "system", "blockMeshDict"), debug_path="debug.vtk") ================================================ FILE: examples/stack/cube.py ================================================ # a mesh for studying flow over a cube import os import classy_blocks as cb # cube side; # it sits in the coordinate system origin # (center of the cube is [0, 0, side/2]) side = 1 # dimension of blocks upstream = 3 downstream = 5 width = 3 height = 3 # cell sizing wall_size = 0.05 far_size = 0.5 mesh = cb.Mesh() # first, create cubic blocks all around point_1 = [-1.5 * side, -1.5 * side, 0] point_2 = [1.5 * side, 1.5 * side, 0] base = cb.Grid(point_1, point_2, 3, 3) stack = cb.ExtrudedStack(base, side * 2, 2) # add all blocks to mesh mesh.add(stack) # fetch the points on appropriate planes and move them to desired dimension mesh.assemble() finder = cb.GeometricFinder(mesh) def move_side(point, normal, axis, value): for vertex in finder.find_on_plane(point, normal): vertex.position[axis] = value move_side(point_1, [-1, 0, 0], 0, -upstream * side - side / 2) # upstream move_side(point_1, [0, -1, 0], 1, -width * side - side / 2) # width -y move_side(point_2, [0, 1, 0], 1, width * side + side / 2) # width +y move_side([0, 0, 2 * side], [0, 0, 1], 2, height * side) # height move_side(point_2, [1, 0, 0], 0, downstream * side + side / 2) # downstream mesh.backport() mesh.clear() # chop relevant blocks stack.grid[0][1][0].chop(0, start_size=far_size, end_size=wall_size) stack.grid[0][1][2].chop(0, start_size=wall_size, end_size=far_size) stack.grid[1][1][1].chop(0, start_size=wall_size) stack.grid[0][0][1].chop(1, start_size=far_size, end_size=wall_size) stack.grid[0][2][1].chop(1, start_size=wall_size, end_size=far_size) stack.grid[1][1][1].chop(1, start_size=wall_size) stack.grid[0][0][0].chop(2, start_size=wall_size) stack.grid[1][1][1].chop(2, start_size=wall_size, end_size=far_size) # Set patches for operation in stack.get_slice(0, 0): operation.set_patch("left", "inlet") for operation in stack.get_slice(0, -1): operation.set_patch("right", "outlet") for operation in stack.get_slice(2, 0): operation.set_patch("bottom", "floor") stack.grid[0][1][0].set_patch("right", "cube") stack.grid[0][0][1].set_patch("back", "cube") stack.grid[0][2][1].set_patch("front", "cube") stack.grid[0][1][2].set_patch("left", "cube") stack.grid[1][1][1].set_patch("bottom", "cube") # Delete the block we're studying; # TODO: BUG: it matters when this is deleted (but should not be the case?) mesh.delete(stack.grid[0][1][1]) mesh.set_default_patch("freestream", "patch") mesh.write(os.path.join("..", "case", "system", "blockMeshDict"), debug_path="debug.vtk") ================================================ FILE: examples/stack/fusilli.py ================================================ import os import numpy as np import classy_blocks as cb from classy_blocks.base.transforms import Rotation, Translation # Something resembling pasta. cell_size = 0.05 oval_point_1 = [0, 0, 0] oval_point_2 = [0, -1, 0] oval_radius = 0.5 normal = [0, 0, 1] mesh = cb.Mesh() base = cb.Oval(oval_point_1, oval_point_2, normal, oval_radius) stack = cb.TransformedStack( base, [Translation([0, 0, 0.3]), Rotation(normal, np.pi / 6, [0, -0.5, 0])], 12, [Translation([0, 0, 0.15]), Rotation(normal, np.pi / 12, [0, -0.5, 0])], ) stack.shapes[0].chop(0, start_size=cell_size, end_size=cell_size / 10) stack.shapes[0].chop(1, start_size=cell_size) stack.chop(count=6) stack.shapes[0].set_start_patch("inlet") stack.shapes[-1].set_end_patch("outlet") mesh.add(stack) mesh.set_default_patch("walls", "wall") mesh.write(os.path.join("..", "case", "system", "blockMeshDict"), debug_path="debug.vtk") ================================================ FILE: examples/transform/mirror.py ================================================ import os import classy_blocks as cb mesh = cb.Mesh() axis_point_1 = [0.0, 0.0, 0.0] axis_point_2 = [2.0, 0.0, 0.0] radius_point_1 = [0.0, 0.0, 2.0] radius_2 = 0.5 bl_thickness = 0.01 core_size = 0.1 frustum = cb.Frustum(axis_point_1, axis_point_2, radius_point_1, radius_2, radius_mid=1.1) frustum.set_end_patch("inlet") frustum.chop_axial(count=30) frustum.chop_radial(start_size=core_size, end_size=bl_thickness) frustum.chop_tangential(start_size=core_size) mesh.add(frustum) # mirroring an operation or a shape will # not invert blocks' orientation; # their 'start' and 'end' patches (axis 2) will # point in the same direction as the original blocks/operations mirror = frustum.copy().mirror([1, 0, 0]) mirror.set_start_patch("outlet") mirror.chop_axial(count=30) mesh.add(mirror) mesh.set_default_patch("walls", "wall") mesh.write(os.path.join("..", "case", "system", "blockMeshDict"), debug_path="debug.vtk") ================================================ FILE: pyproject.toml ================================================ [project] name = "classy_blocks" version = "1.10.0" description = "Python classes for easier creation of openFoam's blockMesh dictionaries." readme = "README.md" license = "MIT" keywords = ["classy_blocks", "OpenFOAM", "blockMesh"] authors = [{ name = "Nejc Jurkovic", email = "kandelabr@gmail.com" }] requires-python = ">=3.9" dependencies = ["numpy", "scipy", "nptyping", "numba"] [project.urls] "Homepage" = "https://github.com/damogranlabs/classy_blocks" "Tutorials" = "https://damogranlabs.com/category/classy_blocks" "Bug Tracker" = "https://github.com/damogranlabs/classy_blocks/issues" "Contributing" = "https://github.com/damogranlabs/classy_blocks/blob/master/CONTRIBUTING.md" [project.optional-dependencies] dev = ["pytest", "parameterized", "ruff~=0.9", "mypy~=1.2", "pre-commit"] [build-system] requires = ["setuptools >= 80.0"] build-backend = "setuptools.build_meta" [tool.ruff] target-version = "py39" line-length = 120 [tool.ruff.lint] select = ["E", "F", "I", "N", "UP", "YTT", "B", "A", "ARG", "RUF"] [[tool.mypy.overrides]] module = "scipy,scipy.*,parameterized.*" ignore_missing_imports = true [tool.pytest.ini_options] minversion = "6.0" addopts = "-ra -q" testpaths = ["tests"] ================================================ FILE: src/classy_blocks/__init__.py ================================================ from .base.transforms import Mirror, Rotation, Scaling, Shear, Translation from .construct.assemblies.assembly import Assembly from .construct.assemblies.joints import LJoint, NJoint, TJoint from .construct.curves.analytic import AnalyticCurve, CircleCurve, LineCurve from .construct.curves.curve import CurveBase from .construct.curves.discrete import DiscreteCurve from .construct.curves.interpolated import LinearInterpolatedCurve, SplineInterpolatedCurve from .construct.edges import Angle, Arc, OnCurve, Origin, PolyLine, Project, Spline from .construct.flat.face import Face from .construct.flat.sketch import Sketch from .construct.flat.sketches.disk import FourCoreDisk, HalfDisk, OneCoreDisk, Oval, WrappedDisk from .construct.flat.sketches.grid import Grid from .construct.flat.sketches.mapped import MappedSketch from .construct.flat.sketches.spline_round import ( HalfSplineDisk, HalfSplineRing, QuarterSplineDisk, QuarterSplineRing, SplineDisk, SplineRing, ) from .construct.operations.box import Box from .construct.operations.connector import Connector from .construct.operations.extrude import Extrude from .construct.operations.loft import Loft from .construct.operations.operation import Operation from .construct.operations.revolve import Revolve from .construct.operations.wedge import Wedge from .construct.shape import ExtrudedShape, LoftedShape, RevolvedShape, Shape from .construct.shapes.cylinder import Cylinder, QuarterCylinder, SemiCylinder from .construct.shapes.elbow import Elbow from .construct.shapes.frustum import Frustum from .construct.shapes.rings import ExtrudedRing, RevolvedRing from .construct.shapes.shell import Shell from .construct.shapes.sphere import EighthSphere, Hemisphere, QuarterSphere from .construct.stack import ExtrudedStack, RevolvedStack, TransformedStack from .grading.graders.fixed import FixedCountGrader from .grading.graders.inflation import InflationGrader from .grading.graders.simple import SimpleGrader from .mesh import Mesh from .modify.find.geometric import GeometricFinder from .modify.find.shape import RoundSolidFinder from .modify.reorient.viewpoint import ViewpointReorienter from .optimize.clamps.clamp import ClampBase from .optimize.clamps.curve import CurveClamp, LineClamp, RadialClamp from .optimize.clamps.free import FreeClamp from .optimize.clamps.surface import ParametricSurfaceClamp, PlaneClamp from .optimize.links import LinkBase, RotationLink, SymmetryLink, TranslationLink from .optimize.optimizer import MeshOptimizer, ShapeOptimizer, SketchOptimizer from .optimize.smoother import MeshSmoother, SketchSmoother __all__ = [ "AnalyticCurve", "Angle", "Arc", "Assembly", "Box", "CircleCurve", "ClampBase", "Connector", "CurveBase", "CurveClamp", "Cylinder", "DiscreteCurve", "EighthSphere", "Elbow", "Extrude", "ExtrudedRing", "ExtrudedShape", "ExtrudedStack", "Face", "FixedCountGrader", "FourCoreDisk", "FreeClamp", "Frustum", "GeometricFinder", "Grid", "HalfDisk", "HalfSplineDisk", "HalfSplineRing", "Hemisphere", "InflationGrader", "LJoint", "LineClamp", "LineCurve", "LinearInterpolatedCurve", "LinkBase", "Loft", "LoftedShape", "MappedSketch", "Mesh", "MeshOptimizer", "MeshSmoother", "Mirror", "NJoint", "OnCurve", "OneCoreDisk", "Operation", "Origin", "Oval", "ParametricSurfaceClamp", "PlaneClamp", "PolyLine", "Project", "QuarterCylinder", "QuarterSphere", "QuarterSplineDisk", "QuarterSplineRing", "RadialClamp", "Revolve", "RevolvedRing", "RevolvedShape", "RevolvedStack", "Rotation", "RotationLink", "RoundSolidFinder", "Scaling", "SemiCylinder", "Shape", "ShapeOptimizer", "Shear", "Shell", "SimpleGrader", "Sketch", "SketchOptimizer", "SketchSmoother", "Spline", "SplineDisk", "SplineInterpolatedCurve", "SplineRing", "SymmetryLink", "TJoint", "TransformedStack", "Translation", "TranslationLink", "ViewpointReorienter", "Wedge", "WrappedDisk", ] ================================================ FILE: src/classy_blocks/assemble/__init__.py ================================================ ================================================ FILE: src/classy_blocks/assemble/assembler.py ================================================ from classy_blocks.assemble.depot import Depot from classy_blocks.assemble.dump import AssembledDump from classy_blocks.assemble.settings import Settings from classy_blocks.base.exceptions import EdgeNotFoundError from classy_blocks.items.block import Block from classy_blocks.items.vertex import Vertex from classy_blocks.lists.block_list import BlockList from classy_blocks.lists.edge_list import EdgeList from classy_blocks.lists.face_list import FaceList from classy_blocks.lists.patch_list import PatchList from classy_blocks.lists.vertex_list import VertexList from classy_blocks.lookup.point_registry import HexPointRegistry from classy_blocks.util import constants class MeshAssembler: def __init__(self, depot: Depot, settings: Settings, merge_tol=constants.TOL): self.depot = depot self.settings = settings self.merge_tol = merge_tol self._operations = self.depot.operations self._points = HexPointRegistry.from_operations(self._operations, self.merge_tol) def _create_blocks(self, vertex_list: VertexList) -> BlockList: block_list = BlockList() for iop, operation in enumerate(self._operations): op_indexes = self._points.cell_addressing[iop] op_vertices = [vertex_list.vertices[i] for i in op_indexes] # duplicate vertices on slave patches for corner in range(8): point = operation.points[corner] # remove master patches, only slave will remain patches = operation.get_patches_at_corner(corner) patches = patches.intersection(self.settings.slave_patches) if len(patches) == 0: continue op_vertices[corner] = vertex_list.add_duplicated(point, patches) block = Block(iop, op_vertices) block.set_chops(operation.chops) block.cell_zone = operation.cell_zone block.visible = operation not in self.depot.deleted block_list.add(block) return block_list def _create_edges(self, block_list: BlockList) -> EdgeList: edge_list = EdgeList() # skim edges from operations for iop, operation in enumerate(self._operations): block = block_list.blocks[iop] vertices = block.vertices for ipnt, point in enumerate(operation.points): vertices[ipnt].project(point.projected_to) edge_list.add_from_operation(vertices, operation) # and add them to blocks for iop in range(len(self._operations)): block = block_list.blocks[iop] for wire in block.wire_list: try: edge = edge_list.find(*wire.vertices) block.add_edge(wire.corners[0], wire.corners[1], edge) except EdgeNotFoundError: continue return edge_list def _add_geometry(self): for solid in self.depot.solids: if solid.geometry is not None: self.settings.add_geometry(solid.geometry) def _create_patches(self, block_list: BlockList) -> tuple[PatchList, FaceList]: patch_list = PatchList() face_list = FaceList() for i, block in enumerate(block_list.blocks): if not block.visible: continue patch_list.add(block.vertices, self._operations[i]) face_list.add(block.vertices, self._operations[i]) for name, settings in self.settings.patch_settings.items(): patch_list.modify(name, settings[0], settings[1:]) # set slave patches for pair in self.settings.merged_patches: patch_list.merge(pair[0], pair[1]) return patch_list, face_list def _update_neighbours(self, block_list: BlockList) -> None: block_list.update_neighbours(self._points) def assemble(self) -> AssembledDump: # Create reused/indexes vertices from operations' points vertex_list = VertexList([Vertex(pos, i) for i, pos in enumerate(self._points.unique_points)]) # Create blocks from vertices; when there's a slave patch specified in an operation, # duplicate vertices for that patch block_list = self._create_blocks(vertex_list) # extract edges from operations and attach them to blocks edge_list = self._create_edges(block_list) # extract auto-generated geometry specs from shapes (like Sphere etc.) self._add_geometry() # update blocks' neighbours self._update_neighbours(block_list) # scrape patch and projection info from operations patch_list, face_list = self._create_patches(block_list) return AssembledDump(vertex_list, block_list, edge_list, face_list, patch_list) ================================================ FILE: src/classy_blocks/assemble/depot.py ================================================ from typing import Union from classy_blocks.construct.assemblies.assembly import Assembly from classy_blocks.construct.operations.operation import Operation from classy_blocks.construct.shape import Shape from classy_blocks.construct.stack import Stack AdditiveType = Union[Operation, Shape, Stack, Assembly] class Depot: """Collects, stores and serves user-added AdditiveType stuff""" def __init__(self) -> None: self.solids: list[AdditiveType] = [] self.deleted: list[Operation] = [] def add_solid(self, solid: AdditiveType) -> None: self.solids.append(solid) def delete_solid(self, operation: Operation) -> None: self.deleted.append(operation) @property def operations(self) -> list[Operation]: operations: list[Operation] = [] for solid in self.solids: if isinstance(solid, Operation): operations.append(solid) else: operations += solid.operations return operations ================================================ FILE: src/classy_blocks/assemble/dump.py ================================================ import abc from classy_blocks.base.exceptions import MeshNotAssembledError from classy_blocks.items.block import Block from classy_blocks.items.edges.edge import Edge from classy_blocks.items.patch import Patch from classy_blocks.items.vertex import Vertex from classy_blocks.lists.block_list import BlockList from classy_blocks.lists.edge_list import EdgeList from classy_blocks.lists.face_list import FaceList from classy_blocks.lists.patch_list import PatchList from classy_blocks.lists.vertex_list import VertexList class DumpBase(abc.ABC): @property @abc.abstractmethod def is_assembled(self) -> bool: pass @property @abc.abstractmethod def vertices(self) -> list[Vertex]: pass @property @abc.abstractmethod def patches(self) -> list[Patch]: pass @property @abc.abstractmethod def blocks(self) -> list[Block]: pass @property @abc.abstractmethod def edges(self) -> list[Edge]: pass class EmptyDump(DumpBase): @property def is_assembled(self): return False @property def vertices(self): raise MeshNotAssembledError("The mesh is not assembled") @property def patches(self): raise MeshNotAssembledError("The Mesh is not assembled") @property def blocks(self): raise MeshNotAssembledError("The Mesh is not assembled") @property def edges(self): raise MeshNotAssembledError("The Mesh is not assembled") class AssembledDump(DumpBase): def __init__( self, vertex_list: VertexList, block_list: BlockList, edge_list: EdgeList, face_list: FaceList, patch_list: PatchList, ): self.vertex_list = vertex_list self.block_list = block_list self.edge_list = edge_list self.face_list = face_list self.patch_list = patch_list @property def is_assembled(self): """Returns True if assemble() has been executed on this mesh""" return True @property def vertices(self): return self.vertex_list.vertices @property def patches(self): return list(self.patch_list.patches.values()) @property def blocks(self): return self.block_list.blocks @property def edges(self): return list(self.edge_list.edges.values()) ================================================ FILE: src/classy_blocks/assemble/settings.py ================================================ from dataclasses import dataclass, field from typing import Optional from classy_blocks.cbtyping import GeometryType @dataclass class Settings: prescale: Optional[int] = None scale: float = 1 transform: Optional[str] = None merge_type: Optional[str] = None check_face_correspondence: Optional[str] = None verbose: Optional[str] = None # user-provided geometry data geometry: GeometryType = field(default_factory=dict) # user-provided patch data # default patch, single entry only default_patch: dict[str, str] = field(default_factory=dict) # merged patches, a list of pairs of patch names merged_patches: list[tuple[str, str]] = field(default_factory=list) patch_settings: dict[str, list[str]] = field(default_factory=dict) def add_geometry(self, geometry: GeometryType) -> None: self.geometry = {**self.geometry, **geometry} def modify_patch(self, name: str, kind: str, settings: Optional[list[str]] = None) -> None: if settings is None: settings = [] self.patch_settings[name] = [kind, *settings] @property def slave_patches(self) -> set[str]: # TODO: cache return {p[1] for p in self.merged_patches} ================================================ FILE: src/classy_blocks/base/__init__.py ================================================ ================================================ FILE: src/classy_blocks/base/element.py ================================================ import abc import copy from collections.abc import Sequence from typing import Optional, TypeVar from classy_blocks.base import transforms as tr from classy_blocks.cbtyping import NPPointType, PointType, VectorType ElementBaseT = TypeVar("ElementBaseT", bound="ElementBase") class ElementBase(abc.ABC): """Base class for mesh-building elements and tools for manipulation thereof.""" def translate(self: ElementBaseT, displacement: VectorType) -> ElementBaseT: """Move by displacement vector; returns the same instance to enable chaining of transformations.""" for component in self.parts: component.translate(displacement) return self def rotate(self: ElementBaseT, angle: float, axis: VectorType, origin: Optional[PointType] = None) -> ElementBaseT: """Rotate by 'angle' around 'axis' going through 'origin'; returns the same instance to enable chaining of transformations.""" if origin is None: origin = self.center for component in self.parts: component.rotate(angle, axis, origin) return self def scale(self: ElementBaseT, ratio: float, origin: Optional[PointType] = None) -> ElementBaseT: """Scale with respect to given origin; returns the same instance to enable chaining of transformations. If no origin is given, the entity is scaled with respect to its center""" if origin is None: origin = self.center for component in self.parts: component.scale(ratio, origin) return self def mirror(self: ElementBaseT, normal: VectorType, origin: Optional[PointType] = None) -> ElementBaseT: """Mirror around a plane, defined by a normal vector and passing through origin; if origin is not given, [0, 0, 0] is assumed""" if origin is None: origin = [0, 0, 0] for component in self.parts: component.mirror(normal, origin) return self def shear( self: ElementBaseT, normal: VectorType, origin: PointType, direction: VectorType, angle: float ) -> ElementBaseT: for component in self.parts: component.shear(normal, origin, direction, angle) return self def copy(self: ElementBaseT) -> ElementBaseT: """Returns a copy of this object""" return copy.deepcopy(self) @property @abc.abstractmethod def parts(self: ElementBaseT) -> list[ElementBaseT]: """A list of lower-dimension elements from which this element is built, for instance: - an edge has a single arc point, - a face has 4 points and 4 edges, - an Operation has 2 faces and 4 side edges""" @property @abc.abstractmethod def center(self) -> NPPointType: """Center of this entity; used as default origin for transforms""" @property def geometry(self) -> Optional[dict]: """A searchable surface, defined in an entity itself; (like, for instance, sphere's blocks are automatically projected to an ad-hoc defined searchableSphere""" return None def transform(self: ElementBaseT, transforms: Sequence[tr.Transformation]) -> ElementBaseT: for t7m in transforms: # remember center or it will change during transformation # of each self.part center = self.center for part in self.parts: if isinstance(t7m, tr.Translation): part.translate(t7m.displacement) continue if isinstance(t7m, tr.Rotation): origin = t7m.origin if origin is None: origin = center part.rotate(t7m.angle, t7m.axis, origin=origin) continue if isinstance(t7m, tr.Scaling): origin = t7m.origin if origin is None: origin = center part.scale(t7m.ratio, origin=origin) continue if isinstance(t7m, tr.Mirror): origin = t7m.origin if origin is None: origin = [0, 0, 0] part.mirror(t7m.normal, origin=origin) continue if isinstance(t7m, tr.Shear): part.shear(t7m.normal, t7m.origin, t7m.direction, t7m.angle) continue return self ================================================ FILE: src/classy_blocks/base/exceptions.py ================================================ from typing import Optional ### Construction class ShapeCreationError(Exception): """Base class for shape creation errors (invalid parameters/types to shape constructors)""" def __init__(self, msg: str, details: Optional[str] = None, *args) -> None: self.msg = msg self.details = details info = self.msg if self.details: info += f"\n\t{self.details}" super().__init__(info, *args) class PointCreationError(ShapeCreationError): pass class ArrayCreationError(ShapeCreationError): pass class AnnulusCreationError(ShapeCreationError): pass class EdgeCreationError(ShapeCreationError): pass class SideCreationError(ShapeCreationError): pass class FaceCreationError(ShapeCreationError): pass class CylinderCreationError(ShapeCreationError): pass class ElbowCreationError(ShapeCreationError): pass class FrustumCreationError(ShapeCreationError): pass class ExtrudedRingCreationError(ShapeCreationError): pass class GeometryConstraintError(Exception): """Raised when input parameters produce an invalid geometry""" class DegenerateGeometryError(Exception): """Raised when orienting failed because of invalid geometry""" # Shell/offsetting logic class SharedPointError(Exception): """Errors with shared points""" class SharedPointNotFoundError(SharedPointError): pass class PointNotCoincidentError(SharedPointError): pass class DisconnectedChopError(SharedPointError): """Issued when chopping a Shell that has disconnected faces""" ### Search/retrieval class VertexNotFoundError(Exception): """Raised when a vertex at a given point in space doesn't exist yet""" class EdgeNotFoundError(Exception): """Raised when an edge between a given pair of vertices doesn't exist yet""" class CornerPairError(Exception): """Raised when given pair of corners is not valid (for example, edge between 0 and 2)""" class PatchNotFoundError(Exception): """Raised when searching for a non-existing Patch""" ### Grading class UndefinedGradingsError(Exception): """Raised when the user hasn't supplied enough grading data to define all blocks in the mesh""" class InconsistentGradingsError(Exception): """Raised when cell counts for edges on the same axis is not consistent""" class NoInstructionError(Exception): """Raised when building a catalogue""" class BlockNotFoundError(Exception): """Raised when building a catalogue""" ### Optimization class NoClampError(Exception): """Raised when there's no junction defined for a given Clamp""" class ClampExistsError(Exception): """Raised when adding a clamp to a junction that already has one defined""" class NoCommonSidesError(Exception): """Raised when two cells don't share a side""" class NoJunctionError(Exception): """Raised when there's a clamp defined for a vertex that doesn't exist""" class InvalidLinkError(Exception): """Raised when a link has been added that doesn't connect two actual points""" class OptimizationError(Exception): """Raised when optimization of a clamp produced results worse than before""" ### Mesh assembly/writing class MeshNotAssembledError(Exception): """Raised when looking for assembled items on a non-assembled mesh""" ================================================ FILE: src/classy_blocks/base/transforms.py ================================================ """Dataclasses for packing combinations of transforms of into an easily digestable function/method arguments""" import dataclasses from typing import Optional from classy_blocks.cbtyping import PointType, VectorType @dataclasses.dataclass class Transformation: """A superclass that addresses all dataclasses for transformation parameters""" @dataclasses.dataclass class Translation(Transformation): """Parameters required to translate an entity""" displacement: VectorType @dataclasses.dataclass class Rotation(Transformation): """Parameters required to rotate an entity""" axis: VectorType angle: float origin: Optional[PointType] = None @dataclasses.dataclass class Scaling(Transformation): """Parameters required to scale an entity""" ratio: float origin: Optional[PointType] = None @dataclasses.dataclass class Mirror(Transformation): """Parameters required to mirror an entity around an arbitrary plane""" normal: VectorType origin: Optional[PointType] = None @dataclasses.dataclass class Shear(Transformation): """Parameters required for a shear transform""" normal: VectorType origin: PointType direction: VectorType angle: float ================================================ FILE: src/classy_blocks/cbtyping.py ================================================ from collections.abc import Sequence from typing import Any, Callable, Literal, Optional, TypedDict, Union from nptyping import Float, NDArray, Shape # A plain list of floats FloatListType = NDArray[Shape["1, *"], Float] # A single point can be specified as a list of floats or as a numpy array NPPointType = NDArray[Shape["3, 1"], Any] PointType = Union[Sequence[Union[int, float]], NPPointType] # Similar: a list of points NPPointListType = NDArray[Shape["*, 3"], Any] PointListType = Union[NPPointListType, Sequence[PointType], Sequence[NPPointType]] # same as PointType but with a different name to avoid confusion NPVectorType = NPPointType VectorType = PointType # parametric curve ParamCurveFuncType = Callable[[float], NPPointType] # edge kinds as per blockMesh's definition EdgeKindType = Literal["line", "arc", "origin", "angle", "spline", "polyLine", "project", "curve"] # edges: arc: 1 point, projected: string, everything else: a list of points EdgeDataType = Union[PointType, PointListType, str] # block sides OrientType = Literal["left", "right", "front", "back", "top", "bottom"] DirectionType = Literal[0, 1, 2] # Project vertex/edge to one or multiple geometries ProjectToType = Union[str, list[str]] # A list of indexes that define a quad IndexType = list[int] # the complete guide to chopping ChopTakeType = Literal["min", "max", "avg"] # which wire of the block to take as reference length ChopPreserveType = Literal["start_size", "end_size", "c2c_expansion", "total_expansion"] # what value to keep class ChopArgs(TypedDict, total=False): """All chopping parameters""" length_ratio: float start_size: float c2c_expansion: float count: int end_size: float total_expansion: float take: ChopTakeType preserve: ChopPreserveType # what goes into blockMeshDict's block grading specification GradingSpecType = tuple[float, int, float] # Used by autograders CellSizeType = Optional[float] # Geometry definition GeometryType = dict[str, list[str]] ================================================ FILE: src/classy_blocks/construct/__init__.py ================================================ ================================================ FILE: src/classy_blocks/construct/assemblies/assembly.py ================================================ import abc from collections.abc import Sequence import numpy as np from classy_blocks.base.element import ElementBase from classy_blocks.construct.operations.operation import Operation from classy_blocks.construct.shape import Shape class Assembly(ElementBase, abc.ABC): def __init__(self, shapes: Sequence[Shape]): self.shapes = shapes @property def parts(self): return self.shapes @property def center(self): return np.average([shape.center for shape in self.shapes]) @property def operations(self) -> list[Operation]: operations: list[Operation] = [] for shape in self.shapes: operations += shape.operations return operations ================================================ FILE: src/classy_blocks/construct/assemblies/joints.py ================================================ import abc import numpy as np from classy_blocks.cbtyping import FloatListType, PointType from classy_blocks.construct.assemblies.assembly import Assembly from classy_blocks.construct.edges import Spline from classy_blocks.construct.flat.sketches.disk import HalfDisk from classy_blocks.construct.shape import Shape from classy_blocks.construct.shapes.cylinder import SemiCylinder from classy_blocks.util import functions as f class CuspSemiCylinder(SemiCylinder): """A cylinder with one slanted/inclined end face""" sketch_class = HalfDisk def __init__( self, axis_point_1: PointType, axis_point_2: PointType, radius_point_1: PointType, end_angle: float = np.pi / 4 ): axis_point_1 = np.asarray(axis_point_1) axis_point_2 = np.asarray(axis_point_2) axis = axis_point_2 - axis_point_1 radius_point_1 = np.asarray(radius_point_1) radius_vector = radius_point_1 - axis_point_1 shear_normal = np.cross(-axis, radius_vector) super().__init__(axis_point_1, axis_point_2, radius_point_1) for loft in self.operations: loft.top_face.remove_edges() for loft in self.shell: point_1 = loft.top_face.points[1].position point_2 = loft.top_face.points[2].position edge_points = f.divide_arc(axis_point_2, point_1, point_2, 5) loft.top_face.add_edge(1, Spline(edge_points)) for loft in self.operations: loft.top_face.shear(shear_normal, axis_point_2, -axis, end_angle) # TODO: include those inner edges (Disk.spline_ratios > Curve) # self.remove_inner_edges(end=True) class CuspCylinder(Assembly): def __init__( self, axis_point_1: PointType, axis_point_2: PointType, radius_point: PointType, end_angle_left: float, end_angle_right: float, ): axis_point_1 = np.asarray(axis_point_1) radius_point_right = np.asarray(radius_point) radius_vector_right = np.asarray(radius_point) - axis_point_1 radius_point_left = axis_point_1 - radius_vector_right self.cusp_right = CuspSemiCylinder(axis_point_1, axis_point_2, radius_point_right, end_angle_right) self.cusp_left = CuspSemiCylinder(axis_point_1, axis_point_2, radius_point_left, end_angle_left) @property def shapes(self): return [self.cusp_right, self.cusp_left] def chop_axial(self, **kwargs): self.cusp_right.chop_axial(**kwargs) def chop_radial(self, **kwargs): self.cusp_right.chop_radial(**kwargs) self.cusp_left.chop_radial(**kwargs) def chop_tangential(self, **kwargs): self.cusp_right.chop_tangential(**kwargs) self.cusp_left.chop_tangential(**kwargs) def set_outer_patch(self, patch_name: str) -> None: self.cusp_left.set_outer_patch(patch_name) self.cusp_right.set_outer_patch(patch_name) def set_start_patch(self, patch_name: str) -> None: self.cusp_left.set_start_patch(patch_name) self.cusp_right.set_start_patch(patch_name) class JointBase(Assembly, abc.ABC): def __init__(self, start_point: PointType, center_point: PointType, radius_point: PointType, branches: int = 4): start_point = np.asarray(start_point) center_point = np.asarray(center_point) radius_point = np.asarray(radius_point) self.assemblies: list[CuspCylinder] = [] shapes: list[Shape] = [] rotate_angles = self._get_angles(branches) rotation_axis = radius_point - start_point for i, angle in enumerate(rotate_angles): angle_right = (rotate_angles[(i + 1) % len(rotate_angles)] - angle) / 2 angle_left = ((angle - rotate_angles[i - 1]) % (2 * np.pi)) / 2 cylinder = CuspCylinder(start_point, center_point, radius_point, angle_left, angle_right) cylinder.rotate(angle, rotation_axis, center_point) self.assemblies.append(cylinder) shapes += cylinder.shapes super().__init__(shapes) @abc.abstractmethod def _get_angles(self, count: int) -> FloatListType: """Returns angles at which CuspCylinders must be rotated""" @property def center(self): # "center" is the start point return self.shapes[0].operations[0].top_face.points[0].position def chop_axial(self, **kwargs): for asm in self.assemblies: asm.chop_axial(**kwargs) def chop_radial(self, **kwargs): self.assemblies[0].chop_radial(**kwargs) def chop_tangential(self, **kwargs): self.assemblies[0].chop_tangential(**kwargs) self.assemblies[1].chop_tangential(**kwargs) def set_outer_patch(self, patch_name: str) -> None: for asm in self.assemblies: asm.set_outer_patch(patch_name) def set_hole_patch(self, hole: int, patch_name: str) -> None: self.assemblies[hole].set_start_patch(patch_name) class NJoint(JointBase): def _get_angles(self, count): return np.linspace(0, 2 * np.pi, num=count, endpoint=False) class TJoint(JointBase): def __init__(self, start_point: PointType, center_point: PointType, radius_point: PointType): super().__init__(start_point, center_point, radius_point) def _get_angles(self, _): return [0, np.pi / 2, 3 * np.pi / 2] class LJoint(JointBase): def __init__(self, start_point: PointType, center_point: PointType, radius_point: PointType): super().__init__(start_point, center_point, radius_point) def _get_angles(self, _): return [0, np.pi / 2] ================================================ FILE: src/classy_blocks/construct/curves/__init__.py ================================================ ================================================ FILE: src/classy_blocks/construct/curves/analytic.py ================================================ from typing import Optional import numpy as np from classy_blocks.cbtyping import NPVectorType, ParamCurveFuncType, PointType, VectorType from classy_blocks.construct.curves.curve import FunctionCurveBase from classy_blocks.construct.point import Point from classy_blocks.util import functions as f class AnalyticCurve(FunctionCurveBase): """A parametric curve, defined by a user-specified function `P = f(t)`""" def __init__(self, function: ParamCurveFuncType, bounds: tuple[float, float]): self.function = function self.bounds = bounds @property def parts(self): raise NotImplementedError("Transforming arbitrary analytic curves is currently not supported") def get_length(self, param_from: Optional[float] = None, param_to: Optional[float] = None) -> float: # simply discretize the curve and sum up the segments; # numerical integration is not reliable and can often yield totally wrong results # (like a negative length or similar) return f.polyline_length(self.discretize(param_from, param_to, count=100)) class LineCurve(AnalyticCurve): """A simple line, defined by 2 points. Parameter goes from 0 at point_1 to 1 at point_2. To extend the line beyond given points, provide custom 'bounds'.""" def __init__(self, point_1: PointType, point_2: PointType, bounds: tuple[float, float] = (0, 1)): self.point_1 = Point(point_1) self.point_2 = Point(point_2) super().__init__(lambda t: self.point_1.position + self.vector * t, bounds) @property def vector(self) -> NPVectorType: return self.point_2.position - self.point_1.position @property def parts(self): return [self.point_1, self.point_2] @property def center(self): # this one is easy return (self.point_1.position + self.point_2.position) / 2 class CircleCurve(AnalyticCurve): """A parametric circle, defined by center, starting point and normal. A full circle is valid by default. Provide custom bounds to clip this curve to an arc.""" def __init__( self, origin: PointType, rim: PointType, normal: VectorType, bounds: tuple[float, float] = (0, 2 * np.pi) ): self.origin = Point(origin) self.rim = Point(rim) # normal is a unit vector and is not transformed the same # as points. To keep things simple, use (and transform) 3 points # and calculate normal on-the-go normal = f.unit_vector(normal) self.atop = Point(origin + normal) super().__init__(lambda t: f.rotate(self.rim.position, t, self.normal, self.origin.position), bounds) @property def normal(self) -> NPVectorType: return self.atop.position - self.origin.position @property def center(self): return self.origin.position @property def parts(self): return [self.origin, self.rim, self.atop] ================================================ FILE: src/classy_blocks/construct/curves/curve.py ================================================ import abc import warnings from typing import Optional, Union import numpy as np import scipy.optimize from classy_blocks.base.element import ElementBase from classy_blocks.cbtyping import NPPointListType, NPPointType, NPVectorType, ParamCurveFuncType, PointType from classy_blocks.construct.series import Series from classy_blocks.util import functions as f from classy_blocks.util.constants import TOL class CurveBase(ElementBase): """A parametric/analytic/interpolated curve in 3D space: = f(t)""" bounds: tuple[float, float] def _check_param(self, param: Union[int, float]) -> Union[int, float]: """Checks that the passed parameter is legit for the given set of points""" if not (self.bounds[0] <= param <= self.bounds[1]): raise ValueError(f"Invalid parameter {param} (0...{self.bounds[1]})") return param def _get_params(self, param_from: Optional[float] = None, param_to: Optional[float] = None) -> tuple[float, float]: """Always take lower/upper bound if params are not supplied""" if param_from is None: param_from = self.bounds[0] if param_to is None: param_to = self.bounds[1] self._check_param(param_from) self._check_param(param_to) return param_from, param_to @abc.abstractmethod def get_point(self, param: float) -> NPPointType: """Returns point at given parameter""" @abc.abstractmethod def discretize( self, param_from: Optional[float] = None, param_to: Optional[float] = None, count: int = 10 ) -> NPPointListType: """Discretizes this curve into 'count' points. Optionally, use the curve between passed parameters; default 'count' is chosen as a sane default for a blockMesh edge.""" @abc.abstractmethod def get_length(self, param_from: Optional[float] = None, param_to: Optional[float] = None) -> float: """Returns the length of the curve between the given parameters; bounds are used if they are not supplied.""" @property def length(self) -> float: """Returns full length of the curve between provided bounds""" return self.get_length(self.bounds[0], self.bounds[1]) @abc.abstractmethod def get_closest_param(self, point: PointType) -> float: """Finds the parameter on curve where point is the closest to given point.""" def get_closest_point(self, point: PointType) -> NPPointType: # TODO: test return self.get_point(self.get_closest_param(point)) def get_param_at_length(self, length: float) -> float: """Returns parameter at specified length along the curve""" return scipy.optimize.brentq(lambda p: self.get_length(0, p) - length, self.bounds[0], self.bounds[1]) def _diff(self, param: float, order: int, delta: float = TOL) -> NPVectorType: params = np.linspace(param - order * delta / 2, param + order * delta / 2, num=order + 1) if params[0] < self.bounds[0]: params += self.bounds[0] - params[0] if params[-1] > self.bounds[1]: params -= params[-1] - self.bounds[1] points = np.array([self.get_point(p) for p in params]) return np.diff(points, n=order, axis=0)[0] def get_tangent(self, param: float, delta: float = TOL) -> NPVectorType: """Returns an approximate, normalized tangent to the curve at given parameter""" return f.unit_vector(self._diff(param, 1, delta)) def get_normal(self, param: float, delta: float = TOL) -> NPVectorType: """Returns an approximated normal vector at given parameter""" # Using Frenet-Serret formula https://en.wikipedia.org/wiki/Curvature return f.unit_vector(self._diff(param, 2, delta)) def get_binormal(self, param: float, delta: float = TOL) -> NPVectorType: """Returns the binormal vector from Frenet-Serret TNB frame (https://en.wikipedia.org/wiki/Frenet%E2%80%93Serret_formulas)""" return f.unit_vector(np.cross(self.get_tangent(param, delta), self.get_normal(param, delta))) class PointCurveBase(CurveBase): """A base object for curves, defined by a list of points""" series: Series def _check_param(self, param): return int(super()._check_param(param)) @property def center(self): warnings.warn("Using an approximate default curve center (average)!", stacklevel=2) return np.average(self.discretize(), axis=0) class FunctionCurveBase(PointCurveBase): """A base object for curves, driven by functions""" function: ParamCurveFuncType def discretize( self, param_from: Optional[float] = None, param_to: Optional[float] = None, count: int = 15 ) -> NPPointListType: """Discretized the curve into 'count' points.""" param_from, param_to = self._get_params(param_from, param_to) params = np.linspace(param_from, param_to, num=count) return np.array([self.function(t) for t in params]) def get_closest_param(self, point: PointType) -> float: """Finds the param on curve where point is the closest to given point""" # because curves can have all sorts of shapes, find # initial guess by checking distance to discretized points point = np.array(point) all_points = self.discretize() distances = np.linalg.norm(all_points.T - point[:, None], axis=0) params = np.linspace(self.bounds[0], self.bounds[1], num=len(distances)) i_smallest = int(np.argmin(distances)) i_prev = max(i_smallest - 1, 0) i_next = min(i_smallest + 1, len(distances) - 1) param_start = params[i_prev] param_end = params[i_next] result = scipy.optimize.minimize_scalar( lambda t: f.norm(self.get_point(t) - point), bounds=(param_start, param_end), ) return result.x def get_point(self, param: float) -> NPPointType: self._check_param(param) return self.function(param) ================================================ FILE: src/classy_blocks/construct/curves/discrete.py ================================================ import warnings from typing import Optional import numpy as np from classy_blocks.cbtyping import NPPointListType, NPPointType, PointListType, PointType from classy_blocks.construct.curves.curve import PointCurveBase from classy_blocks.construct.series import Series from classy_blocks.util import functions as f class DiscreteCurve(PointCurveBase): """A curve, defined by a set of points; All operations on this curve involve only the specified points with no interpolation (contrary to *InterpolatedCurves where values between points are interpolated). Parameter is actually an index to a given point; Discretization yields the original points; Length just sums the distances between points.""" def __init__(self, points: PointListType): self.series = Series(points) self.bounds = (0, len(self.series) - 1) def discretize( self, param_from: Optional[float] = None, param_to: Optional[float] = None, _count: int = 0 ) -> NPPointListType: """Discretizes this curve into points. With DiscreteCurve, parameter 'count' is ignored as points are taken directly.""" param_from, param_to = self._get_params(param_from, param_to) param_start = int(min(param_from, param_to)) param_end = int(max(param_from, param_to)) discretized = self.series[param_start : param_end + 1] if param_from > param_to: return np.flip(discretized, axis=0) return discretized def get_length(self, param_from: Optional[float] = None, param_to: Optional[float] = None) -> float: """Returns the length of this curve between specified params.""" return f.polyline_length(self.discretize(param_from, param_to)) def get_closest_param(self, point: PointType) -> float: """Returns the index of point on this curve where distance to supplied point is the smallest.""" point = np.array(point) all_points = self.discretize() distances = np.linalg.norm(all_points.T - point[:, None], axis=0) params = np.linspace(self.bounds[0], self.bounds[1], num=len(distances)) i_distance = np.argmin(distances) return params[i_distance] @property def center(self): warnings.warn("Using an approximate default curve center (average)!", stacklevel=2) return np.average(self.discretize(), axis=0) def get_point(self, param: float) -> NPPointType: param = self._check_param(param) index = int(param) return self.series[index] @property def parts(self): return [self.series] ================================================ FILE: src/classy_blocks/construct/curves/interpolated.py ================================================ import abc from typing import Optional import numpy as np from classy_blocks.cbtyping import PointListType from classy_blocks.construct.curves.curve import FunctionCurveBase from classy_blocks.construct.curves.interpolators import InterpolatorBase, LinearInterpolator, SplineInterpolator from classy_blocks.construct.series import Series from classy_blocks.util import functions as f class InterpolatedCurveBase(FunctionCurveBase, abc.ABC): """A curve, obtained by interpolation between provided points; Unlike DiscreteCurve, all values between points are accessible by providing appropriate parameter. The parameter is similar to DiscreteCurve's, like an index to the nearest point but here all non-integer values in between are available too. An interpolation function is build from provided points. Length, discretization, center and other calculated properties are based on that function rather than specified points.""" _interpolator: type[InterpolatorBase] def __init__(self, points: PointListType, extrapolate: bool = False, equalize: bool = True): self.series = Series(points) self.function = self._interpolator(self.series, extrapolate, equalize) self.bounds = (0, 1) @property def segments(self) -> int: """Returns number of points this curve was created from""" return len(self.series) - 1 @property def parts(self): # This is called when a transform of any kind is requested on # this class; that means the interpolation function # is no longer valid and needs to be rebuilt self.function.invalidate() return [self.series] def get_length(self, param_from: Optional[float] = None, param_to: Optional[float] = None) -> float: """Returns the length of this curve by summing distance between points. The 'count' parameter is ignored as the original points are taken.""" param_from, param_to = self._get_params(param_from, param_to) index_from = int(param_from * self.segments) + 1 index_to = int(param_to * self.segments) if index_from < index_to: indexes = list(range(index_from, index_to + 1)) else: indexes = [] params = [param_from, *[i / self.segments for i in indexes[:-1]], param_to] return f.polyline_length(np.array([self.function(t) for t in params])) class LinearInterpolatedCurve(InterpolatedCurveBase): _interpolator = LinearInterpolator class SplineInterpolatedCurve(InterpolatedCurveBase): _interpolator = SplineInterpolator ================================================ FILE: src/classy_blocks/construct/curves/interpolators.py ================================================ import abc import numpy as np import scipy.interpolate from classy_blocks.cbtyping import FloatListType, NPPointType, ParamCurveFuncType from classy_blocks.construct.series import Series class InterpolatorBase(abc.ABC): """A easy wrapper around all the complicated options of bunches of scipy's interpolation routines. Also provides caching of built functions unless a transformation has been made (a.k.a. invalidate()) has been called.""" @abc.abstractmethod def _get_function(self) -> ParamCurveFuncType: """Returns an interpolation function from stored points""" def __init__(self, points: Series, extrapolate: bool, equalize: bool = True): self.points = points self.extrapolate = extrapolate self.equalize = equalize self.function = self._get_function() self._valid = True def __call__(self, param: float) -> NPPointType: if not self._valid: self.function = self._get_function() self._valid = True return self.function(param) def invalidate(self) -> None: self._valid = False @property def params(self) -> FloatListType: """A list of parameters for the interpolation curve. If not equalized, it's just linearly spaced floats; if equalized, scaled distances between provided points are taken so that evenly spaced parameters will produce evenly spaced points even if interpolation points are unequally spaced.""" if self.equalize: lengths = np.cumsum(np.sqrt(np.sum((self.points[:-1] - self.points[1:]) ** 2, axis=1))) return np.concatenate(([0], lengths / lengths[-1])) return np.linspace(0, 1, num=len(self.points)) class LinearInterpolator(InterpolatorBase): def _get_function(self): if self.extrapolate: bounds_error = False fill_value = "extrapolate" else: bounds_error = True fill_value = np.nan function = scipy.interpolate.interp1d( self.params, self.points.points, bounds_error=bounds_error, fill_value=fill_value, # type: ignore axis=0, # type: ignore ) return lambda param: function(param) class SplineInterpolator(InterpolatorBase): def _get_function(self): spline = scipy.interpolate.make_interp_spline(self.params, self.points.points, check_finite=False) return lambda t: spline(t, extrapolate=self.extrapolate) ================================================ FILE: src/classy_blocks/construct/edges.py ================================================ import warnings from classy_blocks.base.element import ElementBase from classy_blocks.base.exceptions import EdgeCreationError from classy_blocks.cbtyping import EdgeKindType, NPPointListType, PointListType, PointType, ProjectToType, VectorType from classy_blocks.construct.curves.curve import CurveBase from classy_blocks.construct.curves.discrete import DiscreteCurve from classy_blocks.construct.point import Point, Vector from classy_blocks.util import functions as f class EdgeData(ElementBase): """Common operations on classes for edge creation""" kind: EdgeKindType @property def parts(self): return [] @property def center(self): warnings.warn("Transforming edge with a default center (0 0 0)!", stacklevel=2) return f.vector(0, 0, 0) @property def representation(self) -> EdgeKindType: # what goes into blockMeshDict's edge definition return self.kind class Line(EdgeData): """A 'line' edge is created by default and needs no extra parameters""" kind = "line" class Arc(EdgeData): """Parameters for an arc edge: classic OpenFOAM circular arc definition with a single point lying anywhere on the arc""" kind = "arc" def __init__(self, arc_point: PointType): self.point = Point(arc_point) @property def parts(self): return [self.point] class Origin(EdgeData): """Parameters for an arc edge, alternative ESI-CFD version; defined with an origin point and optional flatness (default 1) https://www.openfoam.com/news/main-news/openfoam-v20-12/pre-processing#x3-22000 https://develop.openfoam.com/Development/openfoam/-/blob/master/src/mesh/blockMesh/blockEdges/arcEdge/arcEdge.H All arc variants are supported by classy_blocks; however, only the first (classic) one will be written to blockMeshDict for compatibility. If an edge was specified by 'angle' or 'origin', the definition will be output as a comment next to that edge definition.""" kind = "origin" def __init__(self, origin: PointType, flatness: float = 1): self.origin = Point(origin) self.flatness = flatness @property def parts(self): return [self.origin] class Angle(EdgeData): """Parameters for an arc edge, alternative definition by Foundation (.org); defined with sector angle and axis https://github.com/OpenFOAM/OpenFOAM-10/commit/73d253c34b3e184802efb316f996f244cc795ec6 All arc variants are supported by classy_blocks; however, only the first (classic) one will be written to blockMeshDict for compatibility. If an edge was specified by 'angle' or 'origin', the definition will be output as a comment next to that edge definition.""" kind = "angle" def __init__(self, angle: float, axis: VectorType): self.angle = angle self.axis = Vector(f.unit_vector(axis)) def translate(self, displacement): """Axis is not to be translated""" def scale(self, ratio, origin=None): """Axis is not to be scaled""" @property def parts(self): return [self.axis] class Project(EdgeData): """Parameters for a 'project' edge""" kind = "project" def __init__(self, label: ProjectToType): self.label = self.convert_label(label) self.check_length() @staticmethod def convert_label(label: ProjectToType) -> list[str]: """Makes sure label is always a list of strings of length 1 or 2""" if isinstance(label, str): return [label] # sort to keep consistent for debugging and testing purposes return list(sorted(label)) def check_length(self) -> None: """Raises an exception if there are too many surfaces to project to""" if not (0 < len(self.label) < 3): raise EdgeCreationError(f"Edges can only be projected to 1 or 2 surfaces: {self.label}") def add_label(self, label: ProjectToType) -> None: """Projects this edge to another surface""" new_labels = self.convert_label(label) for add_label in new_labels: if add_label not in self.label: self.label.append(add_label) self.label.sort() self.check_length() class OnCurve(EdgeData): """An edge, snapped to a parametric curve""" kind: EdgeKindType = "curve" def __init__(self, curve: CurveBase, n_points: int = 10, representation: EdgeKindType = "spline"): self.curve = curve self.n_points = n_points self._repr: EdgeKindType = representation @property def parts(self): return [self.curve] @property def center(self): return self.curve.center @property def representation(self) -> EdgeKindType: return self._repr def discretize(self, param_from: float, param_to: float) -> NPPointListType: return self.curve.discretize(param_from, param_to, self.n_points + 2) class Spline(OnCurve): """Parameters for a spline edge""" kind: EdgeKindType = "spline" def __init__(self, points: PointListType): curve = DiscreteCurve(points) super().__init__(curve, n_points=len(points), representation=self.kind) @property def parts(self): return [self.curve] @property def center(self): return self.curve.center @property def representation(self) -> EdgeKindType: return self.kind class PolyLine(Spline): """Parameters for a polyLine edge""" # a bug? (https://github.com/python/mypy/issues/8796) kind: EdgeKindType = "polyLine" @property def representation(self) -> EdgeKindType: return self.kind ================================================ FILE: src/classy_blocks/construct/flat/__init__.py ================================================ ================================================ FILE: src/classy_blocks/construct/flat/face.py ================================================ import collections import copy from typing import Optional, Union import numpy as np from classy_blocks.base.element import ElementBase from classy_blocks.base.exceptions import FaceCreationError from classy_blocks.cbtyping import NPPointListType, NPPointType, NPVectorType, PointListType, PointType, ProjectToType from classy_blocks.construct.edges import EdgeData, Line, Project from classy_blocks.construct.point import Point from classy_blocks.util import constants from classy_blocks.util import functions as f class Face(ElementBase): """A collection of 4 Vertices and optionally 4 Edges, creating an arbitrary quadrangle. Args: - points: a list or a numpy array of exactly 4 points in 3d space - edges: an optional list of data for edge creation; if provided, it must be have exactly 4 elements, each element a list of data for edge creation; the format is the same as passed to Block.add_edge(). Each element of the list represents an edge between its corner and the next, for instance: edges=[None, Arc([0.4, 1, 1]]), None, None] will create an arc edge between the 1st and the 2nd vertex edges=[Project(['terrain']*4) will project all 4 edges of this face: 0-1, 1-2, 2-3, 3-0.""" def __init__( self, points: PointListType, edges: Optional[list[Optional[EdgeData]]] = None, check_coplanar: bool = False ): # Points points = np.asarray(points, dtype=constants.DTYPE) points_shape = np.shape(points) if points_shape != (4, 3): raise FaceCreationError( "Provide exactly 4 points in 3D space", f"Available {points_shape[0]} points, each with {points_shape[1]} coordinates", ) self.points = [Point(p) for p in points] # Edges self.edges: list[EdgeData] = [Line(), Line(), Line(), Line()] if edges is not None: if len(edges) != 4: raise FaceCreationError( "Provide exactly 4 edges; use None for straight lines", f"Number of edges: {len(edges)}" ) for i, edge in enumerate(edges): self.add_edge(i, edge) if check_coplanar: pts = self.point_array diff = abs(np.dot((pts[1] - pts[0]), np.cross(pts[3] - pts[0], pts[2] - pts[0]))) if diff > constants.TOL: raise FaceCreationError( "FacePoints are not coplanar!", f"Difference: {diff}, tolerance: {constants.TOL}" ) # name of geometry this face can be projected to self.projected_to: Optional[str] = None # patch name to which this face can belong self.patch_name: Optional[str] = None def update(self, points: PointListType) -> None: """Moves points from current position to given""" for i, point in enumerate(points): self.points[i].move_to(point) def add_edge(self, corner: int, edge_data: Union[EdgeData, None]) -> None: """Replaces an existing edge between corner and (corner+1); use None to delete an edge (replace with a straight line)""" if corner > 3: raise FaceCreationError("Provide a corner index between 0 and 3", f"Given corner index: {corner}") if edge_data is None: self.edges[corner] = Line() else: self.edges[corner] = edge_data def remove_edges(self, corners: Optional[list[int]] = None) -> None: """Removes edges (replaces with Lines) from given corners (edges -). If no corners are provided, all are cleared.""" if corners is None: corners = list(range(4)) for corner in corners: self.edges[corner] = Line() def project_edge(self, corner: int, label: ProjectToType) -> None: """Adds a Project edge or add the label to an existing one""" edge = self.edges[corner] if isinstance(edge, Project): edge.add_label(label) return self.add_edge(corner, Project(label)) def invert(self) -> "Face": """Reverses the order of points in this face.""" self.points.reverse() self.edges.reverse() self.edges = [self.edges[i] for i in (1, 2, 3, 0)] return self def copy(self) -> "Face": """Returns a copy of this Face""" return copy.deepcopy(self) @property def point_array(self) -> NPPointListType: """A numpy array of this face's points""" return np.array([p.position for p in self.points]) @property def center(self) -> NPPointType: """Center point of this face""" return np.average(self.point_array, axis=0) @property def normal(self) -> NPVectorType: """Returns a vector normal to this face. For non-planar faces the same rule as in OpenFOAM is followed: divide a quadrangle into 4 triangles, each joining at face center; a normal is the average of normals of those triangles.""" points = self.point_array center = self.center side_1 = points - center side_2 = np.roll(points, -1, axis=0) - center normals = np.cross(side_1, side_2) return f.unit_vector(np.average(normals, axis=0)) @property def parts(self): return self.points + self.edges def project(self, label: str, edges: bool = False, points: bool = False) -> None: """Project this face to given geometry; faces can only be projected to a single surface, therefore provide a single string (contrary to Edge/Vertex where 2 or even 3 surfaces can be intersected and projected to). Use edges=True and points=True as a shortcut to also project face's edges and points to the same geometry. If you want more control (like projecting an edge to an intersection of two surfaces), use face.edges[0] = edges.Project(['label1', 'label2']). Geometry with provided label must be defined separately in Mesh object.""" self.projected_to = label if edges: for i in range(4): self.project_edge(i, label) if points: for i in range(4): self.points[i].project(label) def shift(self, count: int) -> "Face": """Shifts points of this face by 'count', changing its starting point""" indexes = collections.deque(range(4)) indexes.rotate(count) self.points = [self.points[i] for i in indexes] self.edges = [self.edges[i] for i in indexes] return self def reorient(self, start_near: PointType) -> "Face": """Shifts points of this face in circle so that the starting point is closest to given position; the normal is not affected.""" position = np.array(start_near, dtype=constants.DTYPE) indexes = list(range(4)) indexes.sort(key=lambda i: f.norm(position - self.points[i].position)) self.shift(indexes[0]) return self ================================================ FILE: src/classy_blocks/construct/flat/sketch.py ================================================ import abc import copy from typing import ClassVar, TypeVar from classy_blocks.base.element import ElementBase from classy_blocks.cbtyping import NPPointType, NPVectorType from classy_blocks.construct.flat.face import Face SketchT = TypeVar("SketchT", bound="Sketch") class Sketch(ElementBase): """A collection of Faces that form the basis of a 3D Shape.""" # indexes of faces that are to be chopped (within a Shape) # for axis 0 and axis 1; axis 2 is the 3rd dimension chops: ClassVar[list[list[int]]] = [] @property @abc.abstractmethod def faces(self) -> list[Face]: """Faces that form this sketch""" @property @abc.abstractmethod def grid(self) -> list[list[Face]]: """A 2-dimensional list of faces that form this sketch; addressed as x-y for cartesian sketches and as radius-angle for radial sketches. For instance, a 2x3 cartesian grid will obviously contain 2 lists, each of 3 faces but a disk (cylinder) grid will contain 2 lists, first with 4 core faces and the other with 8 outer faces. A simple Annulus sketch will only contain one list with all the faces.""" def copy(self: SketchT) -> SketchT: """Returns a copy of this sketch""" return copy.deepcopy(self) @property @abc.abstractmethod def center(self) -> NPPointType: """Center of this sketch""" @property def normal(self) -> NPVectorType: """Normal of this sketch""" return self.faces[0].normal @property def parts(self): return self.faces ================================================ FILE: src/classy_blocks/construct/flat/sketches/__init__.py ================================================ ================================================ FILE: src/classy_blocks/construct/flat/sketches/annulus.py ================================================ import numpy as np from classy_blocks.base.exceptions import AnnulusCreationError from classy_blocks.cbtyping import NPPointType, PointType, VectorType from classy_blocks.construct.edges import Origin from classy_blocks.construct.flat.face import Face from classy_blocks.construct.flat.sketch import Sketch from classy_blocks.util import functions as f from classy_blocks.util.constants import TOL class Annulus(Sketch): """A base for ring-like shapes; In real-life, Annulus and Ring are the same 2D objects. Here, however, Annulus is a 2D collection of faces whereas Ring is an annulus that has been extruded to 3D.""" def __init__( self, center_point: PointType, outer_radius_point: PointType, normal: VectorType, inner_radius: float, n_segments: int = 8, angle: float = 2 * np.pi, ): center_point = np.asarray(center_point) normal = f.unit_vector(np.asarray(normal)) outer_radius_point = np.asarray(outer_radius_point) inner_radius_point = center_point + f.unit_vector(outer_radius_point - center_point) * inner_radius segment_angle = angle / n_segments face = Face( [ # points inner_radius_point, outer_radius_point, f.rotate(outer_radius_point, segment_angle, normal, center_point), f.rotate(inner_radius_point, segment_angle, normal, center_point), ], [None, Origin(center_point), None, Origin(center_point)], # edges check_coplanar=True, ) self.core: list[Face] = [] self.shell = [face.copy().rotate(i * segment_angle, normal, center_point) for i in range(n_segments)] if self.inner_radius > self.outer_radius: raise AnnulusCreationError( "Outer ring radius must be larger than inner!", f"Inner radius: {self.inner_radius}, Outer radius: {self.outer_radius}", ) diff = abs(np.dot(normal, outer_radius_point - center_point)) if diff > TOL: raise AnnulusCreationError( "Normal and radius are not perpendicular!", f"Difference: {diff}, tolerance: {TOL}" ) @property def faces(self) -> list[Face]: return self.shell @property def grid(self): return [self.shell] @property def center(self) -> NPPointType: """Return center of sketch by assuming radial sides of faces intersect in the center""" return np.average( [ p[0] - f.norm(np.cross(p[2] - p[3], p[3] - p[0])) / f.norm(np.cross(p[2] - p[3], p[1] - p[0])) * (p[1] - p[0]) for p in (face.point_array for face in self.faces) ], axis=0, ) @property def n_segments(self): return len(self.faces) # FIXME: do something with inner_/outer/_point/_vector confusion @property def outer_radius_point(self) -> NPPointType: """Point at outer radius, 0-th segment""" return self.faces[0].point_array[1] @property def outer_radius(self) -> float: """Outer radius""" return f.norm(self.outer_radius_point - self.center) @property def radius(self) -> float: """Outer radius""" return self.outer_radius @property def radius_point(self) -> NPPointType: """See self.outer_radius_point""" return self.outer_radius_point @property def inner_radius_point(self) -> NPPointType: """Point at inner radius, 0-th segment""" return self.faces[0].point_array[0] @property def inner_radius(self) -> float: """Returns inner radius as length, that is, distance between center and inner radius point""" return f.norm(self.inner_radius_point - self.center) ================================================ FILE: src/classy_blocks/construct/flat/sketches/disk.py ================================================ import abc from typing import ClassVar, Optional import numpy as np from classy_blocks.cbtyping import ( IndexType, NPPointListType, NPPointType, NPVectorType, PointListType, PointType, VectorType, ) from classy_blocks.construct.edges import Origin, Spline from classy_blocks.construct.flat.face import Face from classy_blocks.construct.flat.sketches.mapped import MappedSketch from classy_blocks.construct.point import Point from classy_blocks.util import functions as f class FanPattern: """A helper class for calculation of cylinder points""" def __init__(self, center_point: PointType, radius_point: PointType, normal: VectorType): self.center_point = np.asarray(center_point) self.normal = f.unit_vector(np.asarray(normal)) self.radius_point = np.asarray(radius_point) self.radius_vector = self.radius_point - self.center_point def get_outer_points(self, angles) -> NPPointListType: return np.array([f.rotate(self.radius_point, a, self.normal, self.center_point) for a in angles]) def get_inner_points(self, angles, ratios: list[float]) -> NPPointListType: """Inner points are scaled back by defined ratios that repeat over the circumference""" points = self.get_outer_points(angles) for i, point in enumerate(points): ratio = ratios[i % len(ratios)] points[i] = self.center_point + (point - self.center_point) * ratio return points class DiskBase(MappedSketch, abc.ABC): # Ratios between core and outer points: # Relative size of lines 0-1 and 0-4 (in QuarterDisk, others analogously): # Just the right value will yield the lowest non-orthogonality and skewness; # determined empirically core_ratio = 0.8 # Spline points for optimized round meshes # As ratio of radius spline_ratios = ( 0.112269, 0.222463, 0.326692, 0.421177, 0.502076, 0.566526, 0.610532, 0.610535, 0.625913, 0.652300, 0.686516, 0.724996, 0.762587, 0.792025, ) def __init__(self, positions: PointListType, quads: list[IndexType]): # Center point as a constant. self.origo_point = Point(positions[0]) super().__init__(positions, quads) @property def origo(self): return self.origo_point.position # Relative size of the inner square (O-1-2-3) in a single core cylinder: # - too small will cause unnecessary high number of small cells in the square; # - too large will prevent creating large numbers of boundary layers @property def diagonal_ratio(self) -> float: return 2**0.5 * self.spline_ratios[7] / 0.8 * self.core_ratio def circular_core_spline( self, p_core_ratio: PointType, p_diagonal_ratio: PointType, reverse: bool = False, center: Optional[PointType] = None, ) -> NPPointListType: """Creates the spline points for the core.""" p_0 = np.asarray(p_core_ratio) p_1 = np.asarray(p_diagonal_ratio) if center is None: center = self.center # Spline points in unitary coordinates spline_points_u = np.array([self.spline_ratios[-1:6:-1]]).T * np.array([0, 1, 0]) + np.array( [self.spline_ratios[:7]] ).T * np.array([0, 0, 1]) # p_1 and p_2 in unitary coordinates p_0_u = np.array([0, 0.8, 0]) p_1_u = np.array([0, 6.10535e-01, 6.10535e-01]) # orthogonal vectors based on p_0_u and p_1_u u_0_org = p_0_u u_1_org = p_1_u - np.dot(p_1_u, f.unit_vector(u_0_org)) * f.unit_vector(u_0_org) # Spline points in u_0_org and u_1_org spline_d_0_org = np.dot(spline_points_u, f.unit_vector(u_0_org)).reshape((-1, 1)) / f.norm(u_0_org) spline_d_1_org = np.dot(spline_points_u, f.unit_vector(u_1_org)).reshape((-1, 1)) / f.norm(u_1_org) # New plane defined by new points u_0 = p_0 - center u_1 = p_1 - center - np.dot(p_1 - center, f.unit_vector(u_0)) * f.unit_vector(u_0) spline_points_new = center + spline_d_0_org * u_0 + spline_d_1_org * u_1 if reverse: return spline_points_new[::-1] else: return spline_points_new def add_core_spline_edges(self) -> None: """Add a spline to the core blocks for an optimized mesh.""" for i, face in enumerate(self.core): p_0 = face.point_array[(i + 1) % 4] # Core point on radius vector p_1 = face.point_array[(i + 2) % 4] # Core point on diagonal p_2 = face.point_array[(i + 3) % 4] # Core point on perpendicular radius vector spline_curve_0_1 = Spline(self.circular_core_spline(p_0, p_1, reverse=i == 2)) spline_curve_1_2 = Spline(self.circular_core_spline(p_2, p_1, reverse=i != 1)) # Add curves to edges edge_1 = (i + 1) % 4 edge_2 = (i + 2) % 4 face.add_edge(edge_1, spline_curve_0_1) face.add_edge(edge_2, spline_curve_1_2) def add_edges(self): for face in self.shell: face.add_edge(1, Origin(self.origo)) self.add_core_spline_edges() @property def center(self) -> NPPointType: """Center point of this sketch""" return self.faces[0].points[0].position @property def radius_point(self) -> NPPointType: """Point at outer radius""" return self.shell[0].points[1].position @property def radius_vector(self) -> NPVectorType: """Vector that points from center of this *Circle to its (first) radius point Origo is used instead of center to ensure outside is constant, when moving the core, does not change the outer shape.""" return self.radius_point - self.origo @property def radius(self) -> float: """Radius of this *circle, length of self.radius_vector""" return float(f.norm(self.radius_vector)) @property def n_segments(self): return len(self.grid[1]) @property def core(self) -> list[Face]: return self.grid[0] @property def shell(self) -> list[Face]: return self.grid[-1] @property def parts(self): return [self.origo_point, *super().parts] class OneCoreDisk(DiskBase): """A disk with a single block in the center and four blocks around; see docs/sketches for point numbers and faces/grid indexing.""" chops: ClassVar = [ [1], # axis 0 [1, 2], # axis 1 ] def __init__(self, center_point: PointType, radius_point: PointType, normal: VectorType): quad_map = [ # core [0, 1, 2, 3], # shell [0, 4, 5, 1], [1, 5, 6, 2], [2, 6, 7, 3], [3, 7, 4, 0], ] pattern = FanPattern(center_point, radius_point, normal) ratios = [self.diagonal_ratio] angles = np.linspace(0, 2 * np.pi, num=4, endpoint=False) super().__init__([*pattern.get_inner_points(angles, ratios), *pattern.get_outer_points(angles)], quad_map) # correct origo_point as it is not the same as with FourCoreDisk-based sketches self.origo_point = Point(center_point) self.add_edges() @property def center(self): return self.faces[0].center @property def grid(self): return [self.faces[:1], self.faces[1:]] def add_core_spline_edges(self): pass class QuarterDisk(DiskBase): """A quarter of a four-core disk; see docs/sketches for point numbers and faces/grid indexing""" chops: ClassVar = [ [1], # axis 0 [1, 2], # axis 1 ] def __init__(self, center_point: PointType, radius_point: PointType, normal: VectorType): quad_map = [ # core [0, 1, 2, 3], # shell [1, 4, 5, 2], [2, 5, 6, 3], ] pattern = FanPattern(center_point, radius_point, normal) ratios = [self.core_ratio, self.diagonal_ratio] angles = np.linspace(0, np.pi / 2, num=3) super().__init__( [center_point, *pattern.get_inner_points(angles, ratios), *pattern.get_outer_points(angles)], quad_map ) @property def grid(self): return [[self.faces[0]], self.faces[1:]] class HalfDisk(DiskBase): """One half of a four-core disk""" chops: ClassVar = [ [2], # axis 0 [2, 3, 4], # axis 1 ] def __init__(self, center_point: PointType, radius_point: PointType, normal: VectorType): quad_map = [ # core [0, 1, 2, 3], [5, 0, 3, 4], # shell [1, 6, 7, 2], [2, 7, 8, 3], [3, 8, 9, 4], [4, 9, 10, 5], ] pattern = FanPattern(center_point, radius_point, normal) ratios = [self.core_ratio, self.diagonal_ratio] angles = np.linspace(0, np.pi, num=5) DiskBase.__init__( self, [center_point, *pattern.get_inner_points(angles, ratios), *pattern.get_outer_points(angles)], quad_map ) @property def grid(self): return [self.faces[:2], self.faces[2:]] class FourCoreDisk(DiskBase): """A disk with four quads in the core and 8 in shell; the most versatile base for round objects.""" chops: ClassVar = [[4], [4, 5, 6, 8]] def __init__(self, center_point: PointType, radius_point: PointType, normal: VectorType): quad_map = [ # core [0, 1, 2, 3], [5, 0, 3, 4], [6, 7, 0, 5], [7, 8, 1, 0], # shell [1, 9, 10, 2], [2, 10, 11, 3], [3, 11, 12, 4], [4, 12, 13, 5], [5, 13, 14, 6], [6, 14, 15, 7], [7, 15, 16, 8], [8, 16, 9, 1], ] pattern = FanPattern(center_point, radius_point, normal) ratios = [self.core_ratio, self.diagonal_ratio] angles = np.linspace(0, 2 * np.pi, num=8, endpoint=False) DiskBase.__init__( self, [center_point, *pattern.get_inner_points(angles, ratios), *pattern.get_outer_points(angles)], quad_map ) @property def grid(self): return [self.faces[:4], self.faces[4:]] Disk = FourCoreDisk class WrappedDisk(DiskBase): """A OneCoreDisk but with four additional blocks surrounding it, making the sketch a square""" chops: ClassVar = [ [1, 5], [1, 2, 3, 4], ] def __init__(self, center_point: PointType, corner_point: PointType, radius: float, normal: VectorType): # TODO: make pattern a property, ready to be adjusted by subclasses pattern = FanPattern(center_point, corner_point, normal) angles = np.linspace(0, 2 * np.pi, num=4, endpoint=False) radius_ratio = radius / f.norm(pattern.radius_vector) square_ratio = self.diagonal_ratio * radius_ratio square_points = pattern.get_inner_points(angles, [square_ratio]) arc_points = pattern.get_inner_points(angles, [radius_ratio]) outer_points = pattern.get_outer_points(angles) quad_map = [ # core [0, 1, 2, 3], # shell [0, 4, 5, 1], [1, 5, 6, 2], [2, 6, 7, 3], [3, 7, 4, 0], # with added outer quads [4, 8, 9, 5], [5, 9, 10, 6], [6, 10, 11, 7], [7, 11, 8, 4], ] super().__init__([*square_points, *arc_points, *outer_points], quad_map) self.origo_point = Point(center_point) self.add_edges() @property def grid(self): return [[self.faces[0]], self.faces[1:5], self.faces[5:]] def add_edges(self): for face in self.grid[1]: face.add_edge(1, Origin(self.center)) @property def center(self): return self.origo_point.position class Oval(DiskBase): chops: ClassVar = [ [6], [6, 7, 8, 10, 11], ] def __init__(self, center_point_1: PointType, center_point_2: PointType, normal: VectorType, radius: float): quad_map = [ # the core [0, 1, 2, 3], # 0 [5, 0, 3, 4], # 1 [7, 6, 0, 5], # 2 [8, 9, 6, 7], # 3 [9, 10, 11, 6], # 4 [6, 11, 1, 0], # 5 # the shell [1, 12, 13, 2], # 6 [2, 13, 14, 3], # 7 [3, 14, 15, 4], # 8 [4, 15, 16, 5], # 9 [5, 16, 17, 7], # 10 [7, 17, 18, 8], # 11 [8, 18, 19, 9], # 12 [9, 19, 20, 10], # 13 [10, 20, 21, 11], # 14 [11, 21, 12, 1], # 15 ] center_point_1 = np.array(center_point_1) center_point_2 = np.array(center_point_2) normal = f.unit_vector(np.asarray(normal)) ratios = [self.core_ratio, self.diagonal_ratio] angles = np.linspace(0, np.pi, num=5) center_delta = center_point_2 - center_point_1 radius_vector_1 = f.unit_vector(np.cross(normal, center_delta)) * radius radius_point_1 = center_point_1 + radius_vector_1 # point 12 on the sketch pattern_1 = FanPattern(center_point_1, radius_point_1, normal) radius_vector_2 = f.unit_vector(np.cross(normal, -center_delta)) * radius radius_point_2 = center_point_2 + radius_vector_2 # point 17 on the sketch pattern_2 = FanPattern(center_point_2, radius_point_2, normal) inner_points_1 = pattern_1.get_inner_points(angles, ratios) inner_points_2 = pattern_2.get_inner_points(angles, ratios) outer_points_1 = pattern_1.get_outer_points(angles) outer_points_2 = pattern_2.get_outer_points(angles) locations = [center_point_1, *inner_points_1, center_point_2, *inner_points_2, *outer_points_1, *outer_points_2] super().__init__(locations, quad_map) @property def center_1(self) -> NPPointType: return self.faces[0].points[0].position @property def center_2(self) -> NPPointType: return self.faces[5].points[0].position @property def center(self): return (self.center_1 + self.center_2) / 2 def add_edges(self): for i in (6, 7, 8, 9): self.faces[i].add_edge(1, Origin(self.center_1)) for i in (11, 12, 13, 14): self.faces[i].add_edge(1, Origin(self.center_2)) @property def grid(self): return [self.faces[:6], self.faces[6:]] ================================================ FILE: src/classy_blocks/construct/flat/sketches/grid.py ================================================ import numpy as np from classy_blocks.cbtyping import PointType from classy_blocks.construct.flat.face import Face from classy_blocks.construct.flat.sketch import Sketch from classy_blocks.util import functions as f from classy_blocks.util.constants import DTYPE class Grid(Sketch): """A `n x m` array of rectangles; not here because it's particularly useful but as an example of a cartesian sketch/stack. Lies in x-y plane and is aligned with cartesian coordinate system by default but can be rotated arbitrarily just like other entities. point_1 is 'lower left' and point_2 is upper right. TODO: make this more general and user-friendly""" def __init__(self, point_1: PointType, point_2: PointType, count_1: int, count_2: int): point_1 = np.asarray(point_1, dtype=DTYPE) point_2 = np.asarray(point_2, dtype=DTYPE) coords_1 = np.linspace(point_1[0], point_2[0], num=count_1 + 1) coords_2 = np.linspace(point_1[1], point_2[1], num=count_2 + 1) self._grid: list[list[Face]] = [] for iy in range(count_2): self.grid.append([]) for ix in range(count_1): points = [ [coords_1[ix], coords_2[iy], 0], [coords_1[ix + 1], coords_2[iy], 0], [coords_1[ix + 1], coords_2[iy + 1], 0], [coords_1[ix], coords_2[iy + 1], 0], ] self._grid[-1].append(Face(points)) @property def faces(self): return f.flatten_2d_list(self.grid) @property def center(self): return (self.faces[0].points[0].position + self.faces[-1].points[2].position) / 2 @property def grid(self): return self._grid ================================================ FILE: src/classy_blocks/construct/flat/sketches/mapped.py ================================================ import warnings from typing import Union import numpy as np from classy_blocks.cbtyping import IndexType, NPPointListType, NPPointType, PointListType from classy_blocks.construct.flat.face import Face from classy_blocks.construct.flat.sketch import Sketch from classy_blocks.util import constants from classy_blocks.util import functions as f class MappedSketch(Sketch): """A sketch that is created from predefined points. The points are connected to form quads which define Faces.""" def __init__(self, positions: PointListType, quads: list[IndexType]): self._faces: list[Face] = [] self.indexes = quads for quad in self.indexes: face = Face([positions[iq] for iq in quad]) self._faces.append(face) self.add_edges() def update(self, positions: PointListType) -> None: """Update faces with updated positions""" for i, quad in enumerate(self.indexes): points = [positions[iq] for iq in quad] self.faces[i].update(points) def add_edges(self) -> None: """An optional method that will add edges to faces; use `sketch.faces` property to access them.""" @property def faces(self): """A 'flattened' grid of faces""" return self._faces @property def grid(self): """Use a single-tier grid by default; override the method for more sophistication.""" return [self.faces] @property def center(self) -> NPPointType: """Center of this sketch""" return np.average([face.center for face in self.faces], axis=0) @property def positions(self) -> NPPointListType: """Reconstructs positions back from faces, so they are always up-to-date, even after transforms""" indexes = list(np.array(self.indexes).flatten()) max_index = max(indexes) all_points = f.flatten_2d_list([face.point_array.tolist() for face in self.faces]) return np.array([all_points[indexes.index(i)] for i in range(max_index + 1)]) def merge(self, other: Union[list["MappedSketch"], "MappedSketch"]): """Adds a sketch or list of sketches to itself. New faces and indexes are appended and all duplicate points are removed.""" def merge_two_sketches(sketch_1: MappedSketch, sketch_2: MappedSketch) -> None: """Add sketch_2 to sketch_1""" # Check planes are oriented the same if not abs(f.angle_between(sketch_1.normal, sketch_2.normal)) < constants.TOL: warnings.warn( f"Sketch {sketch_2} with normal {sketch_2.normal} is not oriented as " f"sketch {sketch_1} with normal {sketch_1.normal}", stacklevel=1, ) # All unique points sketch_1_pos = sketch_1.positions all_pos = np.asarray( [ *sketch_1_pos.tolist(), *[ pos for pos in sketch_2.positions if all(np.linalg.norm(sketch_1_pos - pos.reshape((1, 3)), axis=1) >= constants.TOL) ], ] ) sketch_2_ind = np.asarray(sketch_2.indexes) # Change sketch_2 indexes to new position list. for i, face in enumerate(sketch_2.faces): for j, pos in enumerate(face.point_array): sketch_2_ind[i, j] = np.argwhere( np.linalg.norm(all_pos - pos.reshape((1, 3)), axis=1) < constants.TOL )[0][0] # Append indexes and faces to sketch_1 sketch_1.indexes = [*list(sketch_1.indexes), *sketch_2_ind.tolist()] sketch_1._faces = [*sketch_1.faces, *sketch_2.faces] # If list of sketches if isinstance(other, list): for o in other: merge_two_sketches(self, o) else: merge_two_sketches(self, other) ================================================ FILE: src/classy_blocks/construct/flat/sketches/spline_round.py ================================================ from typing import ClassVar, Optional import numpy as np from classy_blocks.cbtyping import NPPointListType, NPPointType, NPVectorType, PointType from classy_blocks.construct.edges import Origin, Spline from classy_blocks.construct.flat.sketches.disk import DiskBase, FourCoreDisk, HalfDisk, QuarterDisk from classy_blocks.util import constants from classy_blocks.util import functions as f class SplineRound(DiskBase): """ Base class for spline round sketches. Shape can be oval, elliptical or circular. """ n_outer_spline_points = 20 n_straight_spline_points = 10 # Widths only used for rings width_1: float = 0 width_2: float = 0 disk_initialized: bool = False def __init__(self, side_1: float, side_2: float, **kwargs): self.side_1 = side_1 self.side_2 = side_2 self.n_outer_spline_points = kwargs.get("n_outer_spline_points", self.n_outer_spline_points) self.n_straight_spline_points = kwargs.get("n_straight_spline_points", self.n_straight_spline_points) def remove_core(self): """Remove core. Used for rings""" # remove center point pos = self.positions pos = np.delete(pos, 0, axis=0) # Remove core face self._faces = np.delete(self._faces, slice(0, int(len(self.shell) / 2)), axis=0) self.indexes = np.delete(self.indexes, slice(0, int(len(self.shell) / 2)), axis=0) - 1 self.update(pos) def oval_core_spline( self, p_core_ratio: PointType, p_diagonal_ratio: PointType, radius_1: float, side_1: float, radius_2: float, side_2: float, reverse: bool = False, ) -> NPPointListType: """Creates the spline points for the core.""" p_0 = np.asarray(p_core_ratio) p_1 = np.asarray(p_diagonal_ratio) # Create unitary points of p_0 and p_1 r_1 = radius_1 - side_1 r_2 = radius_2 - side_2 p_0_u = np.array([0, side_1 + self.core_ratio * r_1, 0]) p_1_u = np.array( [0, side_1 + 2 ** (-1 / 2) * self.diagonal_ratio * r_1, side_2 + 2 ** (-1 / 2) * self.diagonal_ratio * r_2] ) # In case of oval shape the center and p_0 used to get the curvy spline are adjusted center_u_adj = np.array([0, side_1, side_2]) p_0_u_adj = p_0_u + np.array([0, 0, side_2]) spline_points_u = self.circular_core_spline(p_0_u_adj, p_1_u, center=center_u_adj, reverse=reverse) # Add straight part for ovals if side_2 > constants.TOL: if reverse: side_points_u = np.linspace( p_0_u_adj, p_0_u_adj - np.array([0, 0, 0.05 * side_2]), self.n_straight_spline_points ) spline_points_u = np.append(spline_points_u, side_points_u, axis=0) else: side_points_u = np.linspace( p_0_u_adj - np.array([0, 0, 0.05 * side_2]), p_0_u_adj, self.n_straight_spline_points ) spline_points_u = np.insert(spline_points_u, 0, side_points_u, axis=0) # Orthogonal vectors based on p_0_u and p_1_u u_0_org = p_0_u u_1_org = p_1_u - np.dot(p_1_u, f.unit_vector(u_0_org)) * f.unit_vector(u_0_org) # Spline points in u_0_org and u_1_org spline_d_0_org = np.dot(spline_points_u, f.unit_vector(u_0_org)).reshape((-1, 1)) / f.norm(u_0_org) spline_d_1_org = np.dot(spline_points_u, f.unit_vector(u_1_org)).reshape((-1, 1)) / f.norm(u_1_org) # New plane defined by new points u_0 = p_0 - self.center u_1 = p_1 - self.center - np.dot(p_1 - self.center, f.unit_vector(u_0)) * f.unit_vector(u_0) spline_points_new = self.center + spline_d_0_org * u_0 + spline_d_1_org * u_1 return spline_points_new def add_core_spline_edges(self) -> None: """Add a spline to the core blocks for an optimized mesh.""" sides = [self.side_1, self.side_2] radi = [self.radius_1, self.radius_2] for i, face in enumerate(self.core): p_0 = face.point_array[(i + 1) % 4] # Core point on radius 1 p_1 = face.point_array[(i + 2) % 4] # Core point on diagonal p_2 = face.point_array[(i + 3) % 4] # Core point on radius 2 curve_0_1 = self.oval_core_spline( p_0, p_1, radi[i % 2], sides[i % 2], radi[(i + 1) % 2], sides[(i + 1) % 2], reverse=i == 2 ) curve_1_2 = self.oval_core_spline( p_2, p_1, radi[(i + 1) % 2], sides[(i + 1) % 2], radi[i % 2], sides[i % 2], reverse=i != 1 ) spline_curve_0_1 = Spline(curve_0_1) spline_curve_1_2 = Spline(curve_1_2) # Add curves to edges edge_1 = (i + 1) % 4 edge_2 = (i + 2) % 4 face.add_edge(edge_1, spline_curve_0_1) face.add_edge(edge_2, spline_curve_1_2) def outer_spline( self, p_radius: PointType, p_diagonal: PointType, radius_1: float, side_1: float, radius_2: float, side_2: float, center: Optional[NPPointType] = None, reverse: bool = False, ) -> NPPointListType: """Creates the spline points for the core.""" p_0 = np.asarray(p_radius) p_1 = np.asarray(p_diagonal) center = self.origo if center is None else np.asarray(center) # Create unitary points of p_0 and p_1 r_1 = radius_1 - side_1 r_2 = radius_2 - side_2 p_0_u = np.array([0, radius_1, 0]) p_1_u = np.array([0, side_1 + 2 ** (-1 / 2) * r_1, side_2 + 2 ** (-1 / 2) * r_2]) p_0_u_adj = p_0_u + np.array([0, 0, side_2]) c_0_u_adj = np.array([0, side_1, side_2]) theta = np.linspace(0, np.pi / 4, self.n_outer_spline_points) spline_points_u = c_0_u_adj + np.array([np.zeros(len(theta)), r_1 * np.cos(theta), r_2 * np.sin(theta)]).T if reverse: spline_points_u = spline_points_u[::-1] # Add straight part for ovals if side_2 > constants.TOL: side_points_u = np.linspace( p_0_u_adj, p_0_u_adj - np.array([0, 0, 0.05 * side_2]), self.n_straight_spline_points ) spline_points_u = np.append(spline_points_u, side_points_u, axis=0) else: # Add straight part for ovals if side_2 > constants.TOL: side_points_u = np.linspace( p_0_u_adj - np.array([0, 0, 0.05 * side_2]), p_0_u_adj, self.n_straight_spline_points ) spline_points_u = np.insert(spline_points_u, 0, side_points_u, axis=0) # Orthogonal vectors based on p_0_u and p_1_u u_0_org = p_0_u u_1_org = p_1_u - np.dot(p_1_u, f.unit_vector(u_0_org)) * f.unit_vector(u_0_org) # Spline points in u_0_org and u_1_org spline_d_0_org = np.dot(spline_points_u, f.unit_vector(u_0_org)).reshape((-1, 1)) / f.norm(u_0_org) spline_d_1_org = np.dot(spline_points_u, f.unit_vector(u_1_org)).reshape((-1, 1)) / f.norm(u_1_org) # New plane defined by new points u_0 = p_0 - center u_1 = p_1 - center - np.dot(p_1 - center, f.unit_vector(u_0)) * f.unit_vector(u_0) spline_points_new = center + spline_d_0_org * u_0 + spline_d_1_org * u_1 return spline_points_new def add_outer_spline_edges(self, center: Optional[NPPointType] = None) -> None: """Add curved edge as spline to outside of sketch""" sides = [self.side_1, self.side_2] radi = [self.radius_1, self.radius_2] for i, face in enumerate(self.shell): p_0 = face.point_array[(i % 2) + 1] # Outer point on radius p_1 = face.point_array[((i + 1) % 2) + 1] # Outer point on diagonal spline_curve_0_1 = self.outer_spline( p_0, p_1, radi[int((i + 1) / 2) % 2], sides[int((i + 1) / 2) % 2], radi[int((i + 3) / 2) % 2], sides[int((i + 3) / 2) % 2], center, reverse=i % 2 == 1, ) face.add_edge(1, Spline(spline_curve_0_1)) def add_inner_spline_edges(self, center: Optional[NPPointType] = None) -> None: """Add curved edge as spline to inside of ring""" sides = [self.side_1, self.side_2, self.side_2, self.side_1] radi = [ self.radius_1 - self.width_1, self.radius_2 - self.width_2, self.radius_2 - self.width_2, self.radius_1 - self.width_1, ] for i, face in enumerate(self.shell): p_0 = face.point_array[0 if i % 2 == 0 else 3] # Inner point on radius p_1 = face.point_array[3 if i % 2 == 0 else 0] # Inner point on diagonal spline_curve_0_1 = self.outer_spline( p_0, p_1, radi[i % 4], sides[i % 4], radi[(i + 1) % 4], sides[(i + 1) % 4], center, reverse=i % 2 == 1 ) face.add_edge(3, Spline(spline_curve_0_1)) def add_edges(self) -> None: # Don't run add_edges in QuarterDisk.__init__() if not self.disk_initialized: return # Circular if ( self.side_1 < constants.TOL and self.side_2 < constants.TOL and abs(self.radius_1 - self.radius_2) < constants.TOL ): super().add_edges() else: self.add_core_spline_edges() self.add_outer_spline_edges() @property def radius_1_point(self) -> NPPointType: return self.radius_point @property def radius_1_vector(self) -> NPVectorType: return self.radius_1_point - self.origo @property def radius_1(self) -> float: return self.radius @property def radius_2_point(self) -> NPPointType: return self.shell[1].points[2].position @property def radius_2_vector(self) -> NPVectorType: return self.radius_2_point - self.origo @property def radius_2(self) -> float: return float(f.norm(self.radius_2_vector)) @property def u_0(self) -> NPVectorType: """Returns unit vector 0 in stable way after transforms.""" return f.unit_vector(np.cross(self.u_1, self.u_2)) @property def normal(self) -> NPVectorType: return self.u_0 @property def u_1(self) -> NPVectorType: """Returns unit vector 1 in stable way after transforms.""" try: return f.unit_vector(self.radius_1_vector) except AttributeError: return self._u_1 @u_1.setter def u_1(self, vec): self._u_1 = f.unit_vector(vec) @property def u_2(self) -> NPVectorType: """Returns unit vector 2 in stable way after transforms.""" try: return f.unit_vector(self.radius_2_vector) except AttributeError: return self._u_2 @u_2.setter def u_2(self, vec): self._u_2 = f.unit_vector(vec) def scale(self, ratio: float, origin: Optional[PointType] = None): """Reimplementation of scale to include side_1 and side_2.""" self.side_1 = ratio * self.side_1 self.side_2 = ratio * self.side_2 return super().scale(ratio, origin) class QuarterSplineDisk(SplineRound, QuarterDisk): """Sketch for Quarter oval, elliptical and circular shapes""" def __init__( self, center_point: PointType, corner_1_point: PointType, corner_2_point: PointType, side_1: float, side_2: float, **kwargs, ) -> None: """ With a normal in x direction corner 1 will be in the y direction and corner 2 the z direction. note the vectors from the center to corner 1 and 2 should be perpendicular. Args: center_point: Center of round shape corner_1_point: Radius for circular and elliptical shape corner_2_point: Radius for circular and elliptical shape side_1: Straight length for oval shape side_2: Straight length for oval shape """ super().__init__(side_1, side_2, **kwargs) corner_1_point = np.asarray(corner_1_point) corner_2_point = np.asarray(corner_2_point) self.u_1 = f.unit_vector(corner_1_point - np.asarray(center_point)) self.u_2 = f.unit_vector(corner_2_point - np.asarray(center_point)) # Create a QuarterDisk self.disk_initialized = False super(SplineRound, self).__init__(center_point, corner_1_point, normal=self.u_0) self.disk_initialized = True # Adjust to actual shape self.correct_disk(corner_1_point, corner_2_point) self.add_edges() def correct_disk(self, corner_1_point: NPPointType, corner_2_point: NPPointType): """Method to convert a circular disk to the elliptical/oval shape defined""" r_1 = f.norm(corner_1_point - self.center) - self.side_1 r_2 = f.norm(corner_2_point - self.center) - self.side_2 pos = self.positions # Core pos[1] = self.center + (self.side_1 + self.core_ratio * r_1) * self.u_1 pos[2] = ( self.center + (self.side_1 + 2 ** (-1 / 2) * self.diagonal_ratio * r_1) * self.u_1 + (self.side_2 + 2 ** (-1 / 2) * self.diagonal_ratio * r_2) * self.u_2 ) pos[3] = self.center + (self.side_2 + self.core_ratio * r_2) * self.u_2 # Shell pos[5] = ( self.center + (self.side_1 + 2 ** (-1 / 2) * r_1) * self.u_1 + (self.side_2 + 2 ** (-1 / 2) * r_2) * self.u_2 ) pos[6] = corner_2_point self.update(pos) class HalfSplineDisk(SplineRound, HalfDisk): """Sketch for Half oval, elliptical and circular shapes""" def __init__( self, center_point: PointType, corner_1_point: PointType, corner_2_point: PointType, side_1: float, side_2: float, **kwargs, ) -> None: """ With a normal in x direction corner 1 will be in the y direction and corner 2 the z direction. note the vectors from the center to corner 1 and 2 should be perpendicular. Args: center_point: Center of round shape corner_1_point: Radius for circular and elliptical shape corner_2_point: Radius for circular and elliptical shape side_1: Straight length for oval shape side_2: Straight length for oval shape """ SplineRound.__init__(self, side_1, side_2, **kwargs) corner_1_point = np.asarray(corner_1_point) corner_2_point = np.asarray(corner_2_point) self.u_1 = f.unit_vector(corner_1_point - np.asarray(center_point)) self.u_2 = f.unit_vector(corner_2_point - np.asarray(center_point)) # Create a HalfDisk self.disk_initialized = False HalfDisk.__init__(self, center_point, corner_1_point, normal=self.u_0) self.disk_initialized = True # Adjust to actual shape self.correct_disk(corner_1_point, corner_2_point) self.add_edges() def correct_disk(self, corner_1_point: NPPointType, corner_2_point: NPPointType): """Method to convert a circular disk to the elliptical/oval shape defined""" r_1 = f.norm(corner_1_point - self.center) - self.side_1 r_2 = f.norm(corner_2_point - self.center) - self.side_2 pos = self.positions # Core pos[1] = self.center + (self.side_1 + self.core_ratio * r_1) * self.u_1 pos[2] = ( self.center + (self.side_1 + 2 ** (-1 / 2) * self.diagonal_ratio * r_1) * self.u_1 + (self.side_2 + 2 ** (-1 / 2) * self.diagonal_ratio * r_2) * self.u_2 ) pos[3] = self.center + (self.side_2 + self.core_ratio * r_2) * self.u_2 pos[4] = ( self.center - (self.side_1 + 2 ** (-1 / 2) * self.diagonal_ratio * r_1) * self.u_1 + (self.side_2 + 2 ** (-1 / 2) * self.diagonal_ratio * r_2) * self.u_2 ) pos[5] = self.center - (self.side_1 + self.core_ratio * r_1) * self.u_1 # Shell pos[7] = ( self.center + (self.side_1 + 2 ** (-1 / 2) * r_1) * self.u_1 + (self.side_2 + 2 ** (-1 / 2) * r_2) * self.u_2 ) pos[8] = corner_2_point pos[9] = ( self.center - (self.side_1 + 2 ** (-1 / 2) * r_1) * self.u_1 + (self.side_2 + 2 ** (-1 / 2) * r_2) * self.u_2 ) self.update(pos) class SplineDisk(SplineRound, FourCoreDisk): """Sketch for oval, elliptical and circular shapes""" def __init__( self, center_point: PointType, corner_1_point: PointType, corner_2_point: PointType, side_1: float, side_2: float, **kwargs, ) -> None: """ With a normal in x direction corner 1 will be in the y direction and corner 2 the z direction. note the vectors from the center to corner 1 and 2 should be perpendicular. Args: center_point: Center of round shape corner_1_point: Radius for circular and elliptical shape corner_2_point: Radius for circular and elliptical shape side_1: Straight length for oval shape side_2: Straight length for oval shape """ SplineRound.__init__(self, side_1, side_2, **kwargs) corner_1_point = np.asarray(corner_1_point) corner_2_point = np.asarray(corner_2_point) self.u_1 = f.unit_vector(corner_1_point - np.asarray(center_point)) self.u_2 = f.unit_vector(corner_2_point - np.asarray(center_point)) # Create a FourCoreDisk self.disk_initialized = False FourCoreDisk.__init__(self, center_point, corner_1_point, normal=self.u_0) self.disk_initialized = True # Adjust to actual shape self.correct_disk(corner_1_point, corner_2_point) self.add_edges() def correct_disk(self, corner_1_point: NPPointType, corner_2_point: NPPointType): """Method to convert a circular disk to the elliptical/oval shape defined""" r_1 = f.norm(corner_1_point - self.center) - self.side_1 r_2 = f.norm(corner_2_point - self.center) - self.side_2 pos = self.positions # Core pos[1] = self.center + (self.side_1 + self.core_ratio * r_1) * self.u_1 pos[2] = ( self.center + (self.side_1 + 2 ** (-1 / 2) * self.diagonal_ratio * r_1) * self.u_1 + (self.side_2 + 2 ** (-1 / 2) * self.diagonal_ratio * r_2) * self.u_2 ) pos[3] = self.center + (self.side_2 + self.core_ratio * r_2) * self.u_2 pos[4] = ( self.center - (self.side_1 + 2 ** (-1 / 2) * self.diagonal_ratio * r_1) * self.u_1 + (self.side_2 + 2 ** (-1 / 2) * self.diagonal_ratio * r_2) * self.u_2 ) pos[5] = self.center - (self.side_1 + self.core_ratio * r_1) * self.u_1 pos[6] = ( self.center - (self.side_1 + 2 ** (-1 / 2) * self.diagonal_ratio * r_1) * self.u_1 - (self.side_2 + 2 ** (-1 / 2) * self.diagonal_ratio * r_2) * self.u_2 ) pos[7] = self.center - (self.side_2 + self.core_ratio * r_2) * self.u_2 pos[8] = ( self.center + (self.side_1 + 2 ** (-1 / 2) * self.diagonal_ratio * r_1) * self.u_1 - (self.side_2 + 2 ** (-1 / 2) * self.diagonal_ratio * r_2) * self.u_2 ) # Shell pos[9] = corner_1_point pos[10] = ( self.center + (self.side_1 + 2 ** (-1 / 2) * r_1) * self.u_1 + (self.side_2 + 2 ** (-1 / 2) * r_2) * self.u_2 ) pos[11] = corner_2_point pos[12] = ( self.center - (self.side_1 + 2 ** (-1 / 2) * r_1) * self.u_1 + (self.side_2 + 2 ** (-1 / 2) * r_2) * self.u_2 ) pos[13] = self.center - (self.side_1 + r_1) * self.u_1 pos[14] = ( self.center - (self.side_1 + 2 ** (-1 / 2) * r_1) * self.u_1 - (self.side_2 + 2 ** (-1 / 2) * r_2) * self.u_2 ) pos[15] = self.center - (self.side_2 + r_2) * self.u_2 pos[16] = ( self.center + (self.side_1 + 2 ** (-1 / 2) * r_1) * self.u_1 - (self.side_2 + 2 ** (-1 / 2) * r_2) * self.u_2 ) self.update(pos) class QuarterSplineRing(QuarterSplineDisk): """Ring based on SplineRound.""" chops: ClassVar = [ [0], # axis 0 [0, 1], # axis 1 ] def __init__( self, center_point: PointType, corner_1_point: PointType, corner_2_point: PointType, side_1: float, side_2: float, width_1: float, width_2: float, **kwargs, ): """ With a normal in x direction corner 1 will be in the y direction and corner 2 the z direction. Note the vectors from the center to corner 1 and 2 should be perpendicular. The ring is defined such it will fit around a QuaterSplineRound defined with the same center, corners and sides. Args: center_point: Center of round shape corner_1_point: Radius for circular and elliptical shape corner_2_point: Radius for circular and elliptical shape side_1: Straight length for oval shape side_2: Straight length for oval shape width_1: Width of shell width_2: Width of shell """ self.width_1 = float(width_1) self.width_2 = float(width_2) # Convert to corners to outer corners if kwargs.get("from_inner", True): corner_1_point = np.asarray(corner_1_point) corner_2_point = np.asarray(corner_2_point) self.u_1 = f.unit_vector(corner_1_point - np.asarray(center_point)) self.u_2 = f.unit_vector(corner_2_point - np.asarray(center_point)) corner_1_point = corner_1_point + self.width_1 * self.u_1 corner_2_point = corner_2_point + self.width_2 * self.u_2 # Initialize QuarterDisk super().__init__(center_point, corner_1_point, corner_2_point, side_1, side_2, **kwargs) def correct_disk(self, corner_1_point: NPPointType, corner_2_point: NPPointType): """Method to convert a disk to a ring""" # First adjust circular disk to SplineDisk super().correct_disk(corner_1_point, corner_2_point) self.remove_core() # Adjust inner curve to be oval/elliptical r_1 = self.radius_1 - self.side_1 - self.width_1 r_2 = self.radius_2 - self.side_2 - self.width_2 pos = self.positions pos[0] = self.radius_1_point - self.width_1 * self.u_1 pos[1] = ( self.origo + (self.side_1 + r_1 * np.cos(np.pi / 4)) * self.u_1 + (self.side_2 + r_2 * np.sin(np.pi / 4)) * self.u_2 ) pos[2] = self.radius_2_point - self.width_2 * self.u_2 self.update(pos) def add_edges(self) -> None: # Don't run add_edges in QuarterDisk.__init__() if not self.disk_initialized: return # Outside # Circular if ( self.side_1 < constants.TOL and self.side_2 < constants.TOL and abs(self.radius_1 - self.radius_2) < constants.TOL ): for face in self.shell: face.add_edge(1, Origin(self.origo)) else: self.add_outer_spline_edges() # Inside # Circular if ( self.side_1 < constants.TOL and self.side_2 < constants.TOL and abs(self.radius_1 - self.radius_2) < constants.TOL and abs(self.width_1 - self.width_2) < constants.TOL ): for face in self.shell: face.add_edge(3, Origin(self.origo)) else: self.add_inner_spline_edges() @property def grid(self): return [self.faces[-2:]] @property def core(self): return None class HalfSplineRing(HalfSplineDisk, QuarterSplineRing): """Ring based on SplineRound.""" chops: ClassVar = [ [0], # axis 0 [0, 1, 2, 3], # axis 1 ] def __init__( self, center_point: PointType, corner_1_point: PointType, corner_2_point: PointType, side_1: float, side_2: float, width_1: float, width_2: float, **kwargs, ): """ With a normal in x direction corner 1 will be in the y direction and corner 2 the z direction. Note the vectors from the center to corner 1 and 2 should be perpendicular. The ring is defined such it will fit around a QuaterSplineRound defined with the same center, corners and sides. Args: center_point: Center of round shape corner_1_point: Radius for circular and elliptical shape corner_2_point: Radius for circular and elliptical shape side_1: Straight length for oval shape side_2: Straight length for oval shape width_1: Width of shell width_2: Width of shell """ self.width_1 = float(width_1) self.width_2 = float(width_2) # Convert to corners to outer corners if kwargs.get("from_inner", True): corner_1_point = np.asarray(corner_1_point) corner_2_point = np.asarray(corner_2_point) self.u_1 = f.unit_vector(corner_1_point - np.asarray(center_point)) self.u_2 = f.unit_vector(corner_2_point - np.asarray(center_point)) corner_1_point = corner_1_point + self.width_1 * self.u_1 corner_2_point = corner_2_point + self.width_2 * self.u_2 HalfSplineDisk.__init__(self, center_point, corner_1_point, corner_2_point, side_1, side_2, **kwargs) def correct_disk(self, corner_1_point: NPPointType, corner_2_point: NPPointType): """Method to convert a disk to a ring""" # First adjust circular disk to SplineDisk super().correct_disk(corner_1_point, corner_2_point) self.remove_core() # Adjust inner curve to be oval/elliptical r_1 = self.radius_1 - self.side_1 - self.width_1 r_2 = self.radius_2 - self.side_2 - self.width_2 pos = self.positions pos[0] = self.radius_1_point - self.width_1 * self.u_1 pos[1] = ( self.origo + (self.side_1 + r_1 * np.cos(np.pi / 4)) * self.u_1 + (self.side_2 + r_2 * np.sin(np.pi / 4)) * self.u_2 ) pos[2] = self.radius_2_point - self.width_2 * self.u_2 pos[3] = ( self.origo - (self.side_1 + r_1 * np.cos(np.pi / 4)) * self.u_1 + (self.side_2 + r_2 * np.sin(np.pi / 4)) * self.u_2 ) pos[4] = self.origo - (self.radius_1 - self.width_1) * self.u_1 self.update(pos) def add_edges(self) -> None: # Don't run add_edges in QuarterDisk.__init__() if not self.disk_initialized: return # Outside # Circular if ( self.side_1 < constants.TOL and self.side_2 < constants.TOL and abs(self.radius_1 - self.radius_2) < constants.TOL ): for face in self.shell: face.add_edge(1, Origin(self.origo)) else: self.add_outer_spline_edges() # Inside # Circular if ( self.side_1 < constants.TOL and self.side_2 < constants.TOL and abs(self.radius_1 - self.radius_2) < constants.TOL and abs(self.width_1 - self.width_2) < constants.TOL ): for face in self.shell: face.add_edge(3, Origin(self.origo)) else: self.add_inner_spline_edges() @property def grid(self): return [self.faces[-4:]] @property def core(self): return None class SplineRing(SplineDisk, QuarterSplineRing): """Ring based on SplineRound.""" chops: ClassVar = [ [0], # axis 0 [0, 1, 2, 3, 4, 5, 6, 7], # axis 1 ] def __init__( self, center_point: PointType, corner_1_point: PointType, corner_2_point: PointType, side_1: float, side_2: float, width_1: float, width_2: float, **kwargs, ): """ With a normal in x direction corner 1 will be in the y direction and corner 2 the z direction. Note the vectors from the center to corner 1 and 2 should be perpendicular. The ring is defined such it will fit around a QuaterSplineRound defined with the same center, corners and sides. Args: center_point: Center of round shape corner_1_point: Radius for circular and elliptical shape corner_2_point: Radius for circular and elliptical shape side_1: Straight length for oval shape side_2: Straight length for oval shape width_1: Width of shell width_2: Width of shell """ self.width_1 = float(width_1) self.width_2 = float(width_2) # Convert to corners to outer corners if kwargs.get("from_inner", True): corner_1_point = np.asarray(corner_1_point) corner_2_point = np.asarray(corner_2_point) self.u_1 = f.unit_vector(corner_1_point - np.asarray(center_point)) self.u_2 = f.unit_vector(corner_2_point - np.asarray(center_point)) corner_1_point = corner_1_point + self.width_1 * self.u_1 corner_2_point = corner_2_point + self.width_2 * self.u_2 SplineDisk.__init__(self, center_point, corner_1_point, corner_2_point, side_1, side_2, **kwargs) def correct_disk(self, corner_1_point: NPPointType, corner_2_point: NPPointType): """Method to convert a disk to a ring""" # First adjust circular disk to splinedisk super().correct_disk(corner_1_point, corner_2_point) self.remove_core() # Adjust inner curve to be oval/elliptical r_1 = self.radius_1 - self.side_1 - self.width_1 r_2 = self.radius_2 - self.side_2 - self.width_2 pos = self.positions pos[0] = self.radius_1_point - self.width_1 * self.u_1 pos[1] = ( self.origo + (self.side_1 + r_1 * np.cos(np.pi / 4)) * self.u_1 + (self.side_2 + r_2 * np.sin(np.pi / 4)) * self.u_2 ) pos[2] = self.radius_2_point - self.width_2 * self.u_2 pos[3] = ( self.origo - (self.side_1 + r_1 * np.cos(np.pi / 4)) * self.u_1 + (self.side_2 + r_2 * np.sin(np.pi / 4)) * self.u_2 ) pos[4] = self.origo - (self.radius_1 - self.width_1) * self.u_1 pos[5] = ( self.origo - (self.side_1 + r_1 * np.cos(np.pi / 4)) * self.u_1 - (self.side_2 + r_2 * np.sin(np.pi / 4)) * self.u_2 ) pos[6] = self.origo - (self.radius_2 - self.width_2) * self.u_2 pos[7] = ( self.origo + (self.side_1 + r_1 * np.cos(np.pi / 4)) * self.u_1 - (self.side_2 + r_2 * np.sin(np.pi / 4)) * self.u_2 ) self.update(pos) @property def grid(self): return [self.faces[-8:]] @property def core(self): return None ================================================ FILE: src/classy_blocks/construct/operations/__init__.py ================================================ ================================================ FILE: src/classy_blocks/construct/operations/box.py ================================================ import numpy as np from classy_blocks.cbtyping import PointType from classy_blocks.construct.flat.face import Face from classy_blocks.construct.operations.loft import Loft class Box(Loft): """A Rudimentary Box with edges aligned to cartesian coordinates x-y-z. Refer to sketch in blockMesh documentation for explanation of args below: https://doc.cfd.direct/openfoam/user-guide-v6/blockmesh https://www.openfoam.com/documentation/user-guide/4-mesh-generation-and-conversion/4.3-mesh-generation-with-the-blockmesh-utility Args: - start_point: one corner of the box - diagonal_point: corner at the other end of volumetric diagonal to start_point; Box() will always sort input data so that it becomes aligned with cartesian coordinate system. Therefore edge 0-1 will correspond to x-axis, 1-2 to y- and 0-4 to z-axis.""" def __init__(self, start_point: PointType, diagonal_point: PointType): start_point = np.asarray(start_point) diagonal_point = np.asarray(diagonal_point) parr = np.vstack((start_point, diagonal_point)).T point_0 = np.asarray([min(parr[0]), min(parr[1]), min(parr[2])]) point_6 = np.asarray([max(parr[0]), max(parr[1]), max(parr[2])]) delta_x = [point_6[0] - point_0[0], 0, 0] delta_y = [0, point_6[1] - point_0[1], 0] delta_z = [0, 0, point_6[2] - point_0[2]] # this is a workaround to make linter happy (doesn't recognize numpy types properly? np_points = np.array([point_0, point_0 + delta_x, point_0 + delta_x + delta_y, point_0 + delta_y]) bottom_face = Face(np_points) top_face = bottom_face.copy().translate(delta_z) super().__init__(bottom_face, top_face) ================================================ FILE: src/classy_blocks/construct/operations/connector.py ================================================ import numpy as np from classy_blocks.construct.flat.face import Face from classy_blocks.construct.operations.operation import Operation from classy_blocks.modify.reorient.viewpoint import ViewpointReorienter from classy_blocks.util import functions as f class FacePair: def __init__(self, face_1: Face, face_2: Face): self.face_1 = face_1 self.face_2 = face_2 @property def distance(self) -> float: """Returns distance between two faces' centers""" return f.norm(self.face_1.center - self.face_2.center) @property def alignment(self) -> float: """Returns a scalar number that is a measure of how well the two faces are aligned, a.k.a. how well their normals align""" vconn = f.unit_vector(self.face_2.center - self.face_1.center) return np.dot(vconn, self.face_1.normal) ** 3 + np.dot(-vconn, self.face_2.normal) ** 3 class Connector(Operation): """A normal Loft but automatically finds and reorders appropriate faces between two arbitrary given blocks. The recipe is as follows: 1. Find a pair of faces whose normals are most nicely aligned 2. Create a loft that connects them 3. Reorder the loft so that is is properly oriented The following limitations apply: "Closest faces" might be an ill-defined term; for example, imagine two boxes: ___ | 2 | |___| ___ | 1 | |___| Here, multiple different faces can be found. Reordering relies on ViewpointReorienter; see the documentation on that for its limitations. Resulting loft will have the bottom face coincident with operation_1 and top face with operation_2. Axis 2 is always between the two operations but axes 0 and 1 depend on positions of operations and is not exactly defined. To somewhat alleviate this confusion it is recommended to chop operation 1 or 2 in axes 0 and 1 and only provide chopping for axis 2 of connector.""" def __init__(self, operation_1: Operation, operation_2: Operation): self.operation_1 = operation_1 self.operation_2 = operation_2 all_pairs: list[FacePair] = [] for orient_1, face_1 in operation_1.get_all_faces().items(): if orient_1 in ("bottom", "left", "front"): face_1.invert() for orient_2, face_2 in operation_2.get_all_faces().items(): if orient_2 in ("bottom", "left", "front"): face_2.invert() all_pairs.append(FacePair(face_1, face_2)) all_pairs.sort(key=lambda pair: pair.distance) all_pairs = all_pairs[:9] all_pairs.sort(key=lambda pair: pair.alignment) start_face = all_pairs[-1].face_1 end_face = all_pairs[-1].face_2 super().__init__(start_face, end_face) viewpoint = operation_1.center + 2 * (operation_1.top_face.center - operation_1.bottom_face.center) ceiling = operation_1.center + 2 * (operation_2.center - operation_1.center) reorienter = ViewpointReorienter(viewpoint, ceiling) reorienter.reorient(self) ================================================ FILE: src/classy_blocks/construct/operations/extrude.py ================================================ from typing import Union import numpy as np from classy_blocks.cbtyping import VectorType from classy_blocks.construct.flat.face import Face from classy_blocks.construct.operations.loft import Loft class Extrude(Loft): """Takes a Face and extrudes it by 'amount'. If 'amount' is float, the extrude direction is normal to 'base'. """ def __init__(self, base: Face, amount: Union[float, VectorType]): self.base = base if isinstance(amount, float) or isinstance(amount, int): extrude_vector = self.base.normal * amount else: extrude_vector = np.asarray(amount) top_face = base.copy().translate(extrude_vector) super().__init__(base, top_face) ================================================ FILE: src/classy_blocks/construct/operations/loft.py ================================================ from classy_blocks.construct.operations.operation import Operation # since any possible block shape can be created with Loft operation, # Loft is the most low-level of all operations. Anything included in Loft # must also be included in Operation. Loft = Operation ================================================ FILE: src/classy_blocks/construct/operations/operation.py ================================================ import warnings from typing import Optional, Union, get_args import numpy as np from typing_extensions import Unpack from classy_blocks.base.element import ElementBase from classy_blocks.base.exceptions import EdgeCreationError from classy_blocks.base.transforms import Mirror from classy_blocks.cbtyping import ( ChopArgs, DirectionType, NPPointType, OrientType, PointType, ProjectToType, VectorType, ) from classy_blocks.construct.edges import Arc, EdgeData, Line, Project, Spline from classy_blocks.construct.flat.face import Face from classy_blocks.construct.point import Point from classy_blocks.grading.define.chop import Chop from classy_blocks.grading.define.collector import ChopCollector from classy_blocks.util import constants from classy_blocks.util import functions as f from classy_blocks.util.constants import SIDES_MAP from classy_blocks.util.frame import Frame from classy_blocks.util.tools import edge_map class Operation(ElementBase): """A base class for all single-block operations (Box, Loft, Revolve, Extrude, Wedge).""" # connects orients/indexes of side faces/edges def __init__(self, bottom_face: Face, top_face: Face): self.bottom_face = bottom_face self.top_face = top_face self.side_edges: list[EdgeData] = [Line(), Line(), Line(), Line()] self.side_projects: list[Optional[str]] = [None, None, None, None] self.side_patches: list[Optional[str]] = [None, None, None, None] # instructions for cell counts and gradings self.chops = ChopCollector() # optionally, put the block in a cell zone self.cell_zone = "" def _project_update(self, edge: EdgeData, label: ProjectToType): """Adds a label to a Project edge or creates a new Project edge and returns it""" if isinstance(edge, Project): edge.add_label(label) return edge return Project(label) def add_side_edge(self, corner_idx: int, edge_data: EdgeData) -> None: """Add an edge between two vertices at the same corner of the lower and upper face (index and index+4 or vice versa).""" if corner_idx < 0 or corner_idx > 3: raise EdgeCreationError( "Unable to create side edge between two faces: corner must be an index to a bottom Vertex (0...3)", f"Given corner index: {corner_idx}", ) self.side_edges[corner_idx] = edge_data def chop(self, axis: DirectionType, **kwargs: Unpack[ChopArgs]) -> None: """ Chop the whole operation (set cell count and optional grading) in one direction. Parameters ---------- Axis : int Direction in which to apply the chop: * **0** - along the first edge of a face * **1** - along the second edge of a face * **2** - between faces / along the operation path Keyword arguments ---------------- start_size : float, optional Width of the first cell. end_size : float, optional Width of the last cell. count : int, optional Number of cells in the chosen direction. c2c_expansion : float, optional Cell-to-cell expansion ratio (default = 1). total_expansion : float, optional Ratio between the first and last cell size. take : optional Edge length to use when computing the cell count. Use 'min', 'max' or 'avg' (the default) preserve : optional Which parameter to maintain consistent when distributing chops to other blocks in the same row. Can be ``c2c_expansion``, ``start_size`` or ``end_size``. The default is ``total_expansion``. length_ratio : optional To use multi-graded blocks, add multiple chops to the same axis by calling ``.chop()`` multiple times. Each chop takes a fraction of length (should total to 1) which is specified by ``length_ratio``. https://cfd.direct/openfoam/user-guide/v9-blockMesh/#multi-grading Notes ----- * Specify one or two chopping parameters (start/end size, c2c expansion, total expansion, count). That specifies grading completely. Using more than two makes the calculation over-defined and will yield inconsistent results or will throw an exception. * When only one parameter is given, ``c2c_expansion`` defaults to 1 and a uniform cell size is produced. * ``total_expansion`` cannot be used with ``c2c_expansion`` = 1. * ``take`` controls which edge length in given axis is taken when calculating grading. """ self.chops.chop_axis(axis, Chop(**kwargs)) def chop_edge(self, corner_1: int, corner_2: int, **kwargs: Unpack[ChopArgs]) -> None: self.chops.chop_edge(corner_1, corner_2, Chop(**kwargs)) def unchop(self, axis: Optional[DirectionType] = None) -> None: """Removes existing chops from an operation (comes handy after copying etc.)""" if axis is None: for i in get_args(DirectionType): self.chops[i].clear() return self.chops[axis].clear() def project_corner(self, corner: int, label: ProjectToType) -> None: """Project the vertex at given corner (local index 0...7) to a single surface or an intersection of multiple surface. WIP according to https://github.com/OpenFOAM/OpenFOAM-10/blob/master/src/meshTools/searchableSurfaces/searchableSurfacesQueries/searchableSurfacesQueries.H """ # bottom and top faces define operation's points if corner > 3: self.top_face.points[corner - 4].project(label) else: self.bottom_face.points[corner].project(label) def project_edge(self, corner_1: int, corner_2: int, label: ProjectToType) -> None: """Replace an edge between given corners with a Projected one or add geometry to an already projected edge""" # decide where the required edge sits loc = edge_map[corner_1][corner_2] corner = loc.start_corner # bottom or top face? if loc.side == "bottom": self.bottom_face.edges[corner] = self._project_update(self.bottom_face.edges[corner], label) return if loc.side == "top": self.top_face.edges[corner] = self._project_update(self.top_face.edges[loc.start_corner], label) return # sides self.side_edges[corner] = self._project_update(self.side_edges[corner], label) def project_side(self, side: OrientType, label: str, edges: bool = False, points: bool = False) -> None: """Project given side to a labeled geometry; Args: - side: 'bottom', 'top', 'front', 'back', 'left', 'right'; the sketch from blockMesh documentation: https://www.openfoam.com/documentation/user-guide/4-mesh-generation-and-conversion/4.3-mesh-generation-with-the-blockmesh-utility bottom, top: faces from which the Operation was created front: along first edge of a face back: opposite front right: along second edge of a face left: opposite right - label: name of predefined geometry (add separately to Mesh object) - edges:if True, all edges belonging to this side will also be projected""" if side == "bottom": self.bottom_face.project(label, edges, points) return if side == "top": self.top_face.project(label, edges, points) return index_1 = self.get_index_from_side(side) index_2 = (index_1 + 1) % 4 self.side_projects[index_1] = label if edges: self.project_edge(index_1, index_2, label) self.project_edge(index_1 + 4, index_2 + 4, label) self.side_edges[index_1] = self._project_update(self.side_edges[index_1], label) self.side_edges[index_2] = self._project_update(self.side_edges[index_2], label) self.top_face.project_edge(index_1, label) self.bottom_face.project_edge(index_1, label) if points: for face in (self.top_face, self.bottom_face): for point_index in (index_1, index_2): face.points[point_index].project(label) def set_patch(self, sides: Union[OrientType, list[OrientType]], name: str) -> None: """Assign a patch to given side of the block; Args: - side: 'bottom', 'top', 'front', 'back', 'left', 'right', a single value or a list of sides; names correspond to position in the sketch from blockMesh documentation: https://www.openfoam.com/documentation/user-guide/4-mesh-generation-and-conversion/4.3-mesh-generation-with-the-blockmesh-utility bottom, top: faces from which the Operation was created front: along first edge of a face back: opposite front right: along second edge of a face left: opposite right - name: the name that goes into blockMeshDict Use mesh.set_patch_* methods to change other properties (type and other settings)""" if not isinstance(sides, list): sides = [sides] for orient in sides: if orient == "bottom": self.bottom_face.patch_name = name elif orient == "top": self.top_face.patch_name = name else: self.side_patches[self.get_index_from_side(orient)] = name def set_cell_zone(self, cell_zone: str) -> None: """Assign a cellZone to this block.""" self.cell_zone = cell_zone @property def parts(self): return [self.bottom_face, self.top_face, *self.side_edges] @property def points(self) -> list[Point]: """Returns a list of Point objects that define this Operation""" return self.bottom_face.points + self.top_face.points @property def point_array(self) -> NPPointType: """Returns 8 points from which this operation is created""" return np.concatenate((self.bottom_face.point_array, self.top_face.point_array)) @property def center(self): return np.average(self.point_array, axis=0) def get_patches_at_corner(self, corner: int) -> set: """Returns patch names at given corner (up to 3)""" patches = set() # 1st patch: from top or bottom face if corner < 4: patches.add(self.bottom_face.patch_name) else: patches.add(self.top_face.patch_name) # 2nd and 3rd patch: from the next an previous side at that corner index = corner % 4 patches.add(self.side_patches[index]) patches.add(self.side_patches[(index + 3) % 4]) # clean up Nones patches.discard(None) return patches def get_face(self, side: OrientType) -> Face: """Returns a new Face on specified side of the Operation. Warning: bottom, left and front faces must be inverted prior to using them for a loft/extrude etc (they point inside the operation by default).""" return Face([self.point_array[i] for i in constants.FACE_MAP[side]]) def get_all_faces(self) -> dict[OrientType, Face]: """Returns a list of all faces""" return {orient: self.get_face(orient) for orient in get_args(OrientType)} def get_closest_side(self, point: PointType) -> OrientType: """Returns side (bottom/top/left/right/front/back) of the closest face to given point""" point = np.array(point) all_faces = self.get_all_faces() sides = list(all_faces.keys()) faces = list(all_faces.values()) centers = np.array([f.norm(point - face.center) for face in faces]) return sides[np.argmin(centers)] def get_closest_face(self, point: PointType) -> Face: """Returns a Face that has a center nearest to given point""" return self.get_face(self.get_closest_side(point)) def get_normal_face(self, point: PointType) -> Face: """Returns a Face that has normal closest to vector that connects returned face and 'point' (viewer).""" point = np.array(point) faces = self.get_all_faces() orients: list[OrientType] = ["bottom", "left", "front"] for orient in orients: faces[orient].invert() face_list = list(faces.values()) dotps = [np.dot(f.unit_vector(point - face.center), face.normal) for face in face_list] return face_list[np.argmax(dotps)] @property def patch_names(self) -> dict[OrientType, str]: """Returns patches names on sides where they are specified""" patch_names: dict[OrientType, str] = {} def add(orient, name): if name is not None: patch_names[orient] = name add("bottom", self.bottom_face.patch_name) add("top", self.top_face.patch_name) for index, orient in enumerate(SIDES_MAP): add(orient, self.side_patches[index]) return patch_names @property def edges(self) -> Frame[EdgeData]: """Returns a Frame with edges as its beams""" edges = Frame[EdgeData]() for i, data in enumerate(self.bottom_face.edges): edges.add_beam(i, (i + 1) % 4, data) for i, data in enumerate(self.top_face.edges): edges.add_beam(i + 4, (i + 1) % 4 + 4, data) for i, data in enumerate(self.side_edges): edges.add_beam(i, i + 4, data) return edges @staticmethod def get_index_from_side(side: OrientType) -> int: """Returns index of edges/patches/projections from given orient""" if side not in SIDES_MAP: raise RuntimeError("Use self.top_face()/self.bottom_face() for actions on top and bottom face") return SIDES_MAP.index(side) def invert(self) -> "Operation": """Flips top and bottom face""" self.top_face, self.bottom_face = self.bottom_face, self.top_face return self def mirror(self, normal: VectorType, origin: Optional[PointType] = None): """Mirroring an operation will create an inside-out block but automatic reordering of all vertices would create confusion. To avoid both, bottom and top face are swapped after mirroring so that original and mirrored lofts face the same z-direction.""" super().mirror(normal, origin) self.invert() return self def transform(self, transforms): mirrors = [isinstance(part, Mirror) for part in transforms] if any(mirrors): warnings.warn( "Using a transform with a Mirror on an Operation; " "this will invert it - use Operation.invert() to put it back in shape", stacklevel=1, ) return super().transform(transforms) @classmethod def from_series(cls, faces: list[Face]) -> "Operation": """Creates a Loft from a list of faces. At least two are required. From faces in between, side edges are created: - 2 faces: no side edges - 3: Arcs - 4 or more: Splines""" if len(faces) < 2: raise ValueError("Provide at least two faces!") loft = cls(faces[0], faces[-1]) if len(faces) == 2: return loft edge_points: list[list[NPPointType]] = [] # collect points for splines on each side for i in range(4): points = [] for face in faces[1:-1]: points.append(face.points[i].position) edge_points.append(points) # add edges according to collected points for i in range(4): if len(edge_points[i]) == 1: loft.add_side_edge(i, Arc(edge_points[i][0])) continue loft.add_side_edge(i, Spline(edge_points[i])) return loft ================================================ FILE: src/classy_blocks/construct/operations/revolve.py ================================================ from classy_blocks.cbtyping import VectorType from classy_blocks.construct import edges from classy_blocks.construct.flat.face import Face from classy_blocks.construct.operations.loft import Loft class Revolve(Loft): """Takes a Face and revolves it by angle around axis; axis can be translated so that it goes through desired origin. Angle is given in radians, revolve is in positive sense (counter-clockwise - right hand rule)""" def __init__(self, base: Face, angle: float, axis: VectorType, origin: VectorType): self.base = base self.angle = angle self.axis = axis self.origin = origin bottom_face = base top_face = base.copy().rotate(angle, axis, origin) super().__init__(bottom_face, top_face) # there are 4 side edges: the simplest is to use 'axis and angle' for i in range(4): self.add_side_edge(i, edges.Angle(self.angle, self.axis)) ================================================ FILE: src/classy_blocks/construct/operations/wedge.py ================================================ from typing import Optional from classy_blocks.construct.flat.face import Face from classy_blocks.construct.operations.revolve import Revolve from classy_blocks.util import functions as f class Wedge(Revolve): """Revolves 'face' around x-axis symetrically by +/- angle/2. By default, the angle is 2 degrees. Used for creating wedge-type geometries for axisymmetric cases. Automatically creates wedge patches* (you still need to include them in changeDictionaryDict - type: wedge). * - default naming of block sides is not very intuitive for wedge geometry so additional names are available for wedges: set_inner_patch() (= 'front') set_outer_patch() (= 'back') other two patches are wedge_left and wedge_right. Sides are named according to this sketch: outer _________________________________ | | | left right | |_______________________________| inner __ _____ __ _____ __ _____ __ __ axis of symmetry (x)""" def __init__(self, face: Face, angle: Optional[float] = None): if angle is None: angle = f.deg2rad(2) # default axis axis = [1.0, 0.0, 0.0] # default origin origin = [0.0, 0.0, 0.0] # first, rotate this face forward, then use init this as Revolve # and rotate the same face base = face.copy().rotate(-angle / 2, axis, origin) super().__init__(base, angle, axis, origin) # assign 'wedge_left' and 'wedge_right' patches super().set_patch("top", "wedge_front") super().set_patch("bottom", "wedge_back") # there's also only 1 cell in z-direction self.chop(2, count=1) def set_inner_patch(self, name: str) -> None: """Set patch closest to axis of rotation (x)""" super().set_patch("front", name) def set_outer_patch(self, name: str) -> None: """Set patch away from axis of rotation (x)""" super().set_patch("back", name) ================================================ FILE: src/classy_blocks/construct/point.py ================================================ from typing import Optional, TypeVar import numpy as np from classy_blocks.base.element import ElementBase from classy_blocks.base.exceptions import PointCreationError from classy_blocks.cbtyping import NPVectorType, PointType, ProjectToType, VectorType from classy_blocks.util import functions as f from classy_blocks.util.constants import DTYPE, TOL, vector_format PointT = TypeVar("PointT", bound="Point") class Point(ElementBase): """A 3D point in space with optional projection to a set of surfaces and transformations""" def __init__(self, position: PointType): self.position = np.array(position, dtype=DTYPE) if not np.shape(self.position) == (3,): raise PointCreationError("Provide a point in 3D space", f"Position: {position}") self._projected_to: set[str] = set() def move_to(self, position: PointType) -> None: """Move this point to supplied position""" self.position[0] = position[0] self.position[1] = position[1] self.position[2] = position[2] def translate(self, displacement): """Move this point by 'displacement' vector""" self.position += np.asarray(displacement, dtype=DTYPE) return self def rotate(self, angle, axis, origin: Optional[PointType] = None): """Rotate this point around an arbitrary axis and origin""" if origin is None: origin = f.vector(0, 0, 0) self.position = f.rotate(self.position, angle, f.unit_vector(axis), origin) return self def scale(self, ratio, origin: Optional[PointType] = None): """Scale point's position around origin.""" if origin is None: origin = f.vector(0, 0, 0) self.position = f.scale(self.position, ratio, origin) return self def mirror(self, normal: VectorType, origin: Optional[PointType] = None): """Mirror (reflect) the point around a plane, defined by normal vector and a passing point""" if origin is None: origin = f.vector(0, 0, 0) self.position = f.mirror(self.position, normal, origin) return self def shear(self, normal: VectorType, origin: PointType, direction: VectorType, angle: float): """Move point along the plane, given by origin and normal""" # if the point is on the plane, do nothing normal = np.asarray(normal, dtype=DTYPE) origin = np.asarray(origin, dtype=DTYPE) distance = f.point_to_plane_distance(origin, normal, self.position) if distance > TOL: direction = f.unit_vector(direction) amount = distance / np.tan(angle) self.position += direction * amount return self def project(self, label: ProjectToType) -> None: """Project this vertex to a single or multiple geometries""" if not isinstance(label, list): label = [label] self._projected_to.update(label) @property def parts(self): return [self] @property def center(self): return self.position @property def projected_to(self) -> list[str]: return sorted(list(self._projected_to)) def __eq__(self, other): return f.norm(self.position - other.position) < TOL def __repr__(self): return f"Point {vector_format(self.position)}" def __str__(self): return repr(self) class Vector(Point): """An 'alias' to avoid confusion in mathematical lingo""" @property def components(self) -> NPVectorType: """Vector's components (same as point's position but more grammatically/mathematically accurate)""" return self.position def __repr__(self): return f"Vector {vector_format(self.position)}" ================================================ FILE: src/classy_blocks/construct/series.py ================================================ from typing import Optional import numpy as np from classy_blocks.base.element import ElementBase from classy_blocks.base.exceptions import ArrayCreationError from classy_blocks.cbtyping import PointListType, PointType, VectorType from classy_blocks.util import functions as f from classy_blocks.util.constants import DTYPE, TOL class Series(ElementBase): def __init__(self, points: PointListType): """A list of points ('positions') in 3D space""" self.points = np.array(points, dtype=DTYPE) shape = np.shape(self.points) if shape[1] != 3: raise ArrayCreationError("Provide a list of points of 3D space!") if len(self.points) <= 1: raise ArrayCreationError("Provide at least 2 points in 3D space!") def translate(self, displacement): self.points += np.asarray(displacement, dtype=DTYPE) return self def rotate(self, angle, axis, origin: Optional[PointType] = None): if origin is None: origin = f.vector(0, 0, 0) axis = np.array(axis) matrix = f.rotation_matrix(axis, angle) rotated_points = np.dot(self.points - origin, matrix.T) self.points = rotated_points + origin return self def scale(self, ratio, origin: Optional[PointType] = None): if origin is None: origin = f.vector(0, 0, 0) self.points = origin + (self.points - origin) * ratio return self def mirror(self, normal: VectorType, origin: Optional[PointType] = None): if origin is None: origin = f.vector(0, 0, 0) self.points = f.mirror(self.points, normal, origin) return self def shear(self, normal: VectorType, origin: PointType, direction: VectorType, angle: float): """Move point along the plane, given by origin and normal""" # if the point is on the plane, do nothing normal = np.asarray(normal, dtype=DTYPE) origin = np.asarray(origin, dtype=DTYPE) # TODO: do this within a single array (numba?) for i, point in enumerate(self.points): if f.point_to_plane_distance(origin, normal, point) > TOL: distance = f.point_to_plane_distance(origin, normal, point) direction = f.unit_vector(direction) amount = distance / np.tan(angle) self.points[i] += direction * amount return self @property def center(self): return np.average(self.points, axis=0) @property def parts(self): return [self] def __len__(self): return len(self.points) def __getitem__(self, i): return self.points[i] ================================================ FILE: src/classy_blocks/construct/shape.py ================================================ """Abstract base classes for different Shape types""" import abc from typing import Generic, Optional, TypeVar, Union import numpy as np from classy_blocks.base.element import ElementBase from classy_blocks.base.exceptions import ShapeCreationError from classy_blocks.cbtyping import DirectionType, NPPointType, VectorType from classy_blocks.construct.edges import Angle from classy_blocks.construct.flat.sketch import Sketch, SketchT from classy_blocks.construct.operations.loft import Loft from classy_blocks.construct.operations.operation import Operation from classy_blocks.util import functions as f ShapeT = TypeVar("ShapeT", bound="Shape") class Shape(ElementBase, abc.ABC): """A collection of Operations that form a predefined parametric shape""" @property @abc.abstractmethod def operations(self) -> list[Operation]: """Operations from which the shape is build""" @property def parts(self): return self.operations def set_cell_zone(self, cell_zone: str) -> None: """Sets cell zone for all blocks in this shape""" for operation in self.operations: operation.set_cell_zone(cell_zone) @property def center(self) -> NPPointType: """Geometric mean of centers of all operations""" return np.average([operation.center for operation in self.operations], axis=0) @property @abc.abstractmethod def grid(self) -> list[list[Operation]]: """A 2-dimensional array consisting of Operations, grouped by their position (like [[core], [shell]] or [[rows], [columns]])""" class LoftedShape(Shape, abc.ABC, Generic[SketchT]): """A Shape, obtained by taking a two and transforming it once or twice (middle/end cross-section), then making profiled Lofts from calculated cross-sections (Elbow, Cylinder, Ring, ...""" def __init__( self, sketch_1: SketchT, sketch_2: SketchT, sketch_mid: Optional[Union[SketchT, list[SketchT]]] = None ): if len(sketch_1.faces) != len(sketch_2.faces): raise ShapeCreationError("Start and end sketch have different number of faces!") if sketch_mid is None: sketch_mid = [] else: if not isinstance(sketch_mid, list): sketch_mid = [sketch_mid] if any([len(sketch_mid_i.faces) != len(sketch_1.faces) for sketch_mid_i in sketch_mid]): raise ShapeCreationError("Mid sketch has a different number of faces from start/end!") self.sketch_1 = sketch_1 self.sketch_2 = sketch_2 self.sketch_mid = sketch_mid self.lofts: list[list[Loft]] = [] for i, list_1 in enumerate(self.sketch_1.grid): self.lofts.append([]) for j, face_1 in enumerate(list_1): face_2 = self.sketch_2.grid[i][j] mid_faces = [sketch.grid[i][j] for sketch in sketch_mid] loft = Loft.from_series([face_1, *mid_faces, face_2]) self.lofts[-1].append(loft) def set_start_patch(self, name: str) -> None: """Assign the faces of start sketch to a named patch""" for operation in self.operations: operation.set_patch("bottom", name) def set_end_patch(self, name: str) -> None: """Assign the faces of end sketch to a named patch""" for operation in self.operations: operation.set_patch("top", name) @property def operations(self): return f.flatten_2d_list(self.lofts) @property def grid(self): """Analogous to Sketch's grid but corresponsing operations are returned""" return self.lofts def chop(self, axis: DirectionType, **kwargs) -> None: """Chops operations along given axis. Only axis 0 and 1 are allowed as defined in sketch_1""" if axis == 2: self.operations[0].chop(2, **kwargs) else: for index in self.sketch_1.chops[axis]: self.operations[index].chop(axis, **kwargs) class ExtrudedShape(LoftedShape): """Analogous to an Extrude operation but on a Sketch""" # TODO: make operations universal - work on faces or sketches equally def __init__(self, sketch: Sketch, amount: Union[float, VectorType]): if isinstance(amount, float) or isinstance(amount, int): extrude_vector = sketch.normal * amount else: extrude_vector = np.asarray(amount) bottom_sketch = sketch top_sketch = bottom_sketch.copy().translate(extrude_vector) super().__init__(bottom_sketch, top_sketch) class RevolvedShape(LoftedShape): def __init__(self, sketch: Sketch, angle: float, axis: VectorType, origin: VectorType): bottom_sketch = sketch top_sketch = bottom_sketch.copy().rotate(angle, axis, origin) super().__init__(bottom_sketch, top_sketch) # add side edges for operation in self.operations: for i in range(4): operation.add_side_edge(i, Angle(angle, axis)) ================================================ FILE: src/classy_blocks/construct/shapes/__init__.py ================================================ ================================================ FILE: src/classy_blocks/construct/shapes/cylinder.py ================================================ from typing import ClassVar, Union import numpy as np from classy_blocks.base import transforms as tr from classy_blocks.base.exceptions import CylinderCreationError from classy_blocks.cbtyping import PointType from classy_blocks.construct.flat.sketches.disk import Disk, HalfDisk, QuarterDisk from classy_blocks.construct.shapes.rings import ExtrudedRing from classy_blocks.construct.shapes.round import RoundSolidShape from classy_blocks.util import functions as f from classy_blocks.util.constants import TOL class SemiCylinder(RoundSolidShape): """Half of a cylinder; it is constructed from given point and axis in a positive sense - right-hand rule. Args: axis_point_1: position of start face axis_point_2: position of end face radius_point_1: defines starting point and radius""" sketch_class: ClassVar[Union[type[Disk], type[HalfDisk], type[QuarterDisk]]] = HalfDisk def __init__(self, axis_point_1: PointType, axis_point_2: PointType, radius_point_1: PointType): axis_point_1 = np.asarray(axis_point_1) axis = np.asarray(axis_point_2) - axis_point_1 radius_point_1 = np.asarray(radius_point_1) diff = np.dot(axis, radius_point_1 - axis_point_1) if diff > TOL: raise CylinderCreationError( "Axis and radius vectors are not perpendicular", f"Difference: {diff}, tolerance: {TOL}" ) transform_2: list[tr.Transformation] = [tr.Translation(axis)] super().__init__(self.sketch_class(axis_point_1, radius_point_1, axis), transform_2, None) def set_symmetry_patch(self, name: str) -> None: self.shell[0].set_patch("front", name) self.shell[3].set_patch("back", name) self.core[0].set_patch("front", name) self.core[1].set_patch("front", name) class QuarterCylinder(RoundSolidShape): """Quarter of a cylinder; it is constructed from given point and axis in a positive sense - right-hand rule. Args: axis_point_1: position of start face axis_point_2: position of end face radius_point_1: defines starting point and radius""" sketch_class: ClassVar[Union[type[Disk], type[HalfDisk], type[QuarterDisk]]] = QuarterDisk def __init__(self, axis_point_1: PointType, axis_point_2: PointType, radius_point_1: PointType): axis_point_1 = np.asarray(axis_point_1) axis = np.asarray(axis_point_2) - axis_point_1 radius_point_1 = np.asarray(radius_point_1) diff = np.dot(axis, radius_point_1 - axis_point_1) if diff > TOL: raise CylinderCreationError( "Axis and radius vectors are not perpendicular", f"Difference: {diff}, tolerance: {TOL}" ) transform_2: list[tr.Transformation] = [tr.Translation(axis)] super().__init__(self.sketch_class(axis_point_1, radius_point_1, axis), transform_2, None) def set_symmetry_patch(self, name: str) -> None: self.shell[0].set_patch("front", name) self.shell[1].set_patch("back", name) self.core[0].set_patch("front", name) self.core[0].set_patch("left", name) class Cylinder(SemiCylinder): sketch_class = Disk @classmethod def chain(cls, source: RoundSolidShape, length: float, start_face: bool = False) -> "Cylinder": """Creates a new Cylinder on start or end face of a round Shape (Elbow, Frustum, Cylinder); Use length > 0 to extrude 'forward' from source's end face; Use length > 0 and `start_face=True` to extrude 'backward' from source's start face""" if length < 0: raise CylinderCreationError( "`chain()` operation failed: use a positive length and `start_face=True` to chain 'backwards'", f"Given length: {length}, `start_face={start_face}`", ) if start_face: sketch = source.sketch_1 length = -length else: sketch = source.sketch_2 axis_point_1 = sketch.center radius_point_1 = sketch.radius_point normal = sketch.normal axis_point_2 = axis_point_1 + f.unit_vector(normal) * length return cls(axis_point_1, axis_point_2, radius_point_1) @classmethod def fill(cls, source: "ExtrudedRing") -> "Cylinder": """Fills the inside of the ring with a matching cylinder""" if source.sketch_1.n_segments != 8: raise CylinderCreationError( "`chain()` operation failed: nly rings made from 8 segments can be filled", f"{source.sketch_1.n_segments} segments available", ) return cls(source.sketch_1.center, source.sketch_2.center, source.sketch_1.inner_radius_point) def set_symmetry_patch(self, _name: str) -> None: raise RuntimeError("Cylinder does not have symmetry patches") ================================================ FILE: src/classy_blocks/construct/shapes/elbow.py ================================================ import numpy as np from classy_blocks.base import transforms as tr from classy_blocks.base.exceptions import ElbowCreationError from classy_blocks.cbtyping import PointType, VectorType from classy_blocks.construct.flat.sketches.disk import Disk from classy_blocks.construct.shapes.round import RoundSolidShape from classy_blocks.util import functions as f class Elbow(RoundSolidShape): """A curved round shape of varying cross-section""" def __init__( self, center_point_1: PointType, radius_point_1: PointType, normal_1: VectorType, sweep_angle: float, arc_center: PointType, rotation_axis: VectorType, radius_2: float, ): radius_1 = f.norm(np.asarray(radius_point_1) - np.asarray(center_point_1)) sketch_1 = Disk(center_point_1, radius_point_1, normal_1) radius_ratio = radius_2 / radius_1 transform_2 = [tr.Rotation(rotation_axis, sweep_angle, arc_center), tr.Scaling(radius_ratio)] transform_mid = [ tr.Rotation(rotation_axis, sweep_angle / 2, arc_center), tr.Scaling((1 + radius_ratio) / 2), ] super().__init__(sketch_1, transform_2, transform_mid) @classmethod def chain( cls, source: RoundSolidShape, sweep_angle: float, arc_center: PointType, rotation_axis: VectorType, radius_2: float, start_face: bool = False, ) -> "Elbow": """Use another round Shape's end face as a starting point for this Elbow; Returns a new Elbow object. To start from the other side, use start_face = True""" if not isinstance(source.sketch_1, Disk): raise ElbowCreationError( "`chain()` operation failed: expecting `Disk`-type face", f"Given `{type(source.sketch_1)}` face instance", ) if start_face: sketch = source.sketch_1 else: sketch = source.sketch_2 return cls(sketch.center, sketch.radius_point, sketch.normal, sweep_angle, arc_center, rotation_axis, radius_2) ================================================ FILE: src/classy_blocks/construct/shapes/frustum.py ================================================ from typing import Optional import numpy as np from classy_blocks.base import transforms as tr from classy_blocks.base.exceptions import FrustumCreationError from classy_blocks.cbtyping import PointType from classy_blocks.construct.flat.sketches.disk import Disk from classy_blocks.construct.shapes.round import RoundSolidShape from classy_blocks.util import functions as f from classy_blocks.util.constants import TOL class Frustum(RoundSolidShape): """Creates a cone frustum (truncated cylinder). Args: axis_point_1: position of the starting face and axis start point axis_point_2: position of the end face and axis end point radius_point_1: defines starting point for blocks radius_2: defines end radius; NOT A POINT! Sides are straight unless radius_mid is given; in that case a profiled body of revolution is created.""" sketch_class = Disk def __init__( self, axis_point_1: PointType, axis_point_2: PointType, radius_point_1: PointType, radius_2: float, radius_mid: Optional[float] = None, ): axis_point_1 = np.asarray(axis_point_1) axis = np.asarray(axis_point_2) - axis_point_1 radius_point_1 = np.asarray(radius_point_1) radius_vector_1 = radius_point_1 - axis_point_1 radius_1 = f.norm(radius_vector_1) diff = np.dot(axis, radius_vector_1) if diff > TOL: raise FrustumCreationError( "Axis and radius vectors are not perpendicular", f"Difference: {diff}, tolerance: {TOL}" ) transform_2 = [tr.Translation(axis_point_2 - axis_point_1), tr.Scaling(radius_2 / radius_1)] if radius_mid is None: transform_mid = None else: transform_mid = [tr.Translation(axis / 2), tr.Scaling(radius_mid / radius_1)] super().__init__(self.sketch_class(axis_point_1, radius_point_1, axis), transform_2, transform_mid) @classmethod def chain( cls, source: RoundSolidShape, length: float, radius_2: float, start_face: bool = False, radius_mid: Optional[float] = None, ) -> "Frustum": """Chain this Frustum to an existing Shape; Use length > 0 to begin on source's end face; Use length > 0 and `start_face=True` to begin on source's start face and go backwards """ if length < 0: raise FrustumCreationError( "`chain()` operation failed: use a positive length and `start_face=True` to chain 'backwards'", f"Given length: {length}, `start_face={start_face}`", ) if start_face: sketch = source.sketch_1 length = -length else: sketch = source.sketch_2 axis_point_2 = sketch.center + f.unit_vector(sketch.normal) * length return cls(sketch.center, axis_point_2, sketch.radius_point, radius_2, radius_mid) ================================================ FILE: src/classy_blocks/construct/shapes/rings.py ================================================ from typing import Union import numpy as np from classy_blocks.base import transforms as tr from classy_blocks.base.exceptions import ExtrudedRingCreationError from classy_blocks.cbtyping import OrientType, PointType from classy_blocks.construct.flat.face import Face from classy_blocks.construct.flat.sketches.annulus import Annulus from classy_blocks.construct.operations.operation import Operation from classy_blocks.construct.operations.revolve import Revolve from classy_blocks.construct.shapes.round import RoundHollowShape, RoundSolidShape from classy_blocks.util import functions as f class ExtrudedRing(RoundHollowShape): """A ring, created by specifying its base, then extruding it""" def __init__( self, axis_point_1: PointType, axis_point_2: PointType, outer_radius_point_1: PointType, inner_radius: float, n_segments: int = 8, ): axis = np.asarray(axis_point_2) - np.asarray(axis_point_1) super().__init__( Annulus(axis_point_1, outer_radius_point_1, axis, inner_radius, n_segments), [tr.Translation(axis)], None, ) @classmethod def chain(cls, source: "ExtrudedRing", length: float, start_face: bool = False) -> "ExtrudedRing": """Creates a new ExtrudedRing on end face of source ring; use start_face=False to chain 'backwards' from the first face""" if length < 0: raise ExtrudedRingCreationError( "`chain()` operation failed: use a positive length and `start_face=True` to chain 'backwards'", f"Given length: {length}, `start_face={start_face}`", ) if start_face: sketch = source.sketch_1 length = -length else: sketch = source.sketch_2 return cls( sketch.center, sketch.center + f.unit_vector(sketch.normal) * length, sketch.outer_radius_point, sketch.inner_radius, n_segments=sketch.n_segments, ) @classmethod def expand(cls, source: Union[RoundSolidShape, RoundHollowShape], thickness: float) -> "ExtrudedRing": """Create a new concentric Ring with radius, enlarged by 'thickness'; Can be used on Cylinder or ExtrudedRing""" sketch_1 = source.sketch_1 sketch_2 = source.sketch_2 new_radius_point = sketch_1.center + f.unit_vector(sketch_1.radius_point - sketch_1.center) * ( sketch_1.radius + thickness ) return cls(sketch_1.center, sketch_2.center, new_radius_point, sketch_1.radius, n_segments=sketch_1.n_segments) @classmethod def contract(cls, source: "ExtrudedRing", inner_radius: float) -> "ExtrudedRing": """Create a new ring on inner surface of the source""" if inner_radius <= 0: raise ExtrudedRingCreationError( "Unable to perform `contract()` operation for inner radius < 0: use `Cylinder.fill(extruded_ring)`", f"Inner radius: {inner_radius}", ) sketch_1 = source.sketch_1 sketch_2 = source.sketch_2 if inner_radius > sketch_1.inner_radius: raise ExtrudedRingCreationError( "Unable to perform `contract()` operation: new inner radius must be smaller than source's", f"Inner radius: {inner_radius}, sketch inner radius: {sketch_1.inner_radius}", ) return cls( sketch_1.center, sketch_2.center, sketch_1.inner_radius_point, inner_radius, n_segments=sketch_1.n_segments ) class RevolvedRing(ExtrudedRing): """A ring specified by its cross-section; can be of arbitrary shape. Face points must be specified in the following order: p3---___ / ---p2 / \\ p0---------------p1 0---- -- ----- -- ----- -- ----- -- --->> axis In this case, chop_*() will work as intended, otherwise the axes will be swapped or blocks will be inverted. Because of RevolvedRing's arbitrary shape, there is no 'start' or 'end' sketch and .expand()/.contract() methods are not available. This shape is useful when building more complex shapes of revolution (with non-orthogonal blocks) from known 2d-blocking in cross-section.""" # TODO: automatic point sorting to match ExtrudedRing numbering? axial_axis = 0 radial_axis = 1 tangential_axis = 2 outer_patch: OrientType = "back" def __init__(self, axis_point_1: PointType, axis_point_2: PointType, cross_section: Face, n_segments: int = 8): self.axis_point_1 = np.asarray(axis_point_1) self.axis_point_2 = np.asarray(axis_point_2) self.axis = self.axis_point_2 - self.axis_point_1 self.center_point = self.axis_point_1 angle = 2 * np.pi / n_segments revolve = Revolve(cross_section, angle, self.axis, self.axis_point_1) self.revolves: list[Operation] = [ revolve.copy().rotate(i * angle, self.axis, self.center_point) for i in range(n_segments) ] def set_inner_patch(self, name: str) -> None: """Assign the faces of inside surface to a named patch""" for operation in self.operations: operation.set_patch("front", name) def set_start_patch(self, name: str) -> None: """Assign the faces of start sketch to a named patch""" for operation in self.operations: operation.set_patch("left", name) def set_end_patch(self, name: str) -> None: """Assign the faces of end sketch to a named patch""" for operation in self.operations: operation.set_patch("right", name) # methods/properties that differ from a lofted-sketch type of shape @property def operations(self) -> list[Operation]: return self.revolves def chop_axial(self, **kwargs): self.operations[0].chop(self.axial_axis, **kwargs) ================================================ FILE: src/classy_blocks/construct/shapes/round.py ================================================ from collections.abc import Sequence from typing import Optional from classy_blocks.base import transforms as tr from classy_blocks.cbtyping import DirectionType, OrientType from classy_blocks.construct.flat.sketch import Sketch from classy_blocks.construct.flat.sketches.annulus import Annulus from classy_blocks.construct.operations.loft import Loft from classy_blocks.construct.shape import LoftedShape class RoundSolidShape(LoftedShape): axial_axis: DirectionType = 2 # Axis along which 'outer sides' run radial_axis: DirectionType = 0 # Axis that goes from center to 'outer side' tangential_axis: DirectionType = 1 # Axis that goes around the circumference of the shape start_patch: OrientType = "bottom" # Sides of blocks that define the start patch end_patch: OrientType = "top" # Sides of blocks that define the end patch""" outer_patch: OrientType = "right" # Sides of blocks that define the outer surface def __init__( self, sketch_1: Sketch, sketch_2_transform: Sequence[tr.Transformation], sketch_mid_transform: Optional[Sequence[tr.Transformation]] = None, ): # start with sketch_1 and transform it # using the _transform function(transform_2_args) to obtain sketch_2; # use _transform function(transform_mid_args) to obtain mid sketch # (only if applicable) sketch_1 = sketch_1 sketch_2 = sketch_1.copy().transform(sketch_2_transform) sketch_mid: Optional[Sketch] = None if sketch_mid_transform is not None: sketch_mid = sketch_1.copy().transform(sketch_mid_transform) super().__init__(sketch_1, sketch_2, sketch_mid) def chop_axial(self, **kwargs): """Chop the shape between start and end face""" super().chop(self.axial_axis, **kwargs) def chop_radial(self, **kwargs): """Chop the outer 'ring', or 'shell'; core blocks will be defined by tangential chops""" # scale all radial sizes to this ratio or core cells will be # smaller than shell's c2s_ratio = max(self.sketch_1.diagonal_ratio, self.sketch_1.core_ratio) if "start_size" in kwargs: kwargs["start_size"] *= c2s_ratio if "end_size" in kwargs: kwargs["end_size"] *= c2s_ratio super().chop(self.radial_axis, **kwargs) def chop_tangential(self, **kwargs): """Circumferential chop; also defines core sizes""" super().chop(self.tangential_axis, **kwargs) @property def core(self): """Operations in the center of the shape""" return self.operations[: len(self.sketch_1.core)] @property def shell(self): """Operations on the outside of the shape""" return self.operations[len(self.sketch_1.core) :] def set_outer_patch(self, name: str) -> None: for operation in self.shell: operation.set_patch(self.outer_patch, name) def remove_inner_edges(self, start: bool = True, end: bool = True) -> None: """Removes spline edges from cylinders. This needs to be done in cases where any of the start/end plane points will move (due to optimization or manual adjustments).""" if start: for face in self.sketch_1.core: face.remove_edges() if end: for face in self.sketch_2.core: face.remove_edges() class RoundHollowShape(RoundSolidShape): def __init__( self, sketch_1: Annulus, sketch_2_transform: list[tr.Transformation], sketch_mid_transform: Optional[list[tr.Transformation]] = None, ): super().__init__(sketch_1, sketch_2_transform, sketch_mid_transform) @property def shell(self) -> list[Loft]: """The 'outer' (that is, 'all') operations""" return self.operations def chop_tangential(self, **kwargs) -> None: """Circumferential chop""" # Ring has no 'core' so tangential chops must be defined explicitly for operation in self.shell: operation.chop(self.tangential_axis, **kwargs) def chop_radial(self, **kwargs): """Chop the outer 'ring', or 'shell'""" self.shell[0].chop(self.radial_axis, **kwargs) def set_inner_patch(self, name: str) -> None: """Assign the faces of inside surface to a named patch""" for operation in self.shell: operation.set_patch("left", name) ================================================ FILE: src/classy_blocks/construct/shapes/shell.py ================================================ import functools import numpy as np from classy_blocks.base.exceptions import DisconnectedChopError, PointNotCoincidentError, SharedPointNotFoundError from classy_blocks.cbtyping import NPPointType, NPVectorType from classy_blocks.construct.flat.face import Face from classy_blocks.construct.operations.loft import Loft from classy_blocks.construct.point import Point from classy_blocks.construct.shape import Shape from classy_blocks.util import functions as f class SharedPoint: """A Point with knowledge of its "owner" Face(s)""" def __init__(self, point: Point): self.point = point self.faces: list[Face] = [] self.indexes: list[int] = [] def add(self, face: Face, index: int) -> None: """Adds an identifies face's point to the list of points at the same position""" if face.points[index] != self.point: raise PointNotCoincidentError for i, this_face in enumerate(self.faces): if this_face == face: if index == self.indexes[i]: # don't add the same face twice return self.faces.append(face) self.indexes.append(index) @property def normal(self) -> NPVectorType: """Normal of this BoundPoint is the average normal of all touching faces""" normals = [f.normal for f in self.faces] return f.unit_vector(np.sum(normals, axis=0)) @property def is_shared(self) -> bool: """Returns False if this point is not shared with any other face in the collection""" return len(self.faces) > 1 def __eq__(self, other): return self.point == other.point class SharedPointStore: """A collection of shared points""" def __init__(self) -> None: self.shared_points: list[SharedPoint] = [] def find_by_point(self, point: Point) -> SharedPoint: for shpoint in self.shared_points: if shpoint.point == point: return shpoint raise SharedPointNotFoundError def add_from_face(self, face: Face, index: int) -> SharedPoint: """Returns a shared point at specified location or creates a new one there""" point = face.points[index] try: shpoint = self.find_by_point(point) except SharedPointNotFoundError: shpoint = SharedPoint(point) self.shared_points.append(shpoint) shpoint.add(face, index) return shpoint class AwareFace: """A face that is aware of their neighbours by using shared points""" def __init__(self, face: Face, shared_points: list[SharedPoint]): self.face = face self.shared_points = shared_points def get_offset_points(self, amount: float) -> list[NPPointType]: """Offsets self.face in direction prescribed by shared points""" return [self.face.points[i].copy().translate(self.shared_points[i].normal * amount).position for i in range(4)] def get_offset_face(self, amount: float) -> Face: return Face(self.get_offset_points(amount)) @property def is_solitary(self) -> bool: """Returns True is this Face is not adjacent to any other face in the store""" return not any(sp.is_shared for sp in self.shared_points) class AwareFaceStore: """Operations on a number of faces; used for creating offset shapes a.k.a. Shell""" def __init__(self, faces: list[Face]): self.faces = faces @functools.cached_property def point_store(self): points_store = SharedPointStore() for face in self.faces: for i in range(4): points_store.add_from_face(face, i) return points_store def get_aware_face(self, face) -> AwareFace: shared_points = [self.point_store.find_by_point(face.points[i]) for i in range(4)] return AwareFace(face, shared_points) @functools.cached_property def aware_faces(self) -> list[AwareFace]: aware_faces: list[AwareFace] = [] for face in self.faces: aware_faces.append(self.get_aware_face(face)) return aware_faces def get_offset_lofts(self, amount: float) -> list[Loft]: offset_faces = [awf.get_offset_face(amount) for awf in self.aware_faces] return [Loft(face, offset_faces[i]) for i, face in enumerate(self.faces)] @functools.cached_property def is_disconnected(self) -> bool: """Returns True if there are faces that are not connected to any other face by any point""" for face in self.faces: if self.get_aware_face(face).is_solitary: return True return False class Shell(Shape): """A Shape, created by offsetting faces. It will contain as many Lofts as there are faces; edges and projections will be dropped. Points are offset in direction normal to their owner face; in case multiple faces share the same point, average normal is taken. Shell.operations will hold Lofts in the same order as passed faces. Use axis=2 for chopping in offset direction.""" def __init__(self, faces: list[Face], amount: float): self.faces = faces self.amount = amount self.aware_face_store = AwareFaceStore(self.faces) self.lofts = self.aware_face_store.get_offset_lofts(self.amount) @property def operations(self): return self.lofts @property def grid(self): return [self.operations] def chop(self, **kwargs) -> None: """Chop in offset direction""" # The lofts should be of approximately the same 'height'; # therefore it doesn't matter which one to chop. BUT! # Only chop one of them because slight differences caused by # averaging might produce different counts. # issue a warning when there are disconnected lofts if self.aware_face_store.is_disconnected: raise DisconnectedChopError("There are unconnected faces in this Shell; chop its operations manually") self.operations[0].chop(2, **kwargs) def set_outer_patch(self, name: str) -> None: """Sets patch name for faces that have been offset""" for operation in self.operations: operation.set_patch("top", name) ================================================ FILE: src/classy_blocks/construct/shapes/sphere.py ================================================ import numpy as np from classy_blocks.cbtyping import NPPointType, NPVectorType, PointType, VectorType from classy_blocks.construct.flat.face import Face from classy_blocks.construct.flat.sketches.disk import QuarterDisk from classy_blocks.construct.operations.loft import Loft from classy_blocks.construct.shape import Shape from classy_blocks.util import constants from classy_blocks.util import functions as f def get_named_points(qdisk: QuarterDisk) -> dict[str, NPPointType]: """Returns a dictionary of named points for easier construction of sphere; points refer to QuarterDisk: P3 |******* P2 | 2 /** | / * S2----D * | 0 | 1 * |_____S1______* O P1 """ points = [face.point_array for face in qdisk.faces] return { "O": points[0][0], "P1": points[1][1], "P2": points[1][2], "P3": points[2][2], "S1": points[0][1], "S2": points[0][3], "D": points[0][2], } def eighth_sphere_lofts( center_point: NPPointType, radius_point: NPPointType, normal: NPVectorType, geometry_label: str, diagonal_angle: float = np.pi / 5, ) -> list[Loft]: """A collection of 4 lofts for an eighth of a sphere; used to construct all other sphere pieces and derivatives""" # An 8th of a sphere has 3 equal flat sides and one round; # the 'bottom' is the one perpendicular to given normal bottom = QuarterDisk(center_point, radius_point, normal) bpoints = get_named_points(bottom) # rotate a QuarterDisk twice around 'bottom' two edges to get them; axes = { # around 'O-P1', 'front': "front": bpoints["P1"] - bpoints["O"], # around 'O-P3', 'left': "left": bpoints["P3"] - bpoints["O"], # diagonal O-D is obtained by rotating around an at 45 degrees "diagonal": f.rotate(bpoints["P3"], np.pi / 4, normal, center_point) - bpoints["O"], } front = bottom.copy().rotate(np.pi / 2, axes["front"], center_point) fpoints = get_named_points(front) left = bottom.copy().rotate(-np.pi / 2, axes["left"], center_point) lpoints = get_named_points(left) point_du = f.rotate(bpoints["D"], -diagonal_angle, axes["diagonal"], center_point) point_p2u = f.rotate(bpoints["P2"], -diagonal_angle, axes["diagonal"], center_point) # 4 lofts for an eighth sphere, 1 core and 3 shell lofts: list[Loft] = [] # core core = Loft( bottom.core[0], Face([fpoints["S2"], fpoints["D"], point_du, lpoints["D"]]), ) lofts.append(core) # shell shell_1 = Loft(bottom.shell[0], Face([fpoints["D"], fpoints["P2"], point_p2u, point_du])) shell_1.project_side("right", geometry_label, edges=True) shell_2 = Loft(bottom.faces[2], Face([point_du, point_p2u, lpoints["P2"], lpoints["D"]])) shell_2.project_side("right", geometry_label, edges=True) shell_3 = Loft(shell_1.top_face, left.faces[1]) shell_3.project_side("right", geometry_label, edges=True) lofts += [shell_1, shell_2, shell_3] return lofts class EighthSphere(Shape): """One eighth of a sphere, the base shape everything sphere-related""" n_cores: int = 1 def __init__( self, center_point: PointType, radius_point: PointType, normal: VectorType, diagonal_angle: float = np.pi / 5 ): center_point = np.asarray(center_point) radius_point = np.asarray(radius_point) normal = f.unit_vector(np.asarray(normal)) rotated_core = [] rotated_shell = [] for i in range(self.n_cores): rotated_radius_point = f.rotate(radius_point, i * np.pi / 2, normal, center_point) rotated_eighth = eighth_sphere_lofts( center_point, rotated_radius_point, normal, self.geometry_label, diagonal_angle ) rotated_core.append(rotated_eighth[0]) rotated_shell += rotated_eighth[1:] self.lofts = rotated_core + rotated_shell ### Chopping def chop_axial(self, **kwargs): """Chop along given normal""" self.shell[0].chop(2, **kwargs) def chop_radial(self, **kwargs): """Chop along radius vector""" self.shell[0].chop(0, **kwargs) def chop_tangential(self, **kwargs): """Chop circumferentially""" for i, operation in enumerate(self.shell): if (i + 1) % 3 == 0: continue operation.chop(1, **kwargs) ### Patches def set_start_patch(self, name: str) -> None: for operation in self.core: operation.set_patch("bottom", name) for i, operation in enumerate(self.shell): if (i + 1) % 3 == 0: continue operation.set_patch("bottom", name) @property def operations(self): return self.lofts @property def core(self): return self.lofts[: self.n_cores] @property def shell(self): return self.lofts[self.n_cores :] @property def grid(self): return [self.core, self.shell] @property def radius_point(self) -> NPPointType: return self.shell[0].bottom_face.points[1].position @property def center_point(self) -> NPPointType: return self.lofts[0].bottom_face.points[0].position @property def normal(self) -> NPVectorType: return self.lofts[0].bottom_face.normal @property def radius(self) -> float: """Radius of this sphere""" return f.norm(self.radius_point - self.center_point) @property def geometry_label(self) -> str: """Name of a unique geometry this will project to""" return f"sphere_{id(self)}" @property def center(self): return self.center_point @property def geometry(self): return { self.geometry_label: [ "type searchableSphere", f"origin {constants.vector_format(self.center_point)}", f"centre {constants.vector_format(self.center_point)}", f"radius {self.radius}", ] } class QuarterSphere(EighthSphere): n_cores = 2 class Hemisphere(EighthSphere): # TODO: TEST n_cores: int = 4 @classmethod def chain(cls, source, start_face=False): """Chain this sphere to the end face of a round solid shape; use start_face=True to chain to te start face.""" # TODO: TEST if start_face: center_point = source.sketch_1.center radius_point = source.sketch_1.radius_point normal = -source.sketch_1.normal else: center_point = source.sketch_2.center radius_point = source.sketch_2.radius_point normal = source.sketch_2.normal return cls(center_point, radius_point, normal) def set_outer_patch(self, name): for operation in self.shell: operation.set_patch("right", name) ================================================ FILE: src/classy_blocks/construct/stack.py ================================================ from collections.abc import Sequence from typing import Optional, Union import numpy as np from classy_blocks.base import transforms as tr from classy_blocks.base.element import ElementBase from classy_blocks.cbtyping import DirectionType, PointType, VectorType from classy_blocks.construct.flat.sketch import Sketch from classy_blocks.construct.operations.operation import Operation from classy_blocks.construct.shape import LoftedShape from classy_blocks.util import functions as f class Stack(ElementBase): """A collection of topologically similar Shapes, stacked on top of each other.""" def __init__(self) -> None: self.shapes: list[LoftedShape] = [] @property def grid(self): """Returns all operations as a 3-dimensional list; first two dimensions within a shape, the third along the stack.""" return [shape.grid for shape in self.shapes] def get_slice(self, axis: DirectionType, index: int) -> list[Operation]: """Returns all operation with given index in specified axis. For cartesian grids this is equivalent to 'lofts on the same plane'; This does not work with custom/mapped sketches that do not conform to a cartesian grid. Example: A stack that consists of 3 shapes, created from a 2x5 grid. - get_slice(0, i) will return 15 operations (5x3, all operations with the same x-coordinate), - get_slice(1, i) will return 6 operations (2x3, all with the same y-coordinate), - get_slice(2, i) will return 10 operations (2x5, all with the same z-coordinate).""" if axis == 2: return self.shapes[index].operations operations: list[Operation] = [] if axis == 0: for shape in self.shapes: operations += [shape.grid[x][index] for x in range(len(shape.grid))] else: for shape in self.shapes: operations += [shape.grid[index][y] for y in range(len(shape.grid[index]))] return operations def chop(self, **kwargs) -> None: """Adds a chop in lofted/extruded/revolved direction to one operation in each shape in the stack.""" for shape in self.shapes: shape.grid[0][0].chop(2, **kwargs) @property def operations(self) -> list[Operation]: return f.flatten_2d_list([shape.operations for shape in self.shapes]) @property def parts(self): return self.shapes @property def center(self): return np.average([op.center for op in self.operations], axis=0) class TransformedStack(Stack): """A stack where each next tier's sketch is transformed according to a list of transformations, passed to constructor. Arc edges can be created by specifying a mid_transforms list. The transformations there refer to base sketch - its vertices will be used as arc points for all lofted edges.""" def __init__( self, base: Sketch, end_transforms: Sequence[tr.Transformation], repeats: int, mid_transforms: Optional[Sequence[tr.Transformation]] = None, ): super().__init__() sketch_1 = base for _ in range(repeats): sketch_2 = sketch_1.copy().transform(end_transforms) if mid_transforms is not None: sketch_mid = sketch_1.copy().transform(mid_transforms) else: sketch_mid = None shape = LoftedShape(sketch_1, sketch_2, sketch_mid) self.shapes.append(shape) sketch_1 = sketch_2.copy() class ExtrudedStack(TransformedStack): """Extruded shapes, stacked on top of each other. Amount is overall 'height' of the stack.""" def __init__(self, base: Sketch, amount: Union[float, VectorType], repeats: int): if isinstance(amount, float) or isinstance(amount, int): extrude_vector = base.normal * amount / repeats else: extrude_vector = np.asarray(amount) / repeats super().__init__(base, [tr.Translation(extrude_vector)], repeats) class RevolvedStack(TransformedStack): """Revolved shapes, stacked around the given center. Angle given is overall and is divided by repeats for each tier.""" def __init__(self, base: Sketch, angle: float, axis: VectorType, origin: PointType, repeats: int): super().__init__( base, [tr.Rotation(axis, angle / repeats, origin)], repeats, [tr.Rotation(axis, angle / repeats / 2, origin)], ) ================================================ FILE: src/classy_blocks/grading/__init__.py ================================================ ================================================ FILE: src/classy_blocks/grading/analyze/__init__.py ================================================ ================================================ FILE: src/classy_blocks/grading/analyze/catalogue.py ================================================ import functools from typing import get_args from classy_blocks.base.exceptions import BlockNotFoundError, NoInstructionError from classy_blocks.cbtyping import DirectionType from classy_blocks.grading.analyze.row import Row from classy_blocks.items.block import Block from classy_blocks.items.wires.axis import Axis from classy_blocks.lists.block_list import BlockList @functools.lru_cache(maxsize=9000) # that's for 3000 blocks def get_block_from_axis(block_list: BlockList, axis: Axis) -> Block: for block in block_list.blocks: if axis in block.axes: return block raise RuntimeError("Block for Axis not found!") class Instruction: """A descriptor that tells in which direction the specific block can be chopped.""" def __init__(self, block: Block): self.block = block self.directions: list[bool] = [False] * 3 @property def is_defined(self): return all(self.directions) def __hash__(self) -> int: return id(self) class RowCatalogue: """A collection of rows on a specified axis""" def __init__(self, block_list: BlockList): self.block_list = block_list self.rows: dict[DirectionType, list[Row]] = {0: [], 1: [], 2: []} self.instructions = [Instruction(block) for block in block_list.blocks] for i in get_args(DirectionType): self._populate(i) def _get_undefined_instructions(self, direction: DirectionType) -> list[Instruction]: return [i for i in self.instructions if not i.directions[direction]] def _find_instruction(self, block: Block): # TODO: perform dedumbing on this exquisite piece of code for instruction in self.instructions: if instruction.block == block: return instruction raise NoInstructionError(f"No instruction found for block {block}") def _add_block_to_row(self, row: Row, instruction: Instruction, direction: DirectionType) -> None: row.add_block(instruction.block, direction) instruction.directions[direction] = True block = instruction.block for neighbour_axis in block.axes[direction].neighbours: neighbour_block = get_block_from_axis(self.block_list, neighbour_axis) if neighbour_block in row.blocks: continue instruction = self._find_instruction(neighbour_block) self._add_block_to_row(row, instruction, neighbour_block.get_axis_direction(neighbour_axis)) def _populate(self, direction: DirectionType) -> None: while True: undefined_instructions = self._get_undefined_instructions(direction) if len(undefined_instructions) == 0: break row = Row() self._add_block_to_row(row, undefined_instructions[0], direction) self.rows[direction].append(row) def get_row_blocks(self, block: Block, direction: DirectionType) -> list[Block]: for row in self.rows[direction]: if block in row.blocks: return row.blocks raise BlockNotFoundError(f"Direction {direction} of {block} not in catalogue") ================================================ FILE: src/classy_blocks/grading/analyze/probe.py ================================================ import dataclasses from typing import Optional from classy_blocks.assemble.dump import AssembledDump from classy_blocks.assemble.settings import Settings from classy_blocks.base.exceptions import PatchNotFoundError from classy_blocks.cbtyping import DirectionType, OrientType from classy_blocks.grading.analyze.catalogue import RowCatalogue from classy_blocks.grading.analyze.row import Row from classy_blocks.items.block import Block from classy_blocks.items.wires.wire import Wire from classy_blocks.optimize.grid import HexGrid from classy_blocks.util.constants import DIRECTION_MAP @dataclasses.dataclass class WireInfo: """Gathers data about a wire; its location, cell sizes, neighbours and wires before/after""" wire: Wire starts_at_wall: bool ends_at_wall: bool @property def length(self) -> float: return self.wire.length @property def size_after(self) -> Optional[float]: """Returns average cell size in wires that come after this one (in series/inline); None if this is the last wire""" # TODO: merge this with size_before somehow sum_size: float = 0 defined_count: int = 0 for joint in self.wire.after: if joint.wire.grading.is_defined: defined_count += 1 if joint.same_dir: sum_size += joint.wire.grading.start_size else: sum_size += joint.wire.grading.end_size if defined_count == 0: return None return sum_size / defined_count @property def size_before(self) -> Optional[float]: """Returns average cell size in wires that come before this one (in series/inline); None if this is the first wire""" # TODO: merge this with size_after somehow sum_size: float = 0 defined_count: int = 0 for joint in self.wire.before: if joint.wire.grading.is_defined: defined_count += 1 if joint.same_dir: sum_size += joint.wire.grading.end_size else: sum_size += joint.wire.grading.start_size if defined_count == 0: return None return sum_size / defined_count class WireCatalogue: """A database of all wires' whereabouts; many wires can be located at the same spot and only some of them can be on 'walls'; gather data first so that wall-bounded wires aren't ignored""" def __init__(self, dump: AssembledDump, settings: Settings): self.dump = dump self.settings = settings # finds blocks' neighbours self.grid = HexGrid.from_dump(dump) # WireInfo is stored at indexes [vertex_1][vertex_2]; # if a coincident wire is inverted, it will reside at # [vertex_2][vertex_1] self.data: dict[int, dict[int, Optional[WireInfo]]] = {} self._populate() def _fetch(self, index_1: int, index_2: int) -> Optional[WireInfo]: if self.data.get(index_1) is None: self.data[index_1] = {} return self.data[index_1].get(index_2) def _populate(self) -> None: for block in self.dump.blocks: for wire in block.wire_list: start_index, end_index = wire.vertices[0].index, wire.vertices[1].index info = self._fetch(start_index, end_index) if info is None: info = WireInfo(wire, False, False) self.data[start_index][end_index] = info starts, ends = self._get_wire_boundaries(wire, block) # remember if any of the coincident wires starts or ends at the wall, info.starts_at_wall = info.starts_at_wall or starts info.ends_at_wall = info.ends_at_wall or ends def _get_wire_boundaries(self, wire: Wire, block: Block) -> tuple[bool, bool]: """Finds out whether a Wire starts or ends on a wall patch""" # only check patches, perpendicular to the wire; # wires lying on wall surfaces aren't eligible for wall/inflation grading start_orient = DIRECTION_MAP[wire.direction][0] end_orient = DIRECTION_MAP[wire.direction][1] block_index = self.dump.blocks.index(block) def is_on_wall(orient: OrientType) -> bool: if self.grid.cells[block_index].neighbours[orient] is not None: # this block side has a neighbour and cannot be a wall patch return False vertices = set(block.get_side_vertices(orient)) try: patch = self.dump.patch_list.find(vertices) if patch.kind == "wall": return True except PatchNotFoundError: if self.settings.default_patch.get("kind") == "wall": return True return False return (is_on_wall(start_orient), is_on_wall(end_orient)) def get_info(self, wire: Wire) -> WireInfo: info = self.data[wire.vertices[0].index][wire.vertices[1].index] if info is None: raise ValueError("Wire not found!") return info class Probe: """Examines the mesh and gathers required data for auto chopping""" def __init__(self, dump: AssembledDump, settings: Settings): # maps blocks to rows self.rows = RowCatalogue(dump.block_list) # build a wire database self.wires = WireCatalogue(dump, settings) def get_row_blocks(self, block: Block, direction: DirectionType) -> list[Block]: return self.rows.get_row_blocks(block, direction) def get_rows(self, direction: DirectionType) -> list[Row]: return self.rows.rows[direction] def get_wire_info(self, wire: Wire) -> WireInfo: return self.wires.get_info(wire) ================================================ FILE: src/classy_blocks/grading/analyze/row.py ================================================ import dataclasses from classy_blocks.base.exceptions import InconsistentGradingsError from classy_blocks.cbtyping import ChopTakeType, DirectionType from classy_blocks.items.block import Block from classy_blocks.items.wires.axis import Axis from classy_blocks.items.wires.wire import Wire @dataclasses.dataclass class Entry: block: Block # blocks of different orientations can belong to the same row; # remember how they are oriented heading: DirectionType # also keep track of blocks that are upside-down; # 'True' means the block is overturned flipped: bool @property def axis(self) -> Axis: return self.block.axes[self.heading] @property def wires(self) -> list[Wire]: return self.axis.wires.wires @property def neighbours(self) -> set[Axis]: return self.axis.neighbours @property def lengths(self) -> list[float]: return [wire.length for wire in self.wires] class Row: """A string of blocks that must share the same count because they sit next to each other. This may not be an actual 'string' of blocks because, depending on blocking, a whole 'layer' of blocks can be chopped by specifying a single block only (for example, direction 2 in a cylinder)""" def __init__(self) -> None: self.entries: list[Entry] = [] # the whole row must share the same cell count; # it's determined if it's greater than 0 self.count = 0 def add_block(self, block: Block, heading: DirectionType) -> None: axis = block.axes[heading] # check neighbours for alignment # TODO: is this even necessary? if len(self.entries) == 0: flipped = False else: # find a block's neighbour among existing entries # and determine its relative alignment for entry in self.entries: if axis in entry.neighbours: flipped = not entry.axis.is_aligned(axis) if entry.flipped: flipped = not flipped break else: # TODO: nicer message raise RuntimeError("No neighbour found!") self.entries.append(Entry(block, heading, flipped)) def get_length(self, take: ChopTakeType = "avg"): lengths: list[float] = [] for entry in self.entries: lengths += entry.lengths if take == "min": return min(lengths) if take == "max": return max(lengths) return sum(lengths) / len(self.entries) / 4 # "avg" def get_axes(self) -> list[Axis]: return [entry.axis for entry in self.entries] def get_wires(self) -> list[Wire]: wires: list[Wire] = [] for axis in self.get_axes(): wires += axis.wires return wires def set_count(self, count: int) -> None: if self.count != 0: if count != self.count: # TODO: a nicer message raise InconsistentGradingsError("Inconsistent counts on row") self.count = count @property def blocks(self) -> list[Block]: return [entry.block for entry in self.entries] ================================================ FILE: src/classy_blocks/grading/define/__init__.py ================================================ ================================================ FILE: src/classy_blocks/grading/define/chop.py ================================================ import dataclasses from functools import lru_cache from typing import Callable, Optional, Union from classy_blocks.cbtyping import ChopPreserveType, ChopTakeType, GradingSpecType from classy_blocks.grading.define import relations as rel @dataclasses.dataclass class ChopRelation: """A container that links a pair of inputs to a grading calculator function that outputs a new value""" output: str input_1: str input_2: str function: Callable[[float, float, float], Union[int, float]] @property def inputs(self) -> set[str]: """Input parameters for this chop function""" return {self.input_1, self.input_2} @classmethod def from_function(cls, function: Callable): """Create this object from given callable (relations functions)""" try: # function name is assembled as `get_____` data = function.__name__.split("__") result_param_name = data[0][4:] param1 = data[1] param2 = data[2] return cls(result_param_name, param1, param2, function) except Exception as err: raise RuntimeError(f"Invalid function name or unexpected parameter names: {function.__name__}") from err @staticmethod @lru_cache(maxsize=1) def get_possible_combinations() -> list["ChopRelation"]: calculation_functions = rel.get_calculation_functions() return [ChopRelation.from_function(f) for _, f in calculation_functions.items()] @dataclasses.dataclass class ChopData: """A collection of results from Chop.calculate()""" length_ratio: float start_size: float c2c_expansion: float count: int end_size: float total_expansion: float take: ChopTakeType = "avg" preserve: ChopPreserveType = "total_expansion" def get_specification(self, invert: bool) -> GradingSpecType: if invert: return (self.length_ratio, self.count, 1 / self.total_expansion) return (self.length_ratio, self.count, self.total_expansion) @dataclasses.dataclass class Chop: """A single 'chop' represents a division in Grading object; user-provided arguments are stored in this object and used for creation of Gradient object""" length_ratio: float = 1.0 start_size: Optional[float] = None c2c_expansion: Optional[float] = None count: Optional[int] = None end_size: Optional[float] = None total_expansion: Optional[float] = None take: ChopTakeType = "avg" preserve: ChopPreserveType = "total_expansion" @property def defined_params(self) -> int: grading_params = [self.start_size, self.end_size, self.count, self.total_expansion, self.c2c_expansion] return len(grading_params) - grading_params.count(None) def __post_init__(self): self.check() def check(self): # check that the user did not overdefine the chop; # if more than 2 parameters are given, the results might change between runs because # of the iterative calculation if self.defined_params > 2: raise ValueError("Over-defined Chop; speficy at most 2 grading parameters!") # also: count can only be an integer if self.count is not None: self.count = max(int(self.count), 1) def calculate(self, length: float) -> ChopData: self.check() # default: take c2c_expansion=1 if there's less than 2 parameters given if self.defined_params < 2: if self.c2c_expansion is None: self.c2c_expansion = 1 """Calculates cell count and total expansion ratio for this chop by calling functions that take known variables and return new values""" data = dataclasses.asdict(self) calculated: set[str] = set() length = length * self.length_ratio for key in data.keys(): if data[key] is not None: calculated.add(key) for _ in range(12): if {"count", "total_expansion", "c2c_expansion", "start_size", "end_size"}.issubset(calculated): data["count"] = int(data["count"]) return ChopData(**data) for chop_rel in ChopRelation.get_possible_combinations(): output = chop_rel.output inputs = chop_rel.inputs function = chop_rel.function if output in calculated: # this value is already calculated, go on continue if inputs.issubset(calculated): # value is not yet calculated but parameters are available data[output] = function(length, data[chop_rel.input_1], data[chop_rel.input_2]) calculated.add(output) raise ValueError(f"Could not calculate count and grading for given parameters: {data}") ================================================ FILE: src/classy_blocks/grading/define/collector.py ================================================ from classy_blocks.cbtyping import DirectionType from classy_blocks.grading.define.chop import Chop from classy_blocks.util.frame import Frame class ChopCollector: def _prepare(self) -> None: self.axis_chops: dict[DirectionType, list[Chop]] = {0: [], 1: [], 2: []} self.edge_chops = Frame[list[Chop]]() self._edge_chops_count = 0 # a quick indicator if there are any edge gradings def __init__(self) -> None: self._prepare() def chop_axis(self, direction: DirectionType, chop: Chop) -> None: self.axis_chops[direction].append(chop) def chop_edge(self, corner_1: int, corner_2: int, chop: Chop) -> None: chops = self.edge_chops[corner_1][corner_2] if chops is not None: chops.append(chop) else: chops = [chop] self.edge_chops.add_beam(corner_1, corner_2, chops) self._edge_chops_count += 1 def clear(self) -> None: self._prepare() @property def is_edge_chopped(self) -> bool: return self._edge_chops_count > 0 def __getitem__(self, i: DirectionType): return self.axis_chops[i] ================================================ FILE: src/classy_blocks/grading/define/grading.py ================================================ """In theory, combination of three of these 6 values can be specified: - Total length - Number of cells - Total expansion ratio - Cell-to-cell expansion ratio - Width of start cell - Width of end cell Also in reality, some are very important: - Width of start cell: required to keep y+ <= 1 - Cell-to-cell expansion ratio: 1 < recommended <= 1.2 - Number of cells: the obvious meshing parameter - Width of end cell: matching with cell sizes of the next block Possible Combinations to consider are: - start_size & c2c_expansion - start_size & count - start_size & end_size - end_size & c2c_expansion - end_size & count - count & c2c_expansion Some nomenclature to avoid confusion Grading: the whole grading specification (length, count, expansion ratio) length: length of an edge we're dealing with count: number of cells in a block for a given direction total_expansion: ratio between last/first cell size c2c_expansion: ratio between sizes of two neighbouring cells start_size: size of the first cell in a block end_size: size of the last cell in a block division: an entry in simpleGrading specification in blockMeshDict calculations meticulously transcribed from the blockmesh grading calculator: https://gitlab.com/herpes-free-engineer-hpe/blockmeshgradingweb/-/blob/master/calcBlockMeshGrading.coffee (since block length is always known, there's less wrestling but the calculation principle is similar)""" import abc import dataclasses import math import warnings from typing import TypeVar from classy_blocks.base.exceptions import UndefinedGradingsError from classy_blocks.cbtyping import GradingSpecType from classy_blocks.grading.define.chop import Chop, ChopData from classy_blocks.util import constants GradingT = TypeVar("GradingT", bound="GradingBase") class GradingBase(abc.ABC): """Grading specification for a single edge""" length: float chops: list[Chop] inverted: bool @abc.abstractmethod def add_chop(self, chop: Chop) -> None: if not (0 < chop.length_ratio <= 1): raise ValueError(f"Length ratio must be between 0 and (including) 1, got {chop.length_ratio}") self.chops.append(chop) @abc.abstractmethod def clear(self) -> None: """Removes all added grading data""" @property @abc.abstractmethod def chop_data(self) -> list[ChopData]: pass def get_specification(self, inverted: bool) -> list[GradingSpecType]: if inverted: chops = list(reversed(self.chop_data)) else: chops = self.chop_data return [data.get_specification(inverted) for data in chops] @property def specification(self) -> list[GradingSpecType]: # a list of [length_ratio, count, total_expansion] for each chop return self.get_specification(self.inverted) @property def start_size(self) -> float: if len(self.chops) == 0: raise RuntimeError("start_size requested but no chops defined") chop = self.chops[0] return chop.calculate(self.length).start_size @property def end_size(self) -> float: if len(self.chops) == 0: raise RuntimeError("end_size requested but no chops defined") chop = self.chops[-1] return chop.calculate(self.length).end_size def copy(self, length: float, invert: bool = False) -> "Grading|CollapsedGrading": """Creates a new grading with the same chops (counts) on a different length, keeping chop.preserve quantity constant; the 'length' parameter is the new wire's length; 'invert' does not set the grading.inverted flag but flips the original value""" new_grading: GradingBase if length > constants.TOL: new_grading = Grading(length) for data in self.chop_data: # take count from calculated chops; # it is of utmost importance it stays the same old_data = dataclasses.asdict(data) new_args = { "length_ratio": data.length_ratio, "count": old_data["count"], data.preserve: old_data[data.preserve], "preserve": data.preserve, "take": data.take, } new_grading.add_chop(Chop(**new_args)) new_grading.inverted = self.inverted if invert: new_grading.inverted = not new_grading.inverted return new_grading new_grading = CollapsedGrading() new_grading.add_chop(Chop(count=self.count)) return new_grading @property @abc.abstractmethod def count(self) -> int: """Return number of cells, summed over all sub-divisions""" @property @abc.abstractmethod def is_defined(self) -> bool: """Returns True is grading is defined""" @property @abc.abstractmethod def description(self) -> str: """Output string for blockMeshDict""" # TODO! Put this into writer class Grading(GradingBase): def __init__(self, length: float) -> None: # "multi-grading" specification according to: # https://cfd.direct/openfoam/user-guide/v9-blockMesh/#multi-grading self.length = length # to be updated when adding/modifying block edges # will change specification to match a wire from end to start self.inverted: bool = False # a list of user-input data self.chops: list[Chop] = [] # results from chop.calculate(): self._chop_data: list[ChopData] = [] @property def chop_data(self): if len(self._chop_data) < len(self.chops): # Chops haven't been calculated yet self._chop_data: list[ChopData] = [] for chop in self.chops: self._chop_data.append(chop.calculate(self.length)) return self._chop_data def add_chop(self, chop: Chop): super().add_chop(chop) @property def count(self): return sum(d.count for d in self.chop_data) @property def is_defined(self): return len(self.chops) > 0 def clear(self) -> None: """Removes all added grading data""" self.chops = [] self._chop_data = [] @property def description(self): if not self.is_defined: raise UndefinedGradingsError(f"Grading not defined: {self}") if len(self.specification) == 1: # its a one-number simpleGrading: return str(self.specification[0][2]) # multi-grading: make a nice list # FIXME: make a nicer list length_ratio_sum = 0.0 out = "(" for spec in self.specification: out += f"({spec[0]} {spec[1]} {spec[2]})" length_ratio_sum += spec[0] out += ")" if not math.isclose(length_ratio_sum, 1, rel_tol=constants.TOL): warnings.warn(f"Length ratio doesn't add up to 1: {length_ratio_sum}", stacklevel=2) return out def __eq__(self, other_grading): # this works theoretically but numerics will probably ruin the party: # return self.specification == other.specification this_spec = self.get_specification(False) other_spec = other_grading.get_specification(False) if len(this_spec) != len(other_spec): return False # so just compare number-by-number for i, this in enumerate(this_spec): other = other_spec[i] for j, this_value in enumerate(this): other_value = other[j] if not math.isclose(this_value, other_value, rel_tol=constants.TOL): return False return True def __repr__(self) -> str: if self.is_defined: return f"Grading ({len(self.chops)} chops {self.description})" return f"Grading ({len(self.chops)})" class CollapsedGrading(GradingBase): def __init__(self) -> None: self.length = 0 self.inverted = False self.chops = [] self._count = 0 def add_chop(self, chop: Chop): if chop.count is None: raise RuntimeError("Adding a chop without count to a collapsed edge") self._count += chop.count @property def chop_data(self): return [ChopData(1, 1, 1, self.count, 1, 1)] @property def count(self): return self._count @property def is_defined(self): return self.count > 0 def clear(self): self._count = 0 @property def description(self): return "1" def __eq__(self, other): return self.count == other.count def __repr__(self): return f"CollapsedGrading(count={self.count})" ================================================ FILE: src/classy_blocks/grading/define/relations.py ================================================ import inspect import sys from typing import Callable import numpy as np import scipy.optimize from classy_blocks.util import constants # here's a list of available calculation functions # transcribed from the blockmesh grading calculator: # https://gitlab.com/herpes-free-engineer-hpe/blockmeshgradingweb/-/blob/master/calcBlockMeshGrading.coffee # (not all are needed in for classy_blocks because length is always a known parameter) R_MAX = 1 / constants.TOL # these functions are introspected and used for calculation according to their # name (get_____(length, param1, param2)); # length is a default argument, passed in always, for simplicity # validator functions def _validate_length(length) -> None: if length <= 0: raise ValueError(f"Length must be positive, got {length}") def _validate_count(count: float, condition: str) -> None: # short operators at the end, as '>' starts with the same character as '>=' allowed_operators = ["==", "!=", ">=", "<=", ">", "<"] # This function use `eval()`, make sure input parameters are valid! if not isinstance(count, (int, float)): raise TypeError(f"`{count}` must be an float, got {type(count)}") for operator in allowed_operators: if condition.startswith(operator): try: _ = float(condition.lstrip(operator)) break except ValueError as err: raise ValueError( f"`{condition}` format is invalid: expecting ''. For example: '>=6'" f"\n\tGot: {condition}" ) from err else: raise ValueError( f"Unknown condition (operator or format): {condition}\n\tAllowed operators are: {allowed_operators}" ) if not eval(f"{count}{condition}"): raise ValueError(f"Count value ({count}) does not meet the condition: {condition}") def _validate_start_end_size(size, name: str) -> None: if size <= 0: raise ValueError(f"{name.capitalize()} size must be positive, got {size}") def _validate_c2c_expansion(c2c_expansion) -> None: if c2c_expansion == 0: raise ValueError("Cell-to-cell expansion must not be 0.") def _validate_total_expansion(expansion) -> None: if expansion == 0: raise ValueError("Total expansion ratio must not be 0.") ### functions returning start_size def get_start_size__count__c2c_expansion(length, count, c2c_expansion): """Calculates start size from given count and cell-to-cell expansion ratio""" _validate_length(length) _validate_count(count, ">=1") if abs(c2c_expansion - 1) > constants.TOL: return length * (1 - c2c_expansion) / (1 - c2c_expansion**count) return length / count def get_start_size__end_size__total_expansion(length, end_size, total_expansion): """Calculates start size from given end size and total expansion ratio""" _validate_length(length) _validate_total_expansion(total_expansion) return end_size / total_expansion ### functions returning end_size def get_end_size__start_size__total_expansion(length, start_size, total_expansion): """Calculates end size from given start size and total expansion ratio""" _validate_length(length) return start_size * total_expansion ### functions returning count def get_count__start_size__c2c_expansion(length, start_size, c2c_expansion): """Calculates count from given start size and cell-to-cell expansion ratio""" _validate_length(length) _validate_start_end_size(start_size, "start") _validate_c2c_expansion(c2c_expansion) if abs(c2c_expansion - 1) > constants.TOL: count = np.log(1 - length / start_size * (1 - c2c_expansion)) / np.log(c2c_expansion) else: count = length / start_size return int(count) + 1 def get_count__end_size__c2c_expansion(length, end_size, c2c_expansion): """Calculates count from given end size and cell-to-cell expansion ratio""" _validate_length(length) if abs(c2c_expansion - 1) > constants.TOL: count = np.log(1 / (1 + length / end_size * (1 - c2c_expansion) / c2c_expansion)) / np.log(c2c_expansion) else: count = length / end_size if np.isnan(count): raise ValueError( f"Could not calculate count from end size {end_size} and cell-to-cell expansion ratio {c2c_expansion}" ) return int(count) + 1 def get_count__total_expansion__c2c_expansion(length, total_expansion, c2c_expansion): """Calculates count from total expansion ratio and cell-to-cell expansion ratio""" _validate_length(length) _validate_total_expansion(total_expansion) if abs(c2c_expansion - 1) <= constants.TOL: raise ValueError( "Cell-to-cell expansion - 1 should be greater than tolerance:" f"\n\tCell-to-cell expansion ratio: {c2c_expansion}" f"\n\tTolerance: {constants.TOL}" ) return int(np.log(total_expansion) / np.log(c2c_expansion)) + 1 def get_count__total_expansion__start_size(length, total_expansion, start_size): """Calculates count from given total expansion ratio and start size""" _validate_length(length) _validate_start_end_size(start_size, "start") _validate_total_expansion(total_expansion) if total_expansion > 1: d_min = start_size else: d_min = start_size * total_expansion if abs(total_expansion - 1) < constants.TOL: return int(length / d_min) def fcnt(cnt): return (1 - total_expansion ** (cnt / (cnt - 1))) / ( 1 - total_expansion ** (1 / (cnt - 1)) ) - length / start_size return int(scipy.optimize.brentq(fcnt, 0, length / d_min)) + 1 # type: ignore ### functions returning c2c_expansion def get_c2c_expansion__count__start_size(length, count, start_size): """Calculates cell-to-cell expansion ratio from given count and start size""" _validate_length(length) _validate_count(count, ">=1") if not (length > start_size > 0): raise ValueError(f"Start size {start_size} must be between 0 and length {length}, got {start_size}") if count == 1: return 1 if abs(count * start_size - length) / length < constants.TOL: return 1 if count * start_size < length: c_max = R_MAX ** (1 / (count - 1)) c_min = (1 + constants.TOL) ** (1 / (count - 1)) else: c_max = (1 - constants.TOL) ** (1 / (count - 1)) c_min = (1 / R_MAX) ** (1 / (count - 1)) def fexp(c2c): return (1 - c2c**count) / (1 - c2c) - length / start_size if fexp(c_min) * fexp(c_max) >= 0: raise ValueError(f"Invalid grading parameters: length {length}, count {count}, start_size {start_size}") return scipy.optimize.brentq(fexp, c_min, c_max) def get_c2c_expansion__count__end_size(length, count, end_size): """Calculates cell-to-cell expansion ratio from given count and end size""" _validate_length(length) _validate_count(count, ">=2") _validate_start_end_size(end_size, "end") if abs(count * end_size - length) / length < constants.TOL: return 1 if count * end_size > length: c_max = R_MAX ** (1 / (count - 1)) c_min = (1 + constants.TOL) ** (1 / (count - 1)) else: c_max = (1 - constants.TOL) ** (1 / (count - 1)) c_min = (1 / R_MAX) ** (1 / (count - 1)) def fexp(c2c): return (1 / c2c ** (count - 1)) * (1 - c2c**count) / (1 - c2c) - length / end_size if fexp(c_min) * fexp(c_max) >= 0: raise ValueError(f"Invalid grading parameters: length {length}, count {count}, end_size {end_size}") return scipy.optimize.brentq(fexp, c_min, c_max) def get_c2c_expansion__count__total_expansion(length, count, total_expansion): """Calculates cell-to-cell expansion ratio from given count and total expansion ratio""" if count == 1: return 1 _validate_length(length) _validate_count(count, ">1") return total_expansion ** (1 / (count - 1)) ### functions returning total expansion def get_total_expansion__count__c2c_expansion(length, count, c2c_expansion): """Calculates total expansion ratio from given count and cell-to-cell expansion ratio""" _validate_length(length) _validate_count(count, ">=1") return c2c_expansion ** (count - 1) def get_total_expansion__start_size__end_size(length, start_size, end_size): """Calculates total expansion ratio from given start size and end size""" _validate_length(length) _validate_start_end_size(start_size, "start") _validate_start_end_size(end_size, "end") return end_size / start_size def get_calculation_functions() -> dict[str, Callable]: # gather available functions for calculation of grading parameters functions = dict() for name, function in inspect.getmembers(sys.modules[__name__], inspect.isfunction): if name.startswith("get_"): if "__" in name: functions[name] = function return functions # type: ignore ================================================ FILE: src/classy_blocks/grading/graders/__init__.py ================================================ ================================================ FILE: src/classy_blocks/grading/graders/auto.py ================================================ import abc import numpy as np from classy_blocks.cbtyping import ChopTakeType from classy_blocks.grading.analyze.row import Row class AutoGraderMixin(abc.ABC): take: ChopTakeType def _get_row_length(self, row: Row) -> float: lengths = [] for entry in row.entries: lengths += entry.lengths if self.take == "max": return max(lengths) if self.take == "min": return min(lengths) return float(np.average(np.array(lengths))) ================================================ FILE: src/classy_blocks/grading/graders/fixed.py ================================================ from classy_blocks.assemble.dump import AssembledDump from classy_blocks.cbtyping import DirectionType from classy_blocks.grading.analyze.row import Row from classy_blocks.grading.define.chop import Chop from classy_blocks.grading.graders.auto import AutoGraderMixin from classy_blocks.grading.graders.manager import AxisGrader, GradingManager from classy_blocks.items.block import Block from classy_blocks.mesh import Mesh class FixedAxisGrader(AxisGrader): def __init__(self, block: Block, direction: DirectionType, count: int): self.count = count super().__init__(block, direction) def get_chops(self): return [Chop(count=self.count)] class FixedCountGrader(GradingManager, AutoGraderMixin): def __init__(self, mesh: Mesh, count: int = 5): self.count = count mesh.assemble() assert isinstance(mesh.dump, AssembledDump) super().__init__(mesh.dump, mesh.settings) def _grade_row(self, row: Row): super()._grade_row(row) if row.count == 0: entry = row.entries[0] axis_grader = FixedAxisGrader(entry.block, entry.heading, self.count) axis_grader.grade() ================================================ FILE: src/classy_blocks/grading/graders/inflation.py ================================================ import abc import copy import dataclasses from typing import Union from classy_blocks.assemble.dump import AssembledDump from classy_blocks.cbtyping import ChopTakeType, DirectionType from classy_blocks.grading.analyze.row import Row from classy_blocks.grading.define import relations as gr from classy_blocks.grading.define.chop import Chop from classy_blocks.grading.graders.auto import AutoGraderMixin from classy_blocks.grading.graders.fixed import FixedAxisGrader from classy_blocks.grading.graders.manager import AxisGrader, GradingManager from classy_blocks.items.block import Block from classy_blocks.mesh import Mesh from classy_blocks.util.constants import VBIG def sum_length(first_cell_size: float, count: int, c2c_expansion: float) -> float: length: float = 0 size: float = first_cell_size for _ in range(count): length += size size *= c2c_expansion return length @dataclasses.dataclass class InflationParams: """Common parameters for inflation grading (see inflation/grader)""" first_cell_size: float bulk_cell_size: float c2c_expansion: float = 1.2 bl_thickness_factor: int = 30 buffer_expansion: float = 2 @property def bl_thickness(self) -> float: return self.first_cell_size * self.bl_thickness_factor @property def inflation_count(self) -> int: """Number of cells in inflation layer""" return gr.get_count__start_size__c2c_expansion(self.bl_thickness, self.first_cell_size, self.c2c_expansion) @property def inflation_end_size(self) -> float: """Size of the last cell of inflation layer""" total_expansion = gr.get_total_expansion__count__c2c_expansion( self.bl_thickness, self.inflation_count, self.c2c_expansion ) return self.first_cell_size * total_expansion @property def buffer_start_size(self) -> float: """Size of the first cell in the buffer layer""" return self.inflation_end_size * self.buffer_expansion @property def buffer_count(self) -> int: return gr.get_count__total_expansion__c2c_expansion( 1, self.bulk_cell_size / self.buffer_start_size, self.buffer_expansion ) @property def buffer_thickness(self) -> float: return sum_length(self.buffer_start_size, self.buffer_count, self.buffer_expansion) class Layer(abc.ABC): """A common interface to all layers of a grading (inflation, buffer, bulk)""" # Defines one chop and tools to handle it start_size: float c2c_expansion: float length: float count: int end_size: float length_ratio: float = 1 def _construct( self, length_limit: float = VBIG, count_limit: int = 10**12, size_limit: float = VBIG ) -> tuple[float, float, int]: # stop construction of the layer when it hits any of the above limits count = 1 size = self.start_size length = self.start_size for _ in range(count_limit): if length >= length_limit: break if count >= count_limit: break if size >= size_limit: break length += size size *= self.c2c_expansion count += 1 return length, size, count def __init__( self, params: InflationParams, length_limit: float = VBIG, count_limit: int = 10**12, size_limit: float = VBIG ): self.params = params # stop construction of the layer when it hits any of the above limits self.length, self.end_size, self.count = self._construct(length_limit, count_limit, size_limit) def get_chop(self): # since everything required for a grading specification # is already specified, just provide count and total expansion; # that's the two values chops calculate return Chop( length_ratio=self.length_ratio, count=self.count, total_expansion=self.end_size / self.start_size, ) def invert(self) -> "Layer": # inverts the data for a chop self.end_size, self.start_size = self.start_size, self.end_size self.c2c_expansion = 1 / self.c2c_expansion return self def copy(self) -> "Layer": return copy.deepcopy(self) def __repr__(self): data = { "start_size": self.start_size, "c2c_expansion": self.c2c_expansion, "length": self.length, "count": self.count, "end_size": self.end_size, "length_ratio": self.length_ratio, } return str(data) class InflationLayer(Layer): def __init__(self, params: InflationParams, remaining_length: float): self.start_size = params.first_cell_size self.c2c_expansion = params.c2c_expansion super().__init__(params, length_limit=min(params.bl_thickness, remaining_length)) class BufferLayer(Layer): def __init__(self, params: InflationParams, remaining_length: float): self.start_size = params.buffer_start_size self.c2c_expansion = params.buffer_expansion super().__init__(params, size_limit=params.bulk_cell_size, length_limit=remaining_length) class BulkLayer(Layer): def __init__(self, params: InflationParams, remaining_length: float): self.start_size = params.bulk_cell_size self.c2c_expansion = 1 super().__init__(params, length_limit=remaining_length) def get_chop(self): return Chop(length_ratio=self.length_ratio, count=self.count) class LayerStack: """A collection of one, two or three layers (chops) for InflationGrader""" def _normalize_ratios(self): # re-assigns length_ratio to all layers sum_lengths = sum(layer.length for layer in self.layers) for layer in self.layers: layer.length_ratio = layer.length / sum_lengths def _construct(self) -> list[Layer]: layers: list[Layer] = [] remaining_length = self.total_length inflation_layer = InflationLayer(self.params, remaining_length) remaining_length -= inflation_layer.length layers.append(inflation_layer) if remaining_length < self.params.buffer_start_size: return layers buffer_layer = BufferLayer(self.params, remaining_length) remaining_length -= buffer_layer.length layers.append(buffer_layer) if remaining_length < self.params.bulk_cell_size: return layers bulk_layer = BulkLayer(self.params, remaining_length) layers.append(bulk_layer) return layers def __init__(self, params: InflationParams, total_length: float): self.params = params self.total_length = total_length self.layers = self._construct() self._normalize_ratios() def invert(self) -> "LayerStack": for layer in self.layers: layer.invert() self.layers.reverse() return self def mirror(self) -> "LayerStack": # rebuild the stack using half the length start_half = LayerStack(self.params, self.total_length / 2) end_half = LayerStack(self.params, self.total_length / 2).invert() self.layers = start_half.layers + end_half.layers self._normalize_ratios() return self @property def count(self) -> int: return sum(layer.count for layer in self.layers) @property def remaining_length(self) -> float: return self.total_length - sum(layer.length for layer in self.layers) class InflationAxisGrader(AxisGrader): def __init__(self, block: Block, direction: DirectionType, params: InflationParams, ref_length: float): self.params = params self.ref_length = ref_length super().__init__(block, direction) def get_stack(self) -> LayerStack: return LayerStack(self.params, self.ref_length) def get_chops(self) -> list[Chop]: stack = self.get_stack() return [layer.get_chop() for layer in stack.layers] class InvertedInflationAxisGrader(InflationAxisGrader): def get_stack(self): stack = super().get_stack() stack.invert() return stack class DoubleInflationAxisGrader(InflationAxisGrader): def get_stack(self) -> LayerStack: stack = LayerStack(self.params, self.ref_length / 2) stack.mirror() return stack class BulkAxisGrader(FixedAxisGrader): def __init__(self, block: Block, direction: DirectionType, params: InflationParams, ref_length: float): super().__init__(block, direction, max(1, int(ref_length / params.bulk_cell_size))) class InflationGrader(GradingManager, AutoGraderMixin): """Parameters for mesh grading for Low-Re cases. To save on cell count, only a required thickness (inflation layer) will be covered with thin cells (c2c_expansion in size ratio between them). Then a bigger expansion ratio will be applied between the last cell of inflation layer and the first cell of the bulk flow. Example: ________________ | | > bulk size (cell_size=bulk, no expansion) |________________ | |________________ > buffer layer (c2c = 2) |________________ |================ > inflation layer (cell_size=y+, c2c=1.2) / / / / / / / / / wall Args: first_cell_size (float): thickness of the first cell near the wall c2c_expansion (float): expansion ratio between cells in inflation layer bl_thickness_factor (int): thickness of the inflation layer in y+ units (relative to first_cell_size) buffer_expansion (float): expansion between cells in buffer layer bulk_cell_size (float): size of cells inside the domain The grader will take all relevant blocks and choose one to start with - set cell counts and other parameters that must stay fixed for all further blocks. It will choose the longest/shortest ('max/min') block edge or something in between ('avg'). The finest grid will be obtained with 'max', the coarsest with 'min'. """ def __init__( self, mesh: Mesh, first_cell_size: float, bulk_cell_size: float, c2c_expansion: float = 1.2, bl_thickness_factor: int = 30, buffer_expansion: float = 2, take: ChopTakeType = "avg", ): self.params = InflationParams( first_cell_size, bulk_cell_size, c2c_expansion, bl_thickness_factor, buffer_expansion ) self.take = take mesh.assemble() assert isinstance(mesh.dump, AssembledDump) super().__init__(mesh.dump, mesh.settings) def _get_grader(self, row: Row) -> Union[type[InflationAxisGrader], type[BulkAxisGrader]]: # use simple grader's method for rows that don't touch walls # but a more sophisticated method for rows on-the-wall starts_at_wall = False ends_at_wall = False for entry in row.entries: for wire in entry.wires: wire_info = self.probe.get_wire_info(wire) if wire_info.starts_at_wall: if not entry.flipped: starts_at_wall = True if wire_info.ends_at_wall: if not entry.flipped: ends_at_wall = True if starts_at_wall and ends_at_wall: return DoubleInflationAxisGrader if starts_at_wall: return InflationAxisGrader if ends_at_wall: return InvertedInflationAxisGrader return BulkAxisGrader def _grade_row(self, row: Row): # obey user-specified gradings first super()._grade_row(row) if row.count == 0: # add automatic gradings to non-specified rows entry = row.entries[0] row_length = self._get_row_length(row) axis_grader = self._get_grader(row)(entry.block, entry.heading, self.params, row_length) axis_grader.grade() ================================================ FILE: src/classy_blocks/grading/graders/manager.py ================================================ from typing import get_args from classy_blocks.assemble.dump import AssembledDump from classy_blocks.assemble.settings import Settings from classy_blocks.base.exceptions import InconsistentGradingsError, UndefinedGradingsError from classy_blocks.cbtyping import DirectionType from classy_blocks.grading.analyze.probe import Probe from classy_blocks.grading.analyze.row import Row from classy_blocks.grading.define.chop import Chop from classy_blocks.grading.define.grading import Grading, GradingBase from classy_blocks.items.block import Block from classy_blocks.items.wires.axis import Axis from classy_blocks.items.wires.wire import Wire from classy_blocks.util.constants import AXIS_PAIRS class WireGrader: def __init__(self, wire: Wire): self.wire = wire @staticmethod def copy(from_wire: Wire, to_wire: Wire) -> None: """Copies grading from another wire but takes care to orient it properly""" if to_wire.grading.is_defined: if to_wire.grading != from_wire.grading: raise InconsistentGradingsError(f"Copying grading to a defined wire! (From {from_wire} to {to_wire})") to_wire.grading = from_wire.grading.copy(to_wire.length, not to_wire.is_aligned(from_wire)) def assign(self, grading: GradingBase) -> None: """Assigns a Grading to a wire; inherits a grading from a coincident wire if there's one""" for coincident in self.wire.coincidents: if coincident.is_graded: if coincident.grading != grading: raise InconsistentGradingsError("Different gradings on coincident wires") # TODO: a nicer message # reuse a coincident grading self.copy(coincident, self.wire) return self.wire.grading = grading def copy_to_coincidents(self, override: bool = False) -> None: """Copies this wire's gradings to all coincidents; returns False if all coincident wires are defined already""" if not self.wire.grading.is_defined: raise UndefinedGradingsError(f"Inheriting from a non-graded wire! {self.wire}") for coincident in self.wire.coincidents: if override: coincident.grading.clear() if not coincident.is_graded: self.copy(self.wire, coincident) def re_chop(self, chops: list[Chop]) -> None: """Takes a wire that's chopped already and change its grading according to a set of different Chops; used for edge grading""" # remember the wire's count and discard all previous chops; # then re-chop using user-specified chops on those wires if not self.wire.grading.is_defined: raise UndefinedGradingsError( "Edge-chopping an un-defined wire; define the mesh fully prior to specifying edge grading" ) wire_count = self.wire.grading.count grading = Grading(self.wire.length) # add all but the last chop for chop in chops[:-1]: grading.add_chop(chop) # for the last chop, use up the remaining count remaining_count = wire_count - grading.count if remaining_count < 1: raise ValueError(f"Wrong edge grading specification! Remaining count = {remaining_count}") # update the chop with remaining count and check if it adds up last_chop = chops[-1] if last_chop.count is None: last_chop.count = remaining_count if last_chop.count is not None and last_chop.count != remaining_count: raise ValueError(f"Multiple edge chops on count don't add up: {chops[-1].count} != {remaining_count}") grading.add_chop(last_chop) self.wire.grading = grading # replace gradings in coincident wires self.copy_to_coincidents(override=True) class AxisGrader: def __init__(self, block: Block, direction: DirectionType): self.block = block self.direction: DirectionType = direction @property def axis(self) -> Axis: return self.block.axes[self.direction] def get_chops(self) -> list[Chop]: """Returns chop for this axis only (no edge chops!)""" return self.block.chops.axis_chops[self.direction] def grade(self) -> int: """Takes all Chops from block and creates Grading objects for all wires on this axis. Returns calculated count if anything was done or 0 if nothing was defined.""" chops = self.get_chops() if not chops: # no chops are defined return 0 axis = self.axis lengths = axis.lengths take = chops[0].take if take == "avg": length = sum(lengths) / 4 else: lengths.sort() if take == "max": length = lengths[-1] else: length = lengths[0] grading = Grading(length) for chop in chops: grading.add_chop(chop) for wire in axis.wires: grader = WireGrader(wire) # TODO: assign priority to Chop so it's possible # to tell whether to copy the grading to coincidents or take an existing one from them grader.assign(grading.copy(wire.length, False)) return grading.count class RowGrader: def __init__(self, row: Row): self.row = row def grade(self) -> None: for entry in self.row.entries: axis_grader = AxisGrader(entry.block, entry.heading) axis_count = axis_grader.grade() if axis_count != 0: # this will raise an exception if called twice with a different count self.row.set_count(axis_count) class GradingDistributor: def __init__(self, row: Row): self.row = row self.axes = set(row.get_axes()) # each wire belongs to an axis; create a lookup dict self.wire_to_axis: dict[Wire, set[Axis]] = {} for axis in self.axes: for wire in axis.wires: if wire not in self.wire_to_axis: self.wire_to_axis[wire] = set() self.wire_to_axis[wire].add(axis) for coincident in wire.coincidents: if coincident not in self.wire_to_axis: self.wire_to_axis[coincident] = set() self.wire_to_axis[coincident].add(axis) self.defined: set[Axis] = set(axis for axis in self.axes if axis.is_graded) @property def undefined_axes(self) -> set[Axis]: return self.axes - self.defined @property def is_done(self) -> bool: return self.defined == self.axes def _list_ungraded(self) -> str: undefined = self.undefined_axes message = "Undefined blocks:\n" for entry in self.row.entries: if entry.axis in undefined: message += f"#{entry.block.index} dir {entry.heading}\n" return message def _complete_axis(self, axis: Axis) -> bool: """Takes an Axis with a defined wire and copies its grading to all non-defined wires of this axis. Returns True if the axis is completely graded (all wires), False otherwise.""" # first, gather defined and non-defined wires for wire in axis.wires: if wire.is_graded: take_from = wire break else: return False for wire in axis.wires: if not wire.is_graded: wire.grading = take_from.grading.copy(wire.length, False) return True def _propagate_wires(self, axis: Axis) -> set[Axis]: """Propagates all wires of this axis to their coincidents""" fresh_neighbours: set[Axis] = set() for wire in axis.wires: if not wire.is_graded: continue wire_grader = WireGrader(wire) wire_grader.copy_to_coincidents() for coincident in wire.coincidents: fresh_neighbours.update(self.wire_to_axis[coincident]) return fresh_neighbours def distribute(self) -> None: max_iterations = len(self.axes) iteration = 0 seed_axes = self.defined while not self.is_done: if iteration > max_iterations: raise UndefinedGradingsError("Cannot grade all blocks! " + self._list_ungraded()) # copy from seeds to their neighbours fresh_neighbours: set[Axis] = set() for axis in seed_axes: fresh_neighbours.update(self._propagate_wires(axis)) fresh_neighbours -= self.defined # the freshly touched axes most probably don't have all the # wires defined yet; complete them by copying grading from defined wires for axis in fresh_neighbours: if self._complete_axis(axis): self.defined.add(axis) seed_axes = fresh_neighbours iteration += 1 class GradingManager: """Calculates and distributes user-defined counts/gradings. Does not add anything to the mesh - throws an exception if non-graded blocks exist.""" def __init__(self, dump: AssembledDump, settings: Settings): self.probe = Probe(dump, settings) def _grade_row(self, row: Row) -> None: grader = RowGrader(row) grader.grade() def _grade_edges(self, row: Row) -> None: # edge grading! # find all wires that have edge_chops in ChopCollector and replace # edge chops on those wires for entry in row.entries: chops = entry.block.chops if not chops.is_edge_chopped: continue for pair in AXIS_PAIRS[entry.heading]: edge_chops = chops.edge_chops[pair[0]][pair[1]] if edge_chops: wire_grader = WireGrader(entry.axis.wires[pair[0]]) wire_grader.re_chop(edge_chops) def _check_consistency(self, row: Row) -> None: axes = row.get_axes() count = axes[0].count def _raise_count(): raise InconsistentGradingsError(f"Different counts detected ({count} vs. {axis.count})") for axis in row.get_axes(): if axis.count != count: _raise_count() for wire in axis.wires: if wire.grading.count != count: _raise_count() for coincident in wire.coincidents: if wire.grading != coincident.grading: raise InconsistentGradingsError( "Different gradings on coincident wires!" f" ({wire.grading} on {wire} vs. {coincident.grading} on {coincident})" ) def grade(self): for direction in get_args(DirectionType): rows = self.probe.get_rows(direction) # TODO: benchmark, tests # TODO: check check_consistency()! for row in rows: # convert user-specified axis chops to gradings # and set count on this row (must be constant accross the row) self._grade_row(row) for row in rows: # start from graded axes and propagate gradings throughout the row distributor = GradingDistributor(row) distributor.distribute() for row in rows: self._grade_edges(row) for row in rows: self._check_consistency(row) ================================================ FILE: src/classy_blocks/grading/graders/simple.py ================================================ from classy_blocks.assemble.dump import AssembledDump from classy_blocks.cbtyping import ChopTakeType from classy_blocks.grading.analyze.row import Row from classy_blocks.grading.graders.auto import AutoGraderMixin from classy_blocks.grading.graders.fixed import FixedAxisGrader from classy_blocks.grading.graders.manager import GradingManager from classy_blocks.mesh import Mesh class SimpleGrader(GradingManager, AutoGraderMixin): def __init__(self, mesh: Mesh, cell_size: float, take: ChopTakeType = "avg"): self.cell_size = cell_size self.take = take mesh.assemble() assert isinstance(mesh.dump, AssembledDump) super().__init__(mesh.dump, mesh.settings) def _get_row_count(self, row: Row): return max(1, int(self._get_row_length(row) / self.cell_size)) def _grade_row(self, row: Row): super()._grade_row(row) if row.count == 0: entry = row.entries[0] axis_grader = FixedAxisGrader(entry.block, entry.heading, self._get_row_count(row)) axis_grader.grade() ================================================ FILE: src/classy_blocks/items/__init__.py ================================================ ================================================ FILE: src/classy_blocks/items/block.py ================================================ from typing import get_args from classy_blocks.cbtyping import DirectionType, IndexType, OrientType from classy_blocks.grading.define.collector import ChopCollector from classy_blocks.items.edges.edge import Edge from classy_blocks.items.vertex import Vertex from classy_blocks.items.wires.axis import Axis from classy_blocks.items.wires.wire import Wire from classy_blocks.util import constants from classy_blocks.util.frame import Frame class Block: """A Block and everything that belongs to it""" def __init__(self, index: int, vertices: list[Vertex]): # index in blockMeshDict self.index = index self.vertices = vertices self.chops = ChopCollector() # wires and axes self.wires = Frame[Wire]() # create wires and connections for quicker addressing for direction in get_args(DirectionType): for pair in constants.AXIS_PAIRS[direction]: wire = Wire(self.vertices, direction, pair[0], pair[1]) self.wires.add_beam(pair[0], pair[1], wire) self.axes = [Axis(i, self.wires.get_axis_beams(i)) for i in get_args(DirectionType)] # cellZone to which the block belongs to self.cell_zone: str = "" # written as a comment after block definition # (visible in blockMeshDict, useful for debugging) self.comment: str = "" # 'hidden' blocks carry all of the data from the mesh # but is not inserted into blockMeshDict; self.visible = True def add_edge(self, corner_1: int, corner_2: int, edge: Edge): """Adds an edge between vertices at specified indexes.""" if not (0 <= corner_1 < 8 and 0 <= corner_2 < 8): raise ValueError( f"Invalid corner 1 ({corner_1}) or corner 2 ({corner_2}) index. Use block-local indexing (0...7)." ) self.wires[corner_1][corner_2].add_edge(edge) def set_chops(self, collector: ChopCollector) -> None: self.chops = collector def get_axis_wires(self, direction: DirectionType) -> list[Wire]: """Returns a list of wires that run in the given axis""" return self.wires.get_axis_beams(direction) def get_axis_direction(self, axis: Axis) -> DirectionType: for i in get_args(DirectionType): if self.axes[i] == axis: return i # TODO: use a custom exception raise RuntimeError("Axis not in this block!") def add_neighbour(self, candidate: "Block") -> None: """Add a block to neighbours, if applicable""" if candidate == self: return # axes for this_axis in self.axes: for cnd_axis in candidate.axes: this_axis.add_neighbour(cnd_axis) this_axis.add_inline(cnd_axis) # wires for this_wire in self.wire_list: for cnd_wire in candidate.wire_list: this_wire.add_coincident(cnd_wire) def update_wires(self) -> None: for wire in self.wire_list: # set actual grading.length after adding edges wire.update() @property def wire_list(self) -> list[Wire]: """A flat list of all wires""" # TODO: no daisy chaining! return self.axes[0].wires.wires + self.axes[1].wires.wires + self.axes[2].wires.wires @property def edge_list(self) -> list[Edge]: """A list of edges from all wires""" all_edges = [] for wire in self.wire_list: if wire.edge.kind != "line": all_edges.append(wire.edge) return all_edges @property def is_graded(self) -> bool: """Returns True if counts and gradings are defined for all axes""" return all(axis.is_graded for axis in self.axes) @property def indexes(self) -> IndexType: return [vertex.index for vertex in self.vertices] def get_side_vertices(self, orient: OrientType) -> list[Vertex]: return [self.vertices[i] for i in constants.FACE_MAP[orient]] def __hash__(self) -> int: return self.index def __repr__(self) -> str: return f"Block {self.index}" ================================================ FILE: src/classy_blocks/items/edges/__init__.py ================================================ ================================================ FILE: src/classy_blocks/items/edges/arcs/__init__.py ================================================ ================================================ FILE: src/classy_blocks/items/edges/arcs/angle.py ================================================ import dataclasses import numpy as np from classy_blocks.cbtyping import PointType, VectorType from classy_blocks.construct import edges from classy_blocks.construct.point import Point from classy_blocks.items.edges.arcs.arc_base import ArcEdgeBase from classy_blocks.util import functions as f from classy_blocks.util.constants import vector_format def arc_from_theta(edge_point_1: PointType, edge_point_2: PointType, angle: float, axis: VectorType) -> PointType: """Calculates a point on the arc edge from given sector angle and an axis of the arc. An interface to the Foundation's arc (axis) alternative edge specification: https://github.com/OpenFOAM/OpenFOAM-dev/commit/73d253c34b3e184802efb316f996f244cc795ec6 Note: Meticulously transcribed from https://github.com/OpenFOAM/OpenFOAM-dev/blob/master/src/mesh/blockMesh/blockEdges/arcEdge/arcEdge.C """ if not (0 < abs(angle) < np.pi * 2): raise ValueError(f"Angle should be between 0 and 2*pi, got {angle}") axis = np.asarray(axis) edge_point_1 = np.asarray(edge_point_1) edge_point_2 = np.asarray(edge_point_2) dp = edge_point_2 - edge_point_1 pm = (edge_point_1 + edge_point_2) / 2 rm = f.unit_vector(np.cross(dp, axis)) length = np.dot(dp, axis) chord = dp - length * axis mag_chord = f.norm(chord) center = pm - length * axis / 2 - rm * mag_chord / 2 / np.tan(angle / 2) return f.arc_mid(center, edge_point_1, edge_point_2) @dataclasses.dataclass class AngleEdge(ArcEdgeBase): """Alternative arc edge specification: sector angle and axis""" data: edges.Angle @property def third_point(self): return Point( arc_from_theta(self.vertex_1.position, self.vertex_2.position, self.data.angle, self.data.axis.components) ) @property def description(self): # produce two lines # one with this edge's specification # arc (axis) alternative edge specification: # TODO! Test output with the new Writer native = f"// arc {self.vertex_1.index} {self.vertex_2.index} " native += f"{self.data.angle} {vector_format(self.data.axis.position)}" # the other is a classic three-point arc definition return super().description + native ================================================ FILE: src/classy_blocks/items/edges/arcs/arc.py ================================================ import dataclasses from classy_blocks.construct import edges from classy_blocks.items.edges.arcs.arc_base import ArcEdgeBase @dataclasses.dataclass class ArcEdge(ArcEdgeBase): """Arc edge: defined by a single point""" data: edges.Arc @property def third_point(self): return self.data.point ================================================ FILE: src/classy_blocks/items/edges/arcs/arc_base.py ================================================ import abc import dataclasses import numpy as np from classy_blocks.construct.point import Point from classy_blocks.items.edges.edge import Edge from classy_blocks.util import constants from classy_blocks.util import functions as f from classy_blocks.util.constants import vector_format @dataclasses.dataclass class ArcEdgeBase(Edge, abc.ABC): """Base for all arc-based edges (arc, origin, angle)""" @property @abc.abstractmethod def third_point(self) -> Point: """The third point that defines the arc, regardless of how it was specified""" @property def length(self) -> float: if self.is_valid: return f.arc_length_3point(self.vertex_1.position, self.third_point.position, self.vertex_2.position) return f.norm(self.vertex_1.position - self.vertex_2.position) @property def description(self): # it's always 'arc' for arc edges return f"arc {self.vertex_1.index} {self.vertex_2.index} {vector_format(self.third_point.position)}" @property def is_valid(self): if super().is_valid: # if case vertex1, vertex2 and point in between # are collinear, blockMesh will find an arc with # infinite radius and crash. # so, check for collinearity; if the three points # are actually collinear, this edge is redundant and can be # silently dropped # cross-product of three collinear vertices must be zero arm_1 = self.vertex_1.position - self.third_point.position arm_2 = self.vertex_2.position - self.third_point.position return abs(f.norm(np.cross(arm_1, arm_2))) > constants.TOL return False ================================================ FILE: src/classy_blocks/items/edges/arcs/origin.py ================================================ import dataclasses import warnings from typing import ClassVar import numpy as np from classy_blocks.cbtyping import NPPointType from classy_blocks.construct import edges from classy_blocks.construct.point import Point from classy_blocks.items.edges.arcs.arc_base import ArcEdgeBase from classy_blocks.util import constants from classy_blocks.util import functions as f from classy_blocks.util.constants import vector_format def arc_from_origin( edge_point_1: NPPointType, edge_point_2: NPPointType, center: NPPointType, adjust_center: bool = True, r_multiplier: float = 1.0, ): """Calculates a point on the arc edge from given endpoints and arc origin. An interface to ESI-CFD's alternative arc edge specification: arc origin [multiplier] () https://develop.openfoam.com/Development/openfoam/-/blob/master/src/mesh/blockMesh/blockEdges/arcEdge/arcEdge.H https://www.openfoam.com/news/main-news/openfoam-v20-12/pre-processing#pre-processing-blockmesh""" # meticulously transcribed from # https://develop.openfoam.com/Development/openfoam/-/blob/master/src/mesh/blockMesh/blockEdges/arcEdge/arcEdge.C # Position vectors from centre p1 = edge_point_1 p3 = edge_point_2 r1 = p1 - center r3 = p3 - center mag1 = f.norm(r1) mag3 = f.norm(r3) chord = p3 - p1 axis = np.cross(r1, r3) # The average radius radius = 0.5 * (mag1 + mag3) # The included angle (not needed) # angle = np.arccos(np.dot(r1, r3)/(mag1*mag3)) needs_adjust = False if adjust_center: needs_adjust = abs(mag1 - mag3) > constants.TOL if r_multiplier != 1: # The min radius is constrained by the chord, # otherwise bad things will happen. needs_adjust = True radius = radius * r_multiplier radius = max(radius, (1.001 * 0.5 * f.norm(chord))) if needs_adjust: # The centre is not equidistant to p1 and p3. # Use the chord and the arcAxis to determine the vector to # the midpoint of the chord and adjust the centre along this # line. new_center = (0.5 * (p3 + p1)) + (radius**2 - 0.25 * f.norm(chord) ** 2) ** 0.5 * f.unit_vector( np.cross(axis, chord) ) # mid-chord -> centre warnings.warn("Adjusting center of edge between" + f" {edge_point_1} and {edge_point_2}", stacklevel=2) return arc_from_origin(p1, p3, new_center, False) # done, return the calculated point return f.arc_mid(center, edge_point_1, edge_point_2) @dataclasses.dataclass class OriginEdge(ArcEdgeBase): """Alternative arc edge specification: origin and radius multiplier""" data: edges.Origin # TODO: make this accessible to the user adjust_center: ClassVar[bool] = True @property def third_point(self): """Calculated arc point from origin and flatness""" point = arc_from_origin( self.vertex_1.position, self.vertex_2.position, self.data.origin.position, self.adjust_center, self.data.flatness, ) if np.any(np.isnan(point)): # try to create a friendly error message :/ raise ValueError(f"Invalid edge specification: {self}") return Point(point) @property def description(self): # produce 2 lines: # one commented out with user-provided description # arc 0 1 origin 1.1 (0 0 0) native = f" // arc {self.vertex_1.index} {self.vertex_2.index} origin" native += f" {self.data.flatness} {vector_format(self.data.origin.position)}" # the other one with a default three-point arc description return super().description + native ================================================ FILE: src/classy_blocks/items/edges/curve.py ================================================ import abc import dataclasses import numpy as np from classy_blocks.cbtyping import EdgeKindType, NPPointListType from classy_blocks.construct import edges from classy_blocks.construct.curves.discrete import DiscreteCurve from classy_blocks.items.edges.edge import Edge from classy_blocks.util.constants import vector_format class CurveEdgeBase(Edge, abc.ABC): """Base class for edges of any curved shape, defined as a list of points""" data: edges.OnCurve @property @abc.abstractmethod def point_array(self) -> NPPointListType: """Edge points as numpy array""" @property def param_start(self) -> float: return 0 @property def param_end(self) -> float: return self.data.n_points - 1 @property def description(self): # TODO! Put this into writer point_list = " ".join([vector_format(p) for p in self.point_array]) return super().description + "(" + point_list + ")" @dataclasses.dataclass class SplineEdge(CurveEdgeBase): """Spline edge, defined by multiple points""" data: edges.Spline @property def point_array(self) -> NPPointListType: return self.data.discretize(self.param_start, self.param_end) @property def length(self): points = np.concatenate(([self.vertex_1.position], self.point_array, [self.vertex_2.position])) return DiscreteCurve(points).length @dataclasses.dataclass class PolyLineEdge(SplineEdge): """PolyLine variant of SplineEdge""" data: edges.PolyLine @dataclasses.dataclass class OnCurveEdge(CurveEdgeBase): """Spline edge, defined by a parametric curve""" data: edges.OnCurve @property def length(self): return self.data.curve.get_length(self.param_start, self.param_end) @property def param_start(self) -> float: """Parameter of given curve at vertex 1""" return self.data.curve.get_closest_param(self.vertex_1.position) @property def param_end(self) -> float: """Parameter of given curve at vertex 2""" return self.data.curve.get_closest_param(self.vertex_2.position) @property def point_array(self) -> NPPointListType: return self.data.discretize(self.param_start, self.param_end)[1:-1] @property def representation(self) -> EdgeKindType: return self.data.representation ================================================ FILE: src/classy_blocks/items/edges/edge.py ================================================ import abc import dataclasses import warnings from classy_blocks.base.element import ElementBase from classy_blocks.base.exceptions import EdgeCreationError from classy_blocks.cbtyping import EdgeKindType from classy_blocks.construct.edges import EdgeData from classy_blocks.items.vertex import Vertex from classy_blocks.util import constants from classy_blocks.util import functions as f @dataclasses.dataclass class Edge(ElementBase): """Common stuff for all edge objects""" vertex_1: Vertex vertex_2: Vertex data: EdgeData def __post_init__(self): if not (isinstance(self.vertex_1, Vertex) and isinstance(self.vertex_2, Vertex)): raise EdgeCreationError( "Unable to create `Edge`: at least one of given points is not `Vertex` type", f"Vertex 1: {type(self.vertex_1)}, vertex 2: {type(self.vertex_2)}", ) @property def kind(self) -> EdgeKindType: """A shorthand for edge.data.kind""" return self.data.kind @property def representation(self) -> EdgeKindType: """A string that goes into blockMesh""" return self.data.kind @property def is_valid(self) -> bool: """Returns True if this edge is elligible to be put into blockMeshDict""" if self.kind == "line": # no need to specify lines return False # wedge geometries produce coincident # edges and vertices; drop those if f.norm(self.vertex_1.position - self.vertex_2.position) < constants.TOL: return False # only arc edges need additional checking (blow-up 1/0 protection) # assume others valid return True @property @abc.abstractmethod def length(self) -> float: """Returns length of this edge's curve""" return f.norm(self.vertex_1.position - self.vertex_2.position) @property @abc.abstractmethod def description(self) -> str: """string description of the edge to be put in blockMeshDict""" # FIXME: separate 'description' logic from geometry-related stuff # (a.k.a., throw this somewhere else) # subclasses continue from here return f"\t{self.representation} {self.vertex_1.index} {self.vertex_2.index} " @property def center(self): warnings.warn("Transforming edge with a default center (0 0 0)!", stacklevel=2) return f.vector(0, 0, 0) @property def parts(self): return [self.vertex_1, self.vertex_2, self.data] ================================================ FILE: src/classy_blocks/items/edges/factory.py ================================================ from classy_blocks.cbtyping import EdgeKindType from classy_blocks.construct.edges import EdgeData from classy_blocks.items.edges.arcs.angle import AngleEdge from classy_blocks.items.edges.arcs.arc import ArcEdge from classy_blocks.items.edges.arcs.origin import OriginEdge from classy_blocks.items.edges.curve import OnCurveEdge, PolyLineEdge, SplineEdge from classy_blocks.items.edges.edge import Edge from classy_blocks.items.edges.line import LineEdge from classy_blocks.items.edges.project import ProjectEdge class EdgeFactory: """Generates edges as requested by the user or returns existing ones if they are defined already""" def __init__(self): self.kinds = {} def register_kind(self, kind: EdgeKindType, creator: type[Edge]) -> None: """Introduces a new edge kind to this factory""" self.kinds[kind] = creator def create(self, vertex_1, vertex_2, data: EdgeData) -> Edge: """Creates an Edge* of the desired kind and returns it""" edge_class = self.kinds[data.kind] return edge_class(vertex_1, vertex_2, data) factory = EdgeFactory() factory.register_kind("line", LineEdge) factory.register_kind("arc", ArcEdge) factory.register_kind("origin", OriginEdge) factory.register_kind("angle", AngleEdge) factory.register_kind("spline", SplineEdge) factory.register_kind("curve", OnCurveEdge) factory.register_kind("polyLine", PolyLineEdge) factory.register_kind("project", ProjectEdge) ================================================ FILE: src/classy_blocks/items/edges/line.py ================================================ import dataclasses from classy_blocks.construct import edges from classy_blocks.items.edges.edge import Edge @dataclasses.dataclass class LineEdge(Edge): """A default Line edge; doesn't need an explicit definition and is not output to blockMeshDict""" data: edges.Line @property def length(self): # straight line return super().length @property def description(self): # no need to output that return "" @property def is_valid(self): return False ================================================ FILE: src/classy_blocks/items/edges/project.py ================================================ import dataclasses from classy_blocks.construct import edges from classy_blocks.items.edges.edge import Edge @dataclasses.dataclass class ProjectEdge(Edge): """Edge, projected to a specified geometry""" data: edges.Project @property def length(self): # can't say much about that length, eh? return super().length @property def description(self): return f"\tproject {self.vertex_1.index} {self.vertex_2.index} ({' '.join(self.data.label)})" ================================================ FILE: src/classy_blocks/items/patch.py ================================================ import warnings from classy_blocks.items.side import Side class Patch: """Definition of a patch, including type, belonging faces and other settings""" def __init__(self, name: str): self.name = name self.sides: set[Side] = set() self.kind = "patch" # 'type' self.settings: list[str] = [] self.slave = False def add_side(self, side: Side) -> None: """Adds a side to the list if it doesn't exist yet""" if side in self.sides: warnings.warn(f"Side {side} has already been assigned to {self.name}", stacklevel=2) return self.sides.add(side) ================================================ FILE: src/classy_blocks/items/side.py ================================================ from classy_blocks.base.exceptions import SideCreationError from classy_blocks.cbtyping import OrientType from classy_blocks.items.vertex import Vertex from classy_blocks.util import constants class Side: """A Block has 6 'sides', defined by vertices but addressed by OrientType; new faces can be created from those and patches are assigned to this""" def __init__(self, orient: OrientType, vertices: list[Vertex]): if len(vertices) != 8: raise SideCreationError("Pass exactly 8 of block vertices", f"Given {len(vertices)} vertice(s)") corners = constants.FACE_MAP[orient] self.vertices = [vertices[i] for i in corners] self._hash = hash(tuple(sorted([v.index for v in self.vertices]))) def __eq__(self, other): return {v.index for v in self.vertices} == {v.index for v in other.vertices} def __hash__(self): return self._hash ================================================ FILE: src/classy_blocks/items/vertex.py ================================================ """Defines a numbered vertex in 3D space and all operations that can be applied to it.""" from classy_blocks.cbtyping import PointType from classy_blocks.construct.point import Point class Vertex(Point): """A 3D point in space with all transformations and an assigned index""" # keep the list as a class variable def __init__(self, position: PointType, index: int): super().__init__(position) # index in blockMeshDict; address of this object when creating edges/blocks self.index = index def __eq__(self, other): # When vertices are created from points, # it is ensured there are no duplicated at the same position. # Thus index is unique for the spot. # Same applies for __hash__. return self.index == other.index def __hash__(self): return self.index def __repr__(self): return f"Vertex {self.index} at {self.position}" @classmethod def from_point(cls, point: Point, index: int): """Creates a Vertex from point, including other properties""" vertex = cls(point.position, index) vertex.project(point.projected_to) return vertex ================================================ FILE: src/classy_blocks/items/wires/__init__.py ================================================ ================================================ FILE: src/classy_blocks/items/wires/axis.py ================================================ from classy_blocks.cbtyping import DirectionType from classy_blocks.items.vertex import Vertex from classy_blocks.items.wires.manager import WireManager from classy_blocks.items.wires.wire import Wire class Axis: """One of block axes, indexed 0, 1, 2 and wires - edges that are defined along the same direction.""" def __init__(self, direction: DirectionType, wires: list[Wire]): self.direction = direction self.wires = WireManager(wires) # will be added after blocks are added to mesh self.neighbours: set[Axis] = set() def add_neighbour(self, other: "Axis") -> None: """Adds an 'axis' from another block if it shares at least one wire""" for this_wire in self.wires: for nei_wire in other.wires: if this_wire.is_coincident(nei_wire): self.neighbours.add(other) break def add_inline(self, other: "Axis") -> None: """Adds an axis that comes before/after this one""" # As opposed to neighbours that are 'around' this axis if self.is_inline(other): for this_wire in self.wires: for nei_wire in other.wires: this_wire.add_inline(nei_wire) break def is_aligned(self, other: "Axis") -> bool: """Returns True if wires of the other axis are aligned to wires of this one""" # first identify common wires for this_wire in self.wires: for other_wire in other.wires: if this_wire.is_coincident(other_wire): return this_wire.is_aligned(other_wire) raise RuntimeError("Axes are not neighbours") def is_inline(self, other: "Axis") -> bool: """Returns True if the other axis is in the same 'row' of blocks than the other""" # instead of creating all sets at once and comparing them, # create them on the fly, from the most to least # common scenario in real-life this_end = {wire.vertices[1] for wire in self.wires} other_start = {wire.vertices[0] for wire in other.wires} if this_end == other_start: return True this_start = {wire.vertices[0] for wire in self.wires} if this_start == other_start: return True other_end = {wire.vertices[1] for wire in other.wires} if this_end == other_end: return True if this_start == other_end: return True return False @property def lengths(self) -> list[float]: return [w.length for w in self.wires] @property def start_vertices(self) -> set[Vertex]: return {wire.vertices[0] for wire in self.wires} @property def end_vertices(self) -> set[Vertex]: return {wire.vertices[1] for wire in self.wires} @property def count(self) -> int: return self.wires.count @property def is_simple(self) -> bool: return self.wires.is_simple @property def is_graded(self): # TODO: change to is_chopped return all(wire.is_graded for wire in self.wires) def __str__(self): return f"Axis {self.direction} (" + "|".join(str(wire) for wire in self.wires.wires) + ")" def __hash__(self): return id(self) ================================================ FILE: src/classy_blocks/items/wires/manager.py ================================================ from classy_blocks.items.wires.wire import Wire class WireManager: def __init__(self, wires: list[Wire]): self.wires = wires def __getitem__(self, index) -> Wire: return self.wires[index] def __iter__(self): return iter(self.wires) def format_single(self) -> str: """Returns a single formatted grading specification for simpleGrading""" return self.wires[0].grading.description def format_all(self) -> str: """Returns all grading specifications, formatted for edgeGrading""" return " ".join([wire.grading.description for wire in self.wires]) @property def length(self) -> float: """Returns length for each wire of this axis; to be used for grading calculation""" return sum(wire.edge.length for wire in self.wires) / 4 @property def is_simple(self) -> bool: """Returns True if only simpleGrading is required for this axis""" # That means all gradings are the same first_grading = self.wires[0].grading for wire in self.wires[1:]: if wire.grading != first_grading: return False return True @property def count(self): return self.wires[0].grading.count ================================================ FILE: src/classy_blocks/items/wires/wire.py ================================================ import dataclasses import functools from classy_blocks.cbtyping import DirectionType from classy_blocks.construct.edges import Line from classy_blocks.grading.define.grading import Grading, GradingBase from classy_blocks.items.edges.edge import Edge from classy_blocks.items.edges.factory import factory from classy_blocks.items.vertex import Vertex @functools.cache def get_length(wire: "Wire") -> float: return wire.edge.length @dataclasses.dataclass class WireJoint: """Remembers an inline wire (before/after) and its orientation (same direction/inverted).""" wire: "Wire" same_dir: bool def __hash__(self): return id(self.wire) class Wire: """Represents two vertices that define an edge; supplies tools to create and compare, etc""" def __init__(self, vertices: list[Vertex], direction: DirectionType, corner_1: int, corner_2: int): self.corners = [corner_1, corner_2] self.vertices = [vertices[corner_1], vertices[corner_2]] self.direction: DirectionType = direction # the default edge is 'line' but will be replaced if the user wishes so self.edge: Edge = factory.create(self.vertices[0], self.vertices[1], Line()) # grading/counts of this wire self.grading: GradingBase = Grading(0) # multiple wires can be at the same spot; this list holds other # coincident wires from different blocks self.coincidents: set[Wire] = set() # wires that precede this (end with this wire's beginning vertex) self.before: set[WireJoint] = set() # wires that follow this (start with this wire's end vertex) self.after: set[WireJoint] = set() self.key = hash(tuple(sorted([v.index for v in self.vertices]))) @property def length(self) -> float: return get_length(self) def update(self) -> None: """Re-sets grading's edge length after the edge has changed""" self.grading.length = self.length def is_coincident(self, candidate: "Wire") -> bool: """Returns True if this wire is in the same spot than the argument, regardless of alignment""" return self.key == candidate.key def is_aligned(self, candidate: "Wire") -> bool: """Returns true is this pair has the same alignment as the pair in the argument""" if not self.is_coincident(candidate): raise RuntimeError(f"Wires are not coincident: {self}, {candidate}") return self.vertices == candidate.vertices def add_edge(self, edge: Edge) -> None: self.edge = edge def add_coincident(self, candidate: "Wire") -> None: """Adds a reference to a coincident wire, if it's aligned""" if self.is_coincident(candidate): self.coincidents.add(candidate) def add_inline(self, candidate: "Wire") -> None: """Adds a reference to a wire that is before or after this one in the same direction""" # this assumes the lines are inline and in the same axis # TODO: Test # TODO: one-liner, bitte if candidate == self: return if candidate.vertices[1] == self.vertices[0]: self.before.add(WireJoint(candidate, True)) elif candidate.vertices[0] == self.vertices[0]: self.before.add(WireJoint(candidate, False)) elif candidate.vertices[0] == self.vertices[1]: self.after.add(WireJoint(candidate, True)) elif candidate.vertices[1] == self.vertices[1]: self.after.add(WireJoint(candidate, False)) @property def is_graded(self) -> bool: return self.grading.is_defined @property def is_collapsed(self): return self.vertices[0] == self.vertices[1] def __repr__(self): return f"Wire {self.corners[0]}-{self.corners[1]} ({self.vertices[0].index}-{self.vertices[1].index})" # def __hash__(self): # return self.key ================================================ FILE: src/classy_blocks/lists/__init__.py ================================================ ================================================ FILE: src/classy_blocks/lists/block_list.py ================================================ from classy_blocks.items.block import Block from classy_blocks.lookup.point_registry import HexPointRegistry class BlockList: """Handling of the 'blocks' part of blockMeshDict""" def __init__(self) -> None: self.blocks: list[Block] = [] def add(self, block: Block) -> None: """Add blocks""" self.blocks.append(block) def update_neighbours(self, registry: HexPointRegistry) -> None: """Find and assign neighbours of a given block entry""" for block in self.blocks: neighbour_indexes = registry.find_cell_neighbours(block.index) for i in neighbour_indexes: block.add_neighbour(self.blocks[i]) def __hash__(self): return id(self) ================================================ FILE: src/classy_blocks/lists/edge_list.py ================================================ from classy_blocks.base.exceptions import EdgeNotFoundError from classy_blocks.construct.edges import EdgeData from classy_blocks.construct.operations.operation import Operation from classy_blocks.items.edges.edge import Edge from classy_blocks.items.edges.factory import factory from classy_blocks.items.vertex import Vertex EdgeLocationType = tuple[int, int] def get_location(vertex_1, vertex_2): if vertex_1.index < vertex_2.index: return (vertex_1.index, vertex_2.index) return (vertex_2.index, vertex_1.index) class EdgeList: """Handling of the 'edges' part of blockMeshDict""" def __init__(self) -> None: self.edges: dict[EdgeLocationType, Edge] = {} def find(self, vertex_1: Vertex, vertex_2: Vertex) -> Edge: location = get_location(vertex_1, vertex_2) if location in self.edges: return self.edges[location] raise EdgeNotFoundError def add(self, vertex_1: Vertex, vertex_2: Vertex, data: EdgeData) -> Edge: """Adds an edge between given vertices or returns an existing one""" location = get_location(vertex_1, vertex_2) # if this edge exists in the list, return it regardless of what's # specified in edge_data; redefinitions of the same edges are ignored if location in self.edges: return self.edges[location] edge = factory.create(vertex_1, vertex_2, data) if edge.is_valid: self.edges[location] = edge return edge def add_from_operation(self, vertices: list[Vertex], operation: Operation) -> list[tuple[int, int, Edge]]: """Queries the operation for edge data and creates edge objects from it""" data_frame = operation.edges edges = [] for data in data_frame.get_all_beams(): corner_1 = data[0] corner_2 = data[1] vertex_1 = vertices[corner_1] vertex_2 = vertices[corner_2] edge = self.add(vertex_1, vertex_2, data[2]) edges.append((corner_1, corner_2, edge)) return edges ================================================ FILE: src/classy_blocks/lists/face_list.py ================================================ import dataclasses from classy_blocks.cbtyping import OrientType from classy_blocks.construct.flat.face import Face from classy_blocks.construct.operations.operation import Operation from classy_blocks.items.side import Side from classy_blocks.items.vertex import Vertex from classy_blocks.util.constants import SIDES_MAP @dataclasses.dataclass class ProjectedFace: """An entry in blockMeshDict.faces""" side: Side label: str def __eq__(self, other): return self.side == other.side class FaceList: """Handling of projected faces (the 'faces' part of blockMeshDict)""" def __init__(self) -> None: self.faces: list[ProjectedFace] = [] def find_existing(self, side: Side) -> bool: """Returns true if side in arguments exists already""" for face in self.faces: if face.side == side: return True return False def add(self, vertices: list[Vertex], operation: Operation) -> None: """Collect projected sides from operation data""" for index, orient in enumerate(SIDES_MAP): label = operation.side_projects[index] if label is not None: self.add_side(Side(orient, vertices), label) # bottom and top faces self.add_face(vertices, "bottom", operation.bottom_face) self.add_face(vertices, "top", operation.top_face) def add_face(self, vertices: list[Vertex], orient: OrientType, face: Face) -> None: """Add a face to faces list (if it is projected to anything)""" if face.projected_to is not None: self.add_side(Side(orient, vertices), face.projected_to) def add_side(self, side: Side, label: str) -> None: """Adds a projected face (side) to the list if it's not there yet""" if not self.find_existing(side): self.faces.append(ProjectedFace(side, label)) ================================================ FILE: src/classy_blocks/lists/patch_list.py ================================================ from collections import OrderedDict from typing import Optional from classy_blocks.base.exceptions import PatchNotFoundError from classy_blocks.cbtyping import OrientType from classy_blocks.construct.operations.operation import Operation from classy_blocks.items.patch import Patch from classy_blocks.items.side import Side from classy_blocks.items.vertex import Vertex class PatchList: """Handling of the patches ('boundary') part of blockMeshDict""" def __init__(self) -> None: self.patches: OrderedDict[str, Patch] = OrderedDict() self.default: dict[str, str] = {} self.merged: list[list[str]] = [] # data for the mergePatchPairs entry def add(self, vertices: list[Vertex], operation: Operation) -> None: """Create Patches from operation's patch_names""" for orient, name in operation.patch_names.items(): self.add_side(name, orient, vertices) def get(self, name: str) -> Patch: """Fetches an existing Patch or creates a new one""" if name not in self.patches: self.patches[name] = Patch(name) return self.patches[name] def find(self, vertices: set[Vertex]) -> Patch: # TODO: use FaceRegistry for patch in self.patches.values(): for side in patch.sides: if set(side.vertices) == vertices: return patch raise PatchNotFoundError def add_side(self, patch_name: str, orient: OrientType, vertices: list[Vertex]) -> None: """Adds a quad to an existing patch or creates a new one""" self.get(patch_name).add_side(Side(orient, vertices)) def modify(self, name: str, kind: str, settings: Optional[list[str]] = None) -> None: """Changes patch's properties""" patch = self.get(name) patch.kind = kind if settings is not None: patch.settings = settings def merge(self, master: str, slave: str) -> None: """Adds an entry in mergePatchPairs list in blockMeshDict""" self.merged.append([master, slave]) ================================================ FILE: src/classy_blocks/lists/vertex_list.py ================================================ from classy_blocks.base.exceptions import VertexNotFoundError from classy_blocks.cbtyping import NPPointType from classy_blocks.construct.point import Point from classy_blocks.items.vertex import Vertex from classy_blocks.util import constants from classy_blocks.util import functions as f class DuplicatedEntry: """A pair vertex:{set of slave patches} that describes a duplicated vertex on mentioned patches""" def __init__(self, vertex: Vertex, patches: set[str]): self.vertex = vertex self.patches = patches @property def point(self) -> NPPointType: """Vertex's point""" return self.vertex.position class VertexList: """Handling of the 'vertices' part of blockMeshDict""" def __init__(self, vertices: list[Vertex]) -> None: self.vertices = vertices # a collection of duplicated vertices # belonging to a certain patch name self.duplicated: list[DuplicatedEntry] = [] def find_duplicated(self, position: NPPointType, slave_patches: set[str]) -> Vertex: """Finds an appropriate entry in self.duplicated, if any""" # TODO: use vertex indexing for dupe in self.duplicated: if f.norm(position - dupe.point) < constants.TOL: if dupe.patches == slave_patches: return dupe.vertex raise VertexNotFoundError(f"No duplicated vertex found: {position} {slave_patches}") def add_duplicated(self, point: Point, slave_patches: set[str]) -> Vertex: """Re-use existing vertices when there's already one at the position; unless that vertex belongs to a slave of a face-merged pair - in that case add a duplicate in the same position anyway""" # different scenarios: # these are covered by grid and KDTree logic # 1. add a new vertex, nothing exist at this location yet # 2. reuse an existing vertex at this location # we need to take care about this: # 3. add a new, duplicated vertex at the same location but for a different set of slave patches # 4. add a new, 'master' vertex because what's at the same location belongs to a slave patch try: vertex = self.find_duplicated(point.position, slave_patches) except VertexNotFoundError: vertex = Vertex.from_point(point, len(self.vertices)) self.vertices.append(vertex) self.duplicated.append(DuplicatedEntry(vertex, slave_patches)) return vertex ================================================ FILE: src/classy_blocks/lookup/__init__.py ================================================ ================================================ FILE: src/classy_blocks/lookup/cell_registry.py ================================================ from classy_blocks.cbtyping import IndexType from classy_blocks.util import functions as f class CellRegistry: # Works for both quads and hexas def __init__(self, addressing: list[IndexType]): self.addressing = addressing # build a map of cells that belong to each point; # first, find the maximum point index max_index = max(f.flatten_2d_list(addressing)) self.near_cells: dict[int, set[int]] = {i: set() for i in range(max_index + 1)} for i_cell, cell_indexes in enumerate(self.addressing): for i_point in cell_indexes: self.near_cells[i_point].add(i_cell) def get_near_cells(self, point_index: int) -> set[int]: return self.near_cells[point_index] ================================================ FILE: src/classy_blocks/lookup/connection_registry.py ================================================ from typing import ClassVar from classy_blocks.cbtyping import IndexType, NPPointListType from classy_blocks.util.constants import EDGE_PAIRS ConnectionType = tuple[int, int] def get_key(index_1: int, index_2: int): return (min(index_1, index_2), max(index_1, index_2)) class Connection: """Connects two points by their index in unique point list""" def __init__(self, index_1: int, index_2: int): self.indexes = get_key(index_1, index_2) def get_other_index(self, index): if self.indexes[0] == index: return self.indexes[1] return self.indexes[0] def __repr__(self): return f"Connection {self.indexes[0]}-{self.indexes[1]}" def __hash__(self): return hash(self.indexes) class ConnectionRegistryBase: edge_pairs: ClassVar[list[ConnectionType]] def __init__(self, points: NPPointListType, addressing: list[IndexType]): self.points = points self.addressing = addressing self.connections: dict[ConnectionType, Connection] = {} self.nodes: dict[int, set[Connection]] = {i: set() for i in range(len(self.points))} for i in range(len(self.addressing)): cell_indexes = self.addressing[i] for pair in self.edge_pairs: index_1 = cell_indexes[pair[0]] index_2 = cell_indexes[pair[1]] edge_key = get_key(index_1, index_2) if edge_key not in self.connections: connection = Connection(index_1, index_2) self.connections[edge_key] = connection self.nodes[index_1].add(connection) self.nodes[index_2].add(connection) def get_connected_indexes(self, index: int) -> set[int]: return {c.get_other_index(index) for c in self.nodes[index]} class QuadConnectionRegistry(ConnectionRegistryBase): edge_pairs: ClassVar = [(0, 1), (1, 2), (2, 3), (3, 0)] class HexConnectionRegistry(ConnectionRegistryBase): edge_pairs: ClassVar = EDGE_PAIRS ================================================ FILE: src/classy_blocks/lookup/face_registry.py ================================================ import abc from typing import ClassVar from classy_blocks.cbtyping import IndexType, OrientType from classy_blocks.optimize.cell import CellBase, HexCell, QuadCell class FaceRegistryBase(abc.ABC): cell_type: ClassVar[type[CellBase]] def get_key_from_side(self, cell: int, side: OrientType) -> tuple: cell_indexes = self.addressing[cell] face_corners = self.orient_indexes[side] face_indexes = [cell_indexes[c] for c in face_corners] return tuple(sorted(face_indexes)) def __init__(self, addressing: list[IndexType]): self.addressing = addressing # this is almost equal to constants.FACE_MAP except # faces are oriented so that they point towards cell centers self.orient_indexes: dict[OrientType, IndexType] = { name: self.cell_type.side_indexes[i] for i, name in enumerate(self.cell_type.side_names) } # will hold faces, accessible in O(1) time, and their coincident cell(s); # boundary faces will have a single coincident cell, internal two self.faces: dict[tuple, set[int]] = {} for cell in range(len(self.addressing)): for side in self.orient_indexes.keys(): face_key = self.get_key_from_side(cell, side) if face_key not in self.faces: self.faces[face_key] = set() self.faces[face_key].add(cell) def get_cells(self, cell: int, side: OrientType) -> set[int]: return self.faces[self.get_key_from_side(cell, side)] class QuadFaceRegistry(FaceRegistryBase): cell_type = QuadCell class HexFaceRegistry(FaceRegistryBase): cell_type = HexCell ================================================ FILE: src/classy_blocks/lookup/point_registry.py ================================================ from typing import TypeVar import numpy as np import scipy.spatial from classy_blocks.base.exceptions import VertexNotFoundError from classy_blocks.cbtyping import IndexType, NPPointListType, PointType from classy_blocks.construct.flat.sketch import Sketch from classy_blocks.construct.operations.operation import Operation from classy_blocks.util import functions as f from classy_blocks.util.constants import TOL, vector_format PointRegistryType = TypeVar("PointRegistryType", bound="PointRegistryBase") class PointRegistryBase: """Searches for, connects and creates unique points, taken from lists of elements (quads, hexas, ...)""" cell_size: int # 4 for quads, 8 for hexas def __init__(self, flattened_points: NPPointListType, merge_tol: float) -> None: if len(flattened_points) % self.cell_size != 0: raise ValueError(f"Number of points not divisible by cell_size: {len(flattened_points)} % {self.cell_size}") self.merge_tol = merge_tol # a flattened list of all points, possibly multiple at the same spot; # each 'cell' gets its own 'drawer' with indexes i...i+cell_size self._repeated_points = flattened_points self._repeated_point_tree = scipy.spatial.KDTree(self._repeated_points) # a list of unique points, analogous to blockMesh's vertex list self.unique_points = self._compile_unique() self._unique_point_tree = scipy.spatial.KDTree(self.unique_points) self.cell_addressing = [self._query_unique(self.find_cell_points(i)) for i in range(self.cell_count)] def _compile_unique(self) -> NPPointListType: # create a list of unique vertices, taken from the list of operations unique_points = [] handled_indexes: set[int] = set() for point in self._repeated_points: coincident_indexes = self._repeated_point_tree.query_ball_point(point, r=self.merge_tol, workers=1) if set(coincident_indexes).isdisjoint(handled_indexes): # this vertex hasn't been handled yet unique_points.append(point) handled_indexes.update(coincident_indexes) return np.array(unique_points) def _query_unique(self, positions) -> list[int]: """A shortcut to KDTree.query_ball_point()""" result = self._unique_point_tree.query_ball_point(positions, r=self.merge_tol, workers=1) if len(np.shape(positions)) > 1: return f.flatten_2d_list(result) return result def _query_repeated(self, positions) -> list[int]: result = self._repeated_point_tree.query_ball_point(positions, r=self.merge_tol, workers=1) if len(np.shape(positions)) > 1: return f.flatten_2d_list(result) return result def find_point_index(self, position: PointType) -> int: """Returns the vertex at given position; raises an exception if multiple or none were found""" indexes = self._query_unique(position) if len(indexes) == 0: raise VertexNotFoundError(f"Vertex at {vector_format(position)} not found!") return indexes[0] def find_point_cells(self, position: PointType) -> list[int]: """Returns indexes of every cell that has a point at given position""" indexes = [i // self.cell_size for i in self._query_repeated(position)] return list(set(indexes)) def find_cell_points(self, cell: int) -> NPPointListType: """Returns points that define this cell""" start_index = cell * self.cell_size end_index = start_index + self.cell_size return self._repeated_points[start_index:end_index] def find_cell_indexes(self, cell: int) -> list[int]: """Returns indexes of points that define this cell""" return self.cell_addressing[cell] def find_cell_neighbours(self, cell: int) -> list[int]: """Returns indexes of this and every touching cell""" cell_points = self.find_cell_points(cell) indexes = [] for point in cell_points: indexes += self.find_point_cells(point) return list(set(indexes)) @staticmethod def flatten(points, length) -> NPPointListType: return np.reshape(points, (length, 3)) @classmethod def from_addresses( cls: type[PointRegistryType], points: NPPointListType, addressing: list[IndexType], merge_tol: float = TOL ) -> PointRegistryType: all_points = cls.flatten( [np.take(points, addr, axis=0) for addr in addressing], len(addressing) * cls.cell_size ) return cls(all_points, merge_tol) @property def cell_count(self) -> int: return len(self._repeated_points) // self.cell_size @property def point_count(self) -> int: return len(self.unique_points) class QuadPointRegistry(PointRegistryBase): """A registry of points, taken from a list of quads""" cell_size = 4 @classmethod def from_sketch(cls: type[PointRegistryType], sketch: Sketch, merge_tol: float = TOL) -> PointRegistryType: return cls( cls.flatten([face.point_array for face in sketch.faces], len(sketch.faces) * cls.cell_size), merge_tol ) class HexPointRegistry(PointRegistryBase): """A registry of points, taken from a list of hexas""" cell_size = 8 @classmethod def from_operations( cls: type[PointRegistryType], operations: list[Operation], merge_tol: float = TOL ) -> PointRegistryType: all_points = cls.flatten([op.point_array for op in operations], len(operations) * cls.cell_size) return cls(all_points, merge_tol) ================================================ FILE: src/classy_blocks/mesh.py ================================================ from typing import Optional, Union from classy_blocks.assemble.assembler import MeshAssembler from classy_blocks.assemble.depot import Depot from classy_blocks.assemble.dump import AssembledDump, DumpBase, EmptyDump from classy_blocks.assemble.settings import Settings from classy_blocks.cbtyping import GeometryType from classy_blocks.construct.assemblies.assembly import Assembly from classy_blocks.construct.operations.operation import Operation from classy_blocks.construct.shape import Shape from classy_blocks.construct.stack import Stack from classy_blocks.grading.graders.manager import GradingManager from classy_blocks.items.block import Block from classy_blocks.items.patch import Patch from classy_blocks.items.vertex import Vertex from classy_blocks.util.constants import TOL from classy_blocks.write.vtk import mesh_to_vtk from classy_blocks.write.writer import MeshWriter AdditiveType = Union[Operation, Shape, Stack, Assembly] class Mesh: """contains blocks, edges and all necessary methods for assembling blockMeshDict""" def __init__(self) -> None: # List of all added/deleted operations/shapes self.depot = Depot() self.settings = Settings() # container for items - assembled blocks, vertices, patches, etc. self.dump: DumpBase = EmptyDump() def add(self, solid: AdditiveType) -> None: """Add a classy_blocks solid to the mesh (Loft, Shape, Assembly, ...)""" # this does nothing yet; # the data will be processed automatically at an # appropriate occasion (before write/optimize) self.depot.add_solid(solid) def merge_patches(self, master: str, slave: str) -> None: """Merges two non-conforming named patches using face merging; https://www.openfoam.com/documentation/user-guide/4-mesh-generation-and-conversion/4.3-mesh-generation-with-the-blockmesh-utility#x13-470004.3.2 (breaks the 100% hex-mesh rule)""" self.settings.merged_patches.append((master, slave)) def set_default_patch(self, name: str, kind: str) -> None: """Adds the 'defaultPatch' entry to the mesh; any non-specified block boundaries will be assigned this patch""" self.settings.default_patch = {"name": name, "kind": kind} def modify_patch(self, name: str, kind: str, settings: Optional[list[str]] = None) -> None: """Fetches a patch named 'patch' and modifies its type and optionally other settings. They are passed on to blockMeshDict as a list of strings as-is, with no additional brain power used""" self.settings.modify_patch(name, kind, settings) def add_geometry(self, geometry: GeometryType) -> None: """Adds named entry in the 'geometry' section of blockMeshDict; 'geometry' is in the form of dictionary {'geometry_name': [list of properties]}; properties are as specified by searchable* class in documentation. See examples/advanced/project for an example.""" self.settings.add_geometry(geometry) def delete(self, operation: Operation) -> None: """Excludes the given operation from any processing; the data remains but it will not contribute to the mesh""" self.depot.delete_solid(operation) def assemble(self, merge_tol: float = TOL) -> AssembledDump: """Converts classy_blocks entities (operations and shapes) to actual vertices, edges, blocks and other stuff to be inserted into blockMeshDict. After this has been done, the above objects cease to have any function or influence on mesh.""" if self.is_assembled: assert isinstance(self.dump, AssembledDump) return self.dump assembler = MeshAssembler(self.depot, self.settings, merge_tol) self.dump = assembler.assemble() return self.dump def clear(self) -> None: """Undoes the assemble() method; clears created blocks and other lists but leaves added depot items intact""" self.dump = EmptyDump() def backport(self) -> None: """When mesh is assembled, points from depot are converted to vertices and operations are converted to blocks. When vertices are edited (modification/optimization), depot entities remain unchanged. This can cause problems with some edges (Origin, Axis, ...) and future stuff. This method updates depot from blocks, clears all lists and reassembles the mesh as if it was modified from the start.""" if not self.is_assembled: raise RuntimeError("Cannot backport non-assembled mesh") operations = self.operations blocks = self.blocks for i, block in enumerate(blocks): op = operations[i] vertices = [vertex.position for vertex in block.vertices] op.bottom_face.update(vertices[:4]) op.top_face.update(vertices[4:]) self.clear() self.assemble() def grade(self) -> None: """Converts chops from operations into gradings on Blocks. Will fail if the mesh has not been assembled yet and will also raise an exception if chops are over- or under-defined. Is called automatically when writing the mesh.""" self.assemble() assert isinstance(self.dump, AssembledDump) # to pacify type checker manager = GradingManager(self.dump, self.settings) manager.grade() def write(self, output_path: str, debug_path: Optional[str] = None, merge_tol: float = TOL) -> None: """Writes a blockMeshDict to specified location. If debug_path is specified, a VTK file is created first where each block is a single cell, to see simplified blocking in case blockMesh fails with an unfriendly error message.""" if not self.is_assembled: self.assemble(merge_tol) if debug_path is not None: mesh_to_vtk(debug_path, self.vertices, self.blocks) # gradings: define after writing VTK; # if it is not specified correctly, this will raise an exception self.grade() assert isinstance(self.dump, AssembledDump) # to pacify type checker writer = MeshWriter(self.dump, self.settings) writer.write(output_path) @property def is_assembled(self) -> bool: """Returns True if assemble() has been executed on this mesh""" return self.dump.is_assembled @property def vertices(self) -> list[Vertex]: return self.dump.vertices @property def patches(self) -> list[Patch]: return list(self.dump.patches) @property def operations(self) -> list[Operation]: """Returns a list of operations from all entities in depot""" return self.depot.operations @property def blocks(self) -> list[Block]: return self.dump.blocks ================================================ FILE: src/classy_blocks/modify/__init__.py ================================================ ================================================ FILE: src/classy_blocks/modify/find/__init__.py ================================================ ================================================ FILE: src/classy_blocks/modify/find/finder.py ================================================ from typing import Optional import numpy as np from classy_blocks.cbtyping import PointType from classy_blocks.items.vertex import Vertex from classy_blocks.mesh import Mesh from classy_blocks.util import constants from classy_blocks.util import functions as f class FinderBase: """Base class for locating Mesh vertices""" def __init__(self, mesh: Mesh): self.mesh = mesh def _find_by_position(self, position: PointType, radius: Optional[float] = None) -> set[Vertex]: """Returns a list of vertices that are inside a sphere of given radius; if that is not given, constants.TOL is taken""" found_vertices: set[Vertex] = set() if radius is None: radius = constants.TOL # TODO: optimize with octree/kdtree position = np.array(position) for vertex in self.mesh.vertices: if f.norm(vertex.position - position) < radius: found_vertices.add(vertex) return found_vertices ================================================ FILE: src/classy_blocks/modify/find/geometric.py ================================================ from typing import Optional from classy_blocks.cbtyping import PointType, VectorType from classy_blocks.items.vertex import Vertex from classy_blocks.modify.find.finder import FinderBase from classy_blocks.util import functions as f class GeometricFinder(FinderBase): """Find mesh vertices inside a specified geometric shape""" def find_in_sphere(self, position: PointType, radius: Optional[float] = None) -> set[Vertex]: """Returns vertices that are inside a sphere of given radius; if that is not given, constants.TOL is taken""" return self._find_by_position(position, radius) def find_in_box_corners(self, corner_point: PointType, diagonal_point: PointType) -> set[Vertex]: """Returns vertices that are inside a box, aligned with cartesian coordinate system and defined by two points on each end of volumetric diagonal.""" # TODO: un-wip this raise NotImplementedError("Alas, this is a work-in-progress") def find_in_box_center(self, center_point: PointType, size_x: float, size_y: float, size_z: float) -> set[Vertex]: """Returns vertices that are inside a box, aligned with cartesian coordinate system and defined by its center and width, height and depth.""" # TODO: un-wip this raise NotImplementedError("Alas, this is a work-in-progress") def find_on_plane(self, point: PointType, normal: VectorType): """Returns vertices that lie on a plane, defined by a point and normal vector.""" found_vertices: set[Vertex] = set() for vertex in self.mesh.vertices: if f.is_point_on_plane(point, normal, vertex.position): found_vertices.add(vertex) return found_vertices ================================================ FILE: src/classy_blocks/modify/find/shape.py ================================================ from classy_blocks.construct.flat.face import Face from classy_blocks.construct.flat.sketches.disk import Disk from classy_blocks.construct.point import Point from classy_blocks.construct.shapes.round import RoundSolidShape from classy_blocks.items.vertex import Vertex from classy_blocks.mesh import Mesh from classy_blocks.modify.find.finder import FinderBase class RoundSolidFinder(FinderBase): """Find vertices on start/end faces of a round solid shape (Cylinder, Elbow, Frustum), ...""" def __init__(self, mesh: Mesh, shape: RoundSolidShape): super().__init__(mesh) self.shape = shape def _get_sketch(self, end_face: bool) -> Disk: if end_face: return self.shape.sketch_2 return self.shape.sketch_1 def _find_from_points(self, points: list[Point]) -> set[Vertex]: vertices: set[Vertex] = set() for point in points: vertices.update(self._find_by_position(point.position)) return vertices def _find_from_faces(self, faces: list[Face]) -> set[Vertex]: vertices: set[Vertex] = set() for face in faces: vertices.update(self._find_from_points(face.points)) return vertices def find_core(self, end_face: bool = False) -> set[Vertex]: """Returns a list of vertices that define inner vertices of a round shape""" faces = self._get_sketch(end_face).core return self._find_from_faces(faces) def find_shell(self, end_face: bool = False) -> set[Vertex]: """Returns a list of vertices on the outer edge of the shape. This only includes two of the vertices that define shell blocks!""" shell_vertices = self._find_from_faces(self._get_sketch(end_face).shell) core_vertices = self._find_from_faces(self._get_sketch(end_face).core) return shell_vertices - core_vertices ================================================ FILE: src/classy_blocks/modify/reorient/__init__.py ================================================ ================================================ FILE: src/classy_blocks/modify/reorient/viewpoint.py ================================================ import numpy as np from scipy.spatial import ConvexHull from classy_blocks.base.exceptions import DegenerateGeometryError from classy_blocks.cbtyping import NPPointListType, NPPointType, NPVectorType, OrientType, PointType from classy_blocks.construct.operations.operation import Operation from classy_blocks.util import constants from classy_blocks.util import functions as f class Triangle: """A 'Simplex' in scipy terms, but in 3D this is just a triangle.""" def __init__(self, points: list[NPPointType]): self.points = points @property def normal(self) -> NPVectorType: side_1 = self.points[1] - self.points[0] side_2 = self.points[2] - self.points[0] return f.unit_vector(np.cross(side_1, side_2)) @property def center(self) -> NPPointType: return np.average(self.points, axis=0) def flip(self): """Flips the triangle so that its normal points the other way""" self.points = np.flip(self.points, axis=0) def orient(self, hull_center: NPPointType) -> None: """Flips the triangle around (if needed) so that normal always points away from the provided hull center""" if np.dot(self.center - hull_center, self.normal) < 0: self.flip() class Quadrangle: """A block face.""" def __init__(self, triangles: list[Triangle]): if len(triangles) > 2: raise DegenerateGeometryError("A Quadrangle can only be defined with two triangles!") common_points = self.get_common_points(triangles[0].points, triangles[1].points) if len(common_points) != 2: raise DegenerateGeometryError("Two triangles that form a face do not have 2 common points!") unique_points = self.get_unique_points(triangles[0].points, triangles[1].points) if len(unique_points) != 2: raise DegenerateGeometryError("Two triangles that form a face do not have 2 unique points!") self.points = [*unique_points, *common_points] # will be sorted later @staticmethod def get_unique_points(list_1: list[NPPointType], list_2: list[NPPointType]) -> list[NPPointType]: """Returns points from list_1 that are not in list_2""" common_points = Quadrangle.get_common_points(list_1, list_2) unique_points: list[NPPointType] = [] for point in [*list_1, *list_2]: unique = True for common_point in common_points: if f.norm(point - common_point) < constants.TOL: unique = False break if unique: unique_points.append(point) return unique_points @staticmethod def get_common_points(list_1: list[NPPointType], list_2: list[NPPointType]) -> list[NPPointType]: """Returns points on the same position in both lists""" common_points: list[NPPointType] = [] for point_1 in list_1: for point_2 in list_2: if f.norm(point_1 - point_2) < constants.TOL: common_points.append(point_1) return common_points def get_common_point(self, quad_1: "Quadrangle", quad_2: "Quadrangle") -> NPPointType: """Identifies common points between this and two other quads.""" common_1 = self.get_common_points(self.points, quad_1.points) common_2 = self.get_common_points(common_1, quad_2.points) if len(common_2) > 1: raise DegenerateGeometryError("More than a single common point between 3 faces!") return common_2[0] class ViewpointReorienter: """Reorient an Operation so that faces are aligned as viewed by observer from a specified viewpoint. Two points must be specified, one 'in front' of the block (preferrably far away) and other 'above' the block (can also be far away). Will fail with degenerate hexahedras (concavity, wedges, dubiously aligned faces, ...). Reorienting will be done in-place so that all other Operation attributes remain unchanged. Therefore it is recommended you do sorting BEFORE adding any edges, patches, and so on. In other case, behaviour is undetermined.""" def __init__(self, observer: PointType, ceiling: PointType): self.observer = np.array(observer) self.ceiling = np.array(ceiling) def _make_triangles(self, points: NPPointListType) -> list[Triangle]: """Creates triangles from hull's simplices""" hull = ConvexHull(points) center = np.average(points, axis=0) if len(hull.simplices) != 12: raise DegenerateGeometryError("The operation is not convex!") point_list = [np.take(points, indexes, axis=0) for indexes in hull.simplices] triangles = [Triangle(points) for points in point_list] for triangle in triangles: triangle.orient(center) return triangles def _get_normals(self, center: NPPointType) -> dict[OrientType, NPVectorType]: v_observer = f.unit_vector(np.array(self.observer) - center) v_ceiling = f.unit_vector(np.array(self.ceiling) - center) # correct ceiling so that it's always at right angle with observer correction = np.dot(v_ceiling, v_observer) * v_observer v_ceiling -= correction v_ceiling = f.unit_vector(v_ceiling) v_left = f.unit_vector(np.cross(v_observer, v_ceiling)) return { "front": v_observer, "back": -v_observer, "top": v_ceiling, "bottom": -v_ceiling, "left": v_left, "right": -v_left, } def _get_aligned(self, triangles: list[Triangle], vector: NPVectorType) -> list[Triangle]: return sorted(triangles, key=lambda t: np.dot(t.normal, vector))[-2:] def reorient(self, operation: Operation): triangles = self._make_triangles(operation.point_array) normals = self._get_normals(operation.center) remaining_triangles = set(triangles) quads: dict[OrientType, Quadrangle] = {} for key, normal in normals.items(): # Take two most nicely aligned triangles aligned = self._get_aligned(list(remaining_triangles), normal) quads[key] = Quadrangle(aligned) remaining_triangles -= set(aligned) # find each point by intersecting specific quads sorted_points = [ quads["bottom"].get_common_point(quads["front"], quads["left"]), quads["bottom"].get_common_point(quads["front"], quads["right"]), quads["bottom"].get_common_point(quads["back"], quads["right"]), quads["bottom"].get_common_point(quads["back"], quads["left"]), quads["top"].get_common_point(quads["front"], quads["left"]), quads["top"].get_common_point(quads["front"], quads["right"]), quads["top"].get_common_point(quads["back"], quads["right"]), quads["top"].get_common_point(quads["back"], quads["left"]), ] for i, point in enumerate(operation.bottom_face.points): point.position = sorted_points[i] for i, point in enumerate(operation.top_face.points): point.position = sorted_points[i + 4] ================================================ FILE: src/classy_blocks/optimize/__init__.py ================================================ ================================================ FILE: src/classy_blocks/optimize/cell.py ================================================ import abc from typing import ClassVar, Optional from classy_blocks.base.exceptions import NoCommonSidesError from classy_blocks.cbtyping import IndexType, NPPointListType, OrientType from classy_blocks.optimize.connection import CellConnection from classy_blocks.util.constants import EDGE_PAIRS class CellBase(abc.ABC): side_names: ClassVar[list[OrientType]] side_indexes: ClassVar[list[IndexType]] edge_pairs: ClassVar[list[tuple[int, int]]] def __init__(self, index: int, grid_points: NPPointListType, indexes: IndexType): self.index = index self.grid_points = grid_points self.indexes = indexes self.neighbours: dict[OrientType, Optional[CellBase]] = {name: None for name in self.side_names} self.connections = [CellConnection(set(pair), {indexes[pair[0]], indexes[pair[1]]}) for pair in self.edge_pairs] def get_common_indexes(self, candidate: "CellBase") -> set[int]: """Returns indexes of common vertices between this and provided cell""" this_indexes = set(self.indexes) cnd_indexes = set(candidate.indexes) return this_indexes.intersection(cnd_indexes) def get_corner(self, index: int) -> int: """Converts vertex index to local index (position of this vertex in the list)""" return self.indexes.index(index) def get_common_side(self, candidate: "CellBase") -> OrientType: """Returns orient of this cell that is shared with candidate""" common_vertices = self.get_common_indexes(candidate) if len(common_vertices) != len(self.side_indexes[0]): raise NoCommonSidesError corners = {self.get_corner(i) for i in common_vertices} for i, indexes in enumerate(self.side_indexes): if set(indexes) == corners: return self.side_names[i] raise NoCommonSidesError @property def boundary(self) -> set[int]: """Returns a list of indexes that define sides on boundary""" boundary = set() for i, side_name in enumerate(self.side_names): side_indexes = self.side_indexes[i] if self.neighbours[side_name] is None: boundary.update({self.indexes[si] for si in side_indexes}) return boundary def __str__(self): return "-".join([str(index) for index in self.indexes]) def __repr__(self): return str(self) class QuadCell(CellBase): # Like constants.FACE_MAP but for quadrangle sides as line segments side_names: ClassVar = ["front", "right", "back", "left"] side_indexes: ClassVar = [[0, 1], [1, 2], [2, 3], [3, 0]] edge_pairs: ClassVar = [(0, 1), (1, 2), (2, 3), (3, 0)] class HexCell(CellBase): """A block, treated as a single cell; its quality metrics can then be transcribed directly from checkMesh.""" # FACE_MAP, ordered and modified so that all faces point towards cell center; # provided their points are visited in an anti-clockwise manner # names and indexes must correspond (both must be in the same order) side_names: ClassVar = ["bottom", "top", "left", "right", "front", "back"] side_indexes: ClassVar = [[0, 1, 2, 3], [7, 6, 5, 4], [4, 0, 3, 7], [6, 2, 1, 5], [0, 4, 5, 1], [7, 3, 2, 6]] edge_pairs: ClassVar = EDGE_PAIRS ================================================ FILE: src/classy_blocks/optimize/clamps/__init__.py ================================================ ================================================ FILE: src/classy_blocks/optimize/clamps/clamp.py ================================================ import abc from typing import Callable, Optional import numpy as np import scipy.optimize from classy_blocks.cbtyping import NPPointType, PointType from classy_blocks.util import functions as f from classy_blocks.util.constants import TOL class ClampBase(abc.ABC): """Movement restriction for optimization by vertex movement""" def __init__( self, position: PointType, function: Callable[[list[float]], NPPointType], bounds: Optional[list[list[float]]] = None, initial_params: Optional[list[float]] = None, ): self.position = np.array(position) self.function = function self.bounds = bounds self.initial_params = initial_params self.params = self.get_params() @property @abc.abstractmethod def initial_guess(self) -> list[float]: """Returns initial guess for get_params_from_vertex""" def get_params(self) -> list[float]: """Returns parameters from initial vertex position""" def distance_from_vertex(params): return f.norm(self.position - self.function(params)) result = scipy.optimize.minimize(distance_from_vertex, self.initial_guess, bounds=self.bounds, tol=TOL) return result.x def update_params(self, params: list[float]): """Updates parameters to given.""" self.params = params self.position = self.function(self.params) ================================================ FILE: src/classy_blocks/optimize/clamps/curve.py ================================================ from typing import Optional import numpy as np from classy_blocks.cbtyping import PointType, VectorType from classy_blocks.construct.curves.curve import CurveBase from classy_blocks.optimize.clamps.clamp import ClampBase from classy_blocks.util import functions as f class CurveClamp(ClampBase): """Clamp that restricts point movement during optimization to a predefined curve. The curve parameter that corresponds to given vertex's position is obtained automatically by minimization. To provide a better starting point in case minimization fails or produces wrong results, an initial parameter can be supplied.""" def __init__( self, position: PointType, curve: CurveBase, initial_param: Optional[float] = None, ): position = np.array(position) if initial_param is not None: initial = [initial_param] else: initial = [curve.get_closest_param(position)] super().__init__(position, lambda t: curve.get_point(t[0]), [list(curve.bounds)], initial) @property def initial_guess(self): return self.initial_params class LineClamp(ClampBase): """Clamp that restricts point movement during optimization to a line, defined by 2 points; Parameter 't' goes from 0 at point_1 to at point_2 where is the distance between the two points (and beyond if different bounds are specified).""" def __init__( self, position: PointType, point_1: PointType, point_2: PointType, bounds: Optional[tuple[float, float]] = None ): position = np.array(position) point_1 = np.array(point_1) point_2 = np.array(point_2) def function(t): return point_1 + t[0] * f.unit_vector(point_2 - point_1) if bounds is None: bounds = (0, f.norm(point_2 - point_1)) super().__init__(position, function, [list(bounds)]) @property def initial_guess(self) -> list[float]: # Finding the closest point on a line is reliable enough # so that specific initial parameters are not needed return [0] class RadialClamp(ClampBase): """Clamp that restricts point movement during optimization to a circular trajectory, defined by center, normal and vertex position at clamp initialization. Parameter t goes from 0 at initial vertex position to 2**pi at the same position all the way around the circle (with radius )""" def __init__( self, position: PointType, center: PointType, normal: VectorType, bounds: Optional[list[float]] = None ): position = np.array(position) initial_point = np.copy(position) if bounds is not None: clamp_bounds = [bounds] else: clamp_bounds = None # Clamps that move points linearly have a clear connection # - . # With rotation, this strongly depends on the radius of the point. # To conquer that, divide params by radius radius = f.point_to_line_distance(center, normal, position) super().__init__( position, lambda params: f.rotate(initial_point, params[0] / radius, normal, center), clamp_bounds ) @property def initial_guess(self): return [0.0] ================================================ FILE: src/classy_blocks/optimize/clamps/free.py ================================================ import numpy as np from classy_blocks.cbtyping import PointType from classy_blocks.optimize.clamps.clamp import ClampBase class FreeClamp(ClampBase): def __init__(self, position: PointType): super().__init__(position, np.asarray) def get_params_from_vertex(self): """Returns parameters from initial vertex position""" # there's no need for all that math in super() return self.position @property def initial_guess(self): return self.position ================================================ FILE: src/classy_blocks/optimize/clamps/surface.py ================================================ import numpy as np from classy_blocks.cbtyping import NPPointType, PointType, VectorType from classy_blocks.optimize.clamps.clamp import ClampBase from classy_blocks.util import functions as f from classy_blocks.util.constants import DTYPE class PlaneClamp(ClampBase): """Clamp that restricts point movement during optimization to an infinite plane, defined by point and normal. Bounds are not supported.""" def __init__(self, position: PointType, point: PointType, normal: VectorType): point = np.array(point, dtype=DTYPE) normal = f.unit_vector(normal) # choose a vector that is not collinear with normal random_dir = f.unit_vector(normal + np.random.random(3)) u_dir = f.unit_vector(np.cross(random_dir, normal)) v_dir = f.unit_vector(np.cross(u_dir, normal)) def position_function(params) -> NPPointType: return point + params[0] * u_dir + params[1] * v_dir super().__init__(position, position_function) @property def initial_guess(self): return [0, 0] class ParametricSurfaceClamp(ClampBase): """Clamp that restricts point movement during optimization to a surface, defined by a function: p = f(u, v); Function f must take two parameters 'u' and 'v' and return a single point in 3D space.""" @property def initial_guess(self): if self.initial_params is None: return [0, 0] return self.initial_params # class InterpolatedSurfaceClamp(ClampBase): # TODO # class TriangulatedSurfaceClamp(ClampBase): # TODO ================================================ FILE: src/classy_blocks/optimize/connection.py ================================================ import dataclasses @dataclasses.dataclass class CellConnection: """A connection between two points; they are refered by indexes rather than positions""" corners: set[int] # cell-local indexes indexes: set[int] ================================================ FILE: src/classy_blocks/optimize/grid.py ================================================ from collections.abc import Sequence from typing import Union import numpy as np from classy_blocks.assemble.dump import AssembledDump from classy_blocks.base.exceptions import InvalidLinkError, NoJunctionError from classy_blocks.cbtyping import IndexType, NPPointListType, NPPointType from classy_blocks.construct.assemblies.assembly import Assembly from classy_blocks.construct.flat.sketch import Sketch from classy_blocks.construct.flat.sketches.mapped import MappedSketch from classy_blocks.construct.operations.operation import Operation from classy_blocks.construct.shape import Shape from classy_blocks.construct.stack import Stack from classy_blocks.lookup.cell_registry import CellRegistry from classy_blocks.lookup.connection_registry import ( ConnectionRegistryBase, HexConnectionRegistry, QuadConnectionRegistry, ) from classy_blocks.lookup.face_registry import FaceRegistryBase, HexFaceRegistry, QuadFaceRegistry from classy_blocks.lookup.point_registry import HexPointRegistry, QuadPointRegistry from classy_blocks.optimize.cell import CellBase, HexCell, QuadCell from classy_blocks.optimize.clamps.clamp import ClampBase from classy_blocks.optimize.junction import Junction from classy_blocks.optimize.links import LinkBase from classy_blocks.util import functions as f from classy_blocks.util.constants import TOL class GridBase: """A list of cells and junctions""" cell_class: type[CellBase] connection_registry_class: type[ConnectionRegistryBase] face_registry_class: type[FaceRegistryBase] def __init__(self, points: NPPointListType, addressing: list[IndexType]): # work on a fixed point array and only refer to it instead of building # new numpy arrays for every calculation self.points = points self.addressing = addressing self.junctions = [Junction(self.points, index) for index in range(len(self.points))] self.cells = [self.cell_class(i, self.points, indexes) for i, indexes in enumerate(addressing)] self._bind_junction_neighbours() self._bind_cell_neighbours() self._bind_junction_cells() def _bind_junction_neighbours(self) -> None: """Adds connections to junctions""" creg = self.connection_registry_class(self.points, self.addressing) for i, junction in enumerate(self.junctions): for c in creg.get_connected_indexes(i): junction.neighbours.add(self.junctions[c]) def _bind_cell_neighbours(self) -> None: """Adds neighbours to cells""" freg = self.face_registry_class(self.addressing) for i, cell in enumerate(self.cells): for orient in cell.side_names: for neighbour in freg.get_cells(i, orient): if neighbour == i: continue cell.neighbours[orient] = self.cells[neighbour] def _bind_junction_cells(self) -> None: """Adds cells to junctions""" creg = CellRegistry(self.addressing) for junction in self.junctions: for cell_index in creg.get_near_cells(junction.index): junction.cells.add(self.cells[cell_index]) def get_junction_from_clamp(self, clamp: ClampBase) -> Junction: for junction in self.junctions: if junction.clamp == clamp: return junction raise NoJunctionError def add_clamp(self, clamp: ClampBase) -> None: for junction in self.junctions: if f.norm(junction.point - clamp.position) < TOL: junction.add_clamp(clamp) return raise NoJunctionError(f"No junction found for clamp at {clamp.position}") def add_link(self, link: LinkBase) -> None: leader_index = -1 follower_index = -1 for i, junction in enumerate(self.junctions): if f.norm(link.leader - junction.point) < TOL: leader_index = i continue if f.norm(link.follower - junction.point) < TOL: follower_index = i if leader_index == -1: raise InvalidLinkError(f"Leader not found for link: {link} (follower: {follower_index})") if follower_index == -1: raise InvalidLinkError(f"Follower not found for link {link} (leader: {leader_index}") if leader_index == follower_index: raise InvalidLinkError(f"Leader and follower are the same for link {link} ({leader_index})") self.junctions[leader_index].add_link(link, follower_index) @property def clamps(self) -> list[ClampBase]: clamps: list[ClampBase] = [] for junction in self.junctions: if junction.clamp is not None: clamps.append(junction.clamp) return clamps @property def quality(self) -> float: """Returns summed qualities of all junctions""" # It is only called when optimizing linked clamps # or at the end of an iteration. return sum(junction.quality for junction in self.junctions) def update(self, index: int, position: NPPointType) -> float: self.points[index] = position junction = self.junctions[index] quality = junction.quality # quality is a sum of this junction and all linked ones for tie in junction.links: # update follower position link = tie.leader link.leader = position link.update() # update grid points self.points[tie.follower_index] = tie.leader.follower # add linked junctions' quality to the sum quality += self.junctions[tie.follower_index].quality for neighbour in junction.neighbours: quality += neighbour.quality return quality class QuadGrid(GridBase): cell_class = QuadCell connection_registry_class = QuadConnectionRegistry face_registry_class = QuadFaceRegistry @classmethod def from_sketch(cls, sketch: Sketch, merge_tol: float = TOL) -> "QuadGrid": if isinstance(sketch, MappedSketch): # Use the mapper's indexes (provided by the user!) return cls(sketch.positions, sketch.indexes) # automatically create a mapping for arbitrary sketches preg = QuadPointRegistry.from_sketch(sketch, merge_tol) return cls(preg.unique_points, preg.cell_addressing) class HexGrid(GridBase): cell_class = HexCell connection_registry_class = HexConnectionRegistry face_registry_class = HexFaceRegistry @classmethod def from_elements( cls, elements: Sequence[Union[Operation, Shape, Stack, Assembly]], merge_tol: float = TOL, ) -> "HexGrid": """Creates a grid from a list of elements""" ops: list[Operation] = [] for element in elements: if isinstance(element, Operation): ops.append(element) else: ops += element.operations preg = HexPointRegistry.from_operations(ops, merge_tol) return cls(preg.unique_points, preg.cell_addressing) @classmethod def from_dump(cls, dump: AssembledDump) -> "HexGrid": """Creates a grid from an assembled Mesh object""" points = np.array([vertex.position for vertex in dump.vertices]) addresses = [block.indexes for block in dump.blocks] return cls(points, addresses) ================================================ FILE: src/classy_blocks/optimize/junction.py ================================================ import dataclasses from typing import Optional import numpy as np from classy_blocks.base.exceptions import ClampExistsError from classy_blocks.cbtyping import NPPointListType, NPPointType from classy_blocks.optimize.cell import CellBase, HexCell from classy_blocks.optimize.clamps.clamp import ClampBase from classy_blocks.optimize.links import LinkBase from classy_blocks.optimize.quality import get_hex_quality, get_quad_quality @dataclasses.dataclass class LinkTie: leader: LinkBase follower_index: int class Junction: """A class that collects Cells that share the same Vertex""" def __init__(self, points: NPPointListType, index: int): self.points = points self.index = index self.cells: set[CellBase] = set() self.neighbours: set[Junction] = set() self.clamp: Optional[ClampBase] = None self.links: list[LinkTie] = [] @property def point(self) -> NPPointType: return self.points[self.index] def add_clamp(self, clamp: ClampBase) -> None: if self.clamp is not None: raise ClampExistsError(f"Clamp already defined for junction {self.index}") self.clamp = clamp def add_link(self, link: LinkBase, follower_index: int) -> None: self.links.append(LinkTie(link, follower_index)) @property def is_boundary(self) -> bool: """Returns True if this junction lies on boundary""" for cell in self.cells: if self.index in cell.boundary: return True return False @property def quality(self) -> float: if isinstance(next(iter(self.cells)), HexCell): quality_function = get_hex_quality else: quality_function = get_quad_quality return sum(quality_function(self.points, np.array(cell.indexes, dtype=np.int32)) for cell in self.cells) ================================================ FILE: src/classy_blocks/optimize/links.py ================================================ import abc import numpy as np from classy_blocks.cbtyping import NPPointType, NPVectorType, PointType, VectorType from classy_blocks.util import constants from classy_blocks.util import functions as f class LinkBase(abc.ABC): """When optimizing a single vertex position, other vertices can be linked to it so that they move together with optimized vertex.""" def __init__(self, leader: PointType, follower: PointType): self.leader = np.array(leader) self.follower = np.array(follower) def update(self) -> None: new_position = self.transform() self.follower = new_position @abc.abstractmethod def transform(self) -> NPPointType: """Determine the new vertex position according to the type of link""" def __str__(self): return f"Link {self.leader} - {self.follower}" class TranslationLink(LinkBase): """A link that maintains the same translation vector between parent clamp/vertex and the linked one.""" def __init__(self, leader: PointType, follower: PointType): super().__init__(leader, follower) self.vector = self.follower - self.leader def transform(self) -> NPPointType: return self.leader + self.vector class RotationLink(LinkBase): """A link that maintains the same angular displacement between parent clamp/vertex and the linked one, around a given axis. It will only work correctly when leader is rotated around given axis and origin.""" def __init__(self, leader: PointType, follower: PointType, axis: VectorType, origin: PointType): super().__init__(leader, follower) self.origin = np.array(origin) self.axis = f.unit_vector(axis) self.orig_leader_radius = self._get_radius(self.leader) self.orig_follower_pos = np.copy(self.follower) if f.norm(self.orig_leader_radius) < constants.TOL: raise ValueError("Leader and rotation axis are coincident!") def transform(self) -> NPPointType: prev_radius = self.orig_leader_radius this_radius = self._get_radius(self.leader) angle = f.angle_between(prev_radius, this_radius) cross_rad = np.cross(prev_radius, this_radius) if np.dot(cross_rad, self.axis) < 0: angle = -angle return f.rotate(self.orig_follower_pos, angle, self.axis, self.origin) def _get_height(self, point: NPPointType) -> NPVectorType: """Returns projection of the point to the axis""" return np.dot(point - self.origin, self.axis) * self.axis def _get_radius(self, point: NPPointType) -> NPVectorType: """Returns projection of the point to plane, given by origin and axis""" return (point - self.origin) - self._get_height(point) class SymmetryLink(LinkBase): """A link that mirrors follower over a given plane.""" def __init__(self, leader: PointType, follower: PointType, normal: VectorType, origin: PointType): self.normal = np.array(normal) self.origin = np.array(origin) super().__init__(leader, follower) def _get_follower(self) -> NPPointType: return f.mirror(self.leader, self.normal, self.origin) def transform(self) -> NPPointType: return self._get_follower() ================================================ FILE: src/classy_blocks/optimize/optimizer.py ================================================ import abc import copy import dataclasses import time from dataclasses import field from typing import Optional import numpy as np import scipy.optimize from classy_blocks.base.exceptions import OptimizationError from classy_blocks.construct.flat.sketch import Sketch from classy_blocks.construct.flat.sketches.mapped import MappedSketch from classy_blocks.construct.operations.operation import Operation from classy_blocks.mesh import Mesh from classy_blocks.optimize.clamps.clamp import ClampBase from classy_blocks.optimize.clamps.surface import PlaneClamp from classy_blocks.optimize.grid import GridBase, HexGrid, QuadGrid from classy_blocks.optimize.links import LinkBase from classy_blocks.optimize.record import ( ClampRecord, IterationRecord, MinimizationMethodType, OptimizationRecord, ) from classy_blocks.optimize.report import OptimizationReporterBase, SilentReporter, TextReporter from classy_blocks.util.constants import TOL, VSMALL @dataclasses.dataclass class OptimizerConfig: # maximum number of iterations; the optimizer will quit at the last iteration # regardless of achieved quality max_iterations: int = 20 # absolute tolerance; if overall grid quality stays within given interval # between iterations the optimizer will quit abs_tol: float = 0 # disabled by default # relative tolerance; if relative change between iterations is less than # given, the optimizer will quit rel_tol: float = 0.01 # method that will be used for scipy.optimize.minimize # (only those that support all required features are valid) method: MinimizationMethodType = "SLSQP" # relaxation: every subsequent iteration will increase under-relaxation # until it's larger than relaxation_threshold; then it will fix it to 1. # Relaxation is identical to OpenFOAM's field relaxation relaxation_start: float = 1 # disabled by default relaxation_iterations: int = 5 # number of relaxed iterations relaxation_threshold: float = 0.9 # value where relaxation factor snaps to 1 # convergence tolerance for a single joint # as passed to scipy.optimize.minimize clamp_tol: float = 1e-3 # additional options passed to Scipy's minimize method, # depending on chosen algorithm; see documentation of scipy.optimize.minimize # and specifically the chosen algorithm options: dict = field(default_factory=dict) class OptimizerBase(abc.ABC): """Provides tools for 2D (sketch) or 3D (mesh blocking) optimization""" reporter: OptimizationReporterBase def __init__(self, grid: GridBase, report: bool = True): self.grid = grid if not report: self.reporter = SilentReporter() else: self.reporter = TextReporter() # holds defaults and can be adjusted before calling .optimize() self.config = OptimizerConfig() def add_clamp(self, clamp: ClampBase) -> None: """Adds a clamp to optimization. Raises an exception if it already exists""" self.grid.add_clamp(clamp) def add_link(self, link: LinkBase) -> None: self.grid.add_link(link) def _get_sensitivity(self, clamp: ClampBase): """Returns maximum partial derivative at current params""" junction = self.grid.get_junction_from_clamp(clamp) initial_position = copy.copy(junction.point) def fquality(clamp, junction, params): try: clamp.update_params(params) quality = self.grid.update(junction.index, clamp.position) self.grid.update(junction.index, initial_position) return quality except (RuntimeError, ValueError): return 0 sensitivities = scipy.optimize.approx_fprime(clamp.params, lambda p: fquality(clamp, junction, p), epsilon=TOL) return np.linalg.norm(sensitivities) def _optimize_clamp(self, clamp: ClampBase, relaxation_factor: float) -> ClampRecord: """Move clamp.vertex so that quality at junction is improved; rollback changes if grid quality decreased after optimization""" junction = self.grid.get_junction_from_clamp(clamp) crecord = ClampRecord(junction.index, self.grid.quality) self.reporter.clamp_start(crecord) initial_position = copy.copy(junction.point) initial_params = copy.copy(clamp.params) def fquality(params): if clamp.bounds is not None: for i, b in enumerate(clamp.bounds): params[i] = np.clip(params[i], b[0], b[1]) clamp.update_params(params) return self.grid.update(junction.index, clamp.position) try: result = scipy.optimize.minimize( fquality, clamp.params, bounds=clamp.bounds, method=self.config.method, tol=self.config.clamp_tol, options=self.config.options, ) if not result.success: raise OptimizationError(result.message) # relax and update for i, param in enumerate(result.x): clamp.params[i] = initial_params[i] + relaxation_factor * (param - initial_params[i]) fquality(clamp.params) # always check grid quality, not clamp's crecord.grid_final = self.grid.quality if not crecord.improvement > 0: raise OptimizationError("No improvement") except OptimizationError as e: # roll back to the initial state self.grid.update(junction.index, initial_position) crecord.rolled_back = True crecord.error_message = str(e) crecord.grid_final = self.grid.quality self.reporter.clamp_end(crecord) return crecord def _optimize_iteration(self, iteration_no: int) -> IterationRecord: rlf = self.relaxation_factor(iteration_no) irecord = IterationRecord(iteration_no, self.grid.quality, rlf) self.reporter.iteration_start(iteration_no, rlf) clamps = sorted(self.grid.clamps, key=lambda c: self._get_sensitivity(c), reverse=True) for clamp in clamps: self._optimize_clamp(clamp, rlf) irecord.grid_final = self.grid.quality self.reporter.iteration_end(irecord) return irecord def relaxation_factor(self, iteration_no: int) -> float: iter_no = iteration_no threshold = self.config.relaxation_threshold start_relax = self.config.relaxation_start target_iter = self.config.relaxation_iterations if iter_no >= target_iter: return 1.0 if start_relax >= threshold: return 1.0 k = -np.log(1 - (threshold - start_relax) / (threshold - start_relax + VSMALL)) # normalize iteration to [0, 1] t = iter_no / target_iter # increase the factor slowly at the beginning and quicker at the end value = start_relax + (threshold - start_relax) * (1 - np.exp(-k * (t**3))) return value def optimize( self, max_iterations: Optional[int] = None, tolerance: Optional[float] = None, method: Optional[MinimizationMethodType] = None, ) -> bool: """Move vertices as defined and restrained with Clamps so that better mesh quality is obtained. Within each iteration, all vertices will be moved, starting with the one with the most influence on quality. Lower tolerance values. max_iterations, tolerance (relative) and method enable rough adjustment of optimization; for fine tuning, modify optimizer.config attribute. Returns True is optimization was successful (tolerance reached)""" if max_iterations is not None: self.config.max_iterations = max_iterations if tolerance is not None: self.config.rel_tol = tolerance if method is not None: self.config.method = method orecord = OptimizationRecord(time.time(), self.grid.quality) # TODO: cache repeating quality queries for i in range(self.config.max_iterations): iter_record = self._optimize_iteration(i) if iter_record.abs_improvement < 0: # can happen during the relaxed iterations continue if iter_record.abs_improvement < self.config.abs_tol: orecord.termination = "abs" break if iter_record.rel_improvement < self.config.rel_tol: orecord.termination = "rel" break else: orecord.termination = "limit" orecord.grid_final = self.grid.quality orecord.time_end = time.time() self.reporter.optimization_end(orecord) self._backport() return orecord.termination in ("abs", "rel") @abc.abstractmethod def _backport(self) -> None: """Reflect optimization results back to the original mesh/sketch""" class MeshOptimizer(OptimizerBase): def __init__(self, mesh: Mesh, report: bool = True, merge_tol: float = TOL): self.mesh = mesh grid = HexGrid.from_dump(self.mesh.assemble(merge_tol=merge_tol)) super().__init__(grid, report) def _backport(self): # copy the stuff back to mesh for i, point in enumerate(self.grid.points): self.mesh.vertices[i].move_to(point) class ShapeOptimizer(OptimizerBase): def __init__(self, operations: list[Operation], report: bool = True, merge_tol: float = TOL): grid = HexGrid.from_elements(operations, merge_tol) super().__init__(grid, report) self.operations = operations def _backport(self) -> None: # Move every point of every operation to wherever it is now for iop, indexes in enumerate(self.grid.addressing): operation = self.operations[iop] for ipnt, i in enumerate(indexes): operation.points[ipnt].move_to(self.grid.points[i]) class SketchOptimizer(OptimizerBase): def __init__(self, sketch: Sketch, report: bool = True, merge_tol: float = TOL): self.sketch = sketch grid = QuadGrid.from_sketch(sketch, merge_tol=merge_tol) super().__init__(grid, report) def _backport(self): if isinstance(self.sketch, MappedSketch): self.sketch.update(self.grid.points) return # take faces and points from QuadGrid for face_index, face in enumerate(self.sketch.faces): point_indexes = self.grid.addressing[face_index] for corner_index, point_index in enumerate(point_indexes): point = self.grid.points[point_index] face.points[corner_index].position = point def auto_optimize( self, max_iterations: Optional[int] = None, tolerance: Optional[float] = None, method: Optional[MinimizationMethodType] = None, ) -> bool: """Adds a PlaneClamp to all non-boundary points and optimize the sketch. To include boundary points (those that can be moved along a line or a curve), add clamps manually before calling this method.""" normal = self.sketch.normal for junction in self.grid.junctions: if not junction.is_boundary: clamp = PlaneClamp(junction.point, junction.point, normal) self.add_clamp(clamp) return super().optimize(max_iterations, tolerance, method) ================================================ FILE: src/classy_blocks/optimize/quality.py ================================================ import numba # type:ignore import numpy as np from nptyping import Int32, NDArray, Shape from classy_blocks.cbtyping import NPPointListType, NPPointType, NPVectorType from classy_blocks.util.constants import VSMALL NPIndexType = NDArray[Shape["*, 1"], Int32] @numba.jit(nopython=True) def scale_quality(base: float, exponent: float, factor: float, value: float) -> float: return factor * base ** (exponent * value) - factor @numba.jit(nopython=True) def scale_aspect(ratio: float) -> float: return scale_quality(4, 3, 2, np.log10(ratio)) @numba.jit(nopython=True) def take(points: NPPointListType, indexes: NPIndexType): n_points = len(indexes) dim = points.shape[1] result = np.empty((n_points, dim), dtype=points.dtype) for i in range(n_points): for j in range(dim): result[i, j] = points[indexes[i], j] return result @numba.jit(nopython=True, cache=True) def get_center_point(points: NPPointListType) -> NPPointType: return np.sum(points, axis=0) / len(points) @numba.jit(nopython=True, cache=True) def get_quad_normal(points: NPPointListType) -> tuple[NPVectorType, NPVectorType, float]: normal = np.zeros(3) center = get_center_point(points) min_length: float = 1e30 max_length: float = 0 for i in range(4): side_1 = points[i] - center side_2 = points[(i + 1) % 4] - center length = float(np.linalg.norm(side_2 - side_1)) max_length = max(max_length, length) min_length = min(min_length, length) tri_normal = np.cross(side_1, side_2) tri_normal /= np.linalg.norm(tri_normal) normal += tri_normal return center, normal / np.linalg.norm(normal), max_length / (min_length + VSMALL) @numba.jit(nopython=True, cache=True) def scale_angle(angle: float) -> float: n = 6 m = 100 threshold = 75 a = m / (n * threshold ** (n - 1)) if angle <= threshold: return a * angle**n return a * threshold**n + m * (angle - threshold) @numba.jit(nopython=True, cache=True) def get_quad_non_ortho(points: NPPointListType, center: NPPointType, normal: NPVectorType, corner: int) -> float: this_point = points[corner] next_point = points[(corner + 1) % 4] # non-ortho angle: angle between side_normal and center-side_center side_center = (this_point + next_point) / 2 side_vector = next_point - this_point side_normal = np.cross(normal, side_vector) side_normal /= np.linalg.norm(side_normal) + VSMALL center_vector = center - side_center center_vector /= np.linalg.norm(center_vector) + VSMALL # non-orthogonality angle covers values from 0 to +/-180 degrees # and values above +/-70-ish are unacceptable regardless of the orientation; # therefore it makes no sense checking for inverted/degenerate quads return 180 * np.arccos(np.dot(side_normal, center_vector)) / np.pi @numba.jit(nopython=True, cache=True) def get_quad_inner_angle(points: NPPointListType, normal: NPVectorType, corner: int) -> float: next_side = points[(corner + 1) % 4] - points[corner] next_side /= np.linalg.norm(next_side) + VSMALL prev_side = points[(corner - 1) % 4] - points[corner] prev_side /= np.linalg.norm(prev_side) + VSMALL inner_angle = 180 * np.arccos(np.dot(next_side, prev_side)) / np.pi # ranges from 0 to 360 degrees but arccos only covert 0...180; # use normal to check for the rest if np.dot(np.cross(next_side, prev_side), normal) < 0: inner_angle += 180 return inner_angle @numba.jit(nopython=True, cache=True) def get_quad_quality(grid_points: NPPointListType, cell_indexes: NPIndexType) -> float: quality = 0 quad_points = take(grid_points, cell_indexes) center, normal, aspect = get_quad_normal(quad_points) for i in range(4): non_ortho_angle = get_quad_non_ortho(quad_points, center, normal, i) quality += scale_angle(non_ortho_angle) inner_angle = get_quad_inner_angle(quad_points, normal, i) - 90 quality += scale_angle(inner_angle) quality += scale_aspect(aspect) return quality @numba.jit(nopython=True, cache=True) def get_hex_quality(grid_points: NPPointListType, cell_indexes: NPIndexType) -> float: cell_points = take(grid_points, cell_indexes) cell_center = get_center_point(cell_points) side_indexes = np.array([[0, 1, 2, 3], [7, 6, 5, 4], [4, 0, 3, 7], [6, 2, 1, 5], [0, 4, 5, 1], [7, 3, 2, 6]]) quality = 0 for side in side_indexes: # Non-ortho angle in a hexahedron is measured between two vectors: # and # but since cells on the boundary don't have a neighbour # simply is taken. # For this kind of optimization it is quite sufficient to # take for all cells. side_points = take(cell_points, side) side_center, side_normal, side_aspect = get_quad_normal(side_points) center_vector = cell_center - side_center center_vector /= np.linalg.norm(center_vector) + VSMALL non_ortho_angle = 180 * np.arccos(min(1 - VSMALL, np.dot(side_normal, center_vector))) / np.pi quality += scale_angle(non_ortho_angle) # take inner angles and aspect from quad calculation; for i in range(4): inner_angle = get_quad_inner_angle(side_points, side_normal, i) - 90 quality += scale_angle(inner_angle) quality += scale_aspect(side_aspect) return quality ================================================ FILE: src/classy_blocks/optimize/record.py ================================================ import dataclasses from typing import Literal MinimizationMethodType = Literal["SLSQP", "L-BFGS-B", "Nelder-Mead", "Powell", "trust-constr"] TerminationReason = Literal["running", "abs", "rel", "limit"] @dataclasses.dataclass class OptimizationRecord: time_start: float grid_initial: float termination: TerminationReason = "running" grid_final: float = 0 time_end: float = 0 @property def abs_improvement(self) -> float: return self.grid_initial - self.grid_final @property def rel_improvement(self) -> float: return self.abs_improvement / self.grid_initial @dataclasses.dataclass class IterationRecord: iteration: int grid_initial: float relaxation: float grid_final: float = 0 @property def abs_improvement(self) -> float: return self.grid_initial - self.grid_final @property def rel_improvement(self) -> float: return (self.grid_initial - self.grid_final) / self.grid_initial @dataclasses.dataclass class ClampRecord: vertex_index: int # clamp quality is taken from fquality in optimize_clamp(); grid_initial: float grid_final: float = 0 rolled_back: bool = False error_message: str = "" @property def improvement(self) -> float: return self.grid_initial - self.grid_final ================================================ FILE: src/classy_blocks/optimize/report.py ================================================ import abc from classy_blocks.optimize.record import ClampRecord, IterationRecord, OptimizationRecord from classy_blocks.util.tools import report class OptimizationReporterBase(abc.ABC): @abc.abstractmethod def iteration_start(self, iteration_no: int, relaxation: float) -> None: pass @abc.abstractmethod def clamp_start(self, crecord: ClampRecord) -> None: pass @abc.abstractmethod def clamp_end(self, crecord: ClampRecord) -> None: pass @abc.abstractmethod def iteration_end(self, srecord: IterationRecord) -> None: pass @abc.abstractmethod def optimization_end(self, orecord: OptimizationRecord) -> None: pass class SilentReporter(OptimizationReporterBase): def iteration_start(self, iteration_no: int, relaxation: float) -> None: pass def clamp_start(self, crecord: ClampRecord) -> None: pass def clamp_end(self, crecord: ClampRecord) -> None: pass def iteration_end(self, srecord: IterationRecord) -> None: pass def optimization_end(self, orecord: OptimizationRecord) -> None: pass class TextReporter(OptimizationReporterBase): def iteration_start(self, iteration_no: int, relaxation: float) -> None: report(f"Optimization iteration {iteration_no}") report(f"Relaxation: {relaxation:.4f}") report("{:6s}".format("Vertex"), end="") report("{:>12s}".format("Initial"), end="") report("{:>12s}".format("Improvement"), end="") report("{:>12s}".format("Final"), end="") report("{:>12s}".format("Status")) def clamp_start(self, crecord: ClampRecord) -> None: report(f"{crecord.vertex_index:>6}", end="") report(f"{crecord.grid_initial:12.3e}", end="") def clamp_end(self, crecord: ClampRecord) -> None: report(f"{crecord.improvement:12.0f}", end="") report(f"{crecord.grid_final:12.3e}", end="") if crecord.rolled_back: report(f" Rollback ({crecord.error_message})", end="") report("") # a.k.a. new line def iteration_end(self, srecord: IterationRecord) -> None: report(f"Iteration {srecord.iteration} finished.", end=" ") report(f"Improvement: {srecord.abs_improvement:.0f}", end="") report(f" ({srecord.grid_initial:.3e} > {srecord.grid_final:.3e})") def optimization_end(self, orecord: OptimizationRecord) -> None: message = { "abs": "Absolute tolerance reached", "rel": "Relative tolerance reached", "limit": "Iteration limit hit", }[orecord.termination] report(f"{message}, stopping optimization.") report( f"Improvement: {orecord.grid_initial:.3e} > {orecord.grid_final:.3e} ({100 * orecord.rel_improvement:.0f}%)" ) report(f"Duration: {int(orecord.time_end - orecord.time_start)} s") ================================================ FILE: src/classy_blocks/optimize/smoother.py ================================================ import abc from collections.abc import Iterable import numpy as np from classy_blocks.cbtyping import PointListType from classy_blocks.construct.flat.sketches.mapped import MappedSketch from classy_blocks.mesh import Mesh from classy_blocks.optimize.grid import GridBase, HexGrid, QuadGrid from classy_blocks.optimize.junction import Junction from classy_blocks.util import functions as f from classy_blocks.util.constants import TOL class SmootherBase(abc.ABC): def __init__(self, grid: GridBase): self.grid = grid self.inner: list[Junction] = [] for junction in self.grid.junctions: if not junction.is_boundary: self.inner.append(junction) self.fixed: set[int] = set() def fix_indexes(self, indexes: Iterable[int]) -> None: self.fixed.update(set(indexes)) def fix_points(self, points: PointListType): for point in points: for junction in self.grid.junctions: if f.norm(point - junction.point) < TOL: self.fixed.add(junction.index) def smooth(self, iterations: int = 5) -> None: for _ in range(iterations): for junction in self.inner: if junction.index in self.fixed: continue near_points = [j.point for j in junction.neighbours] self.grid.points[junction.index] = np.average(near_points, axis=0) self.backport() @abc.abstractmethod def backport(self) -> None: """Copy results of smoothing back to the grid""" class MeshSmoother(SmootherBase): def __init__(self, mesh: Mesh, merge_tol: float = TOL): self.mesh = mesh super().__init__(HexGrid.from_dump(self.mesh.assemble(merge_tol=merge_tol))) def backport(self): for i, point in enumerate(self.grid.points): self.mesh.vertices[i].move_to(point) class SketchSmoother(SmootherBase): def __init__(self, sketch: MappedSketch): self.sketch = sketch grid = QuadGrid.from_sketch(self.sketch) super().__init__(grid) def backport(self): positions = self.grid.points for i, quad in enumerate(self.sketch.indexes): points = np.take(positions, quad, axis=0) self.sketch.faces[i].update(points) ================================================ FILE: src/classy_blocks/util/__init__.py ================================================ ================================================ FILE: src/classy_blocks/util/constants.py ================================================ import numpy as np from classy_blocks.cbtyping import DirectionType, OrientType # data type DTYPE = np.float64 # geometric tolerance for searching/merging points TOL = 1e-7 # a small-ish value, named after OpenFOAM's constant VSMALL = 1e-6 # a big-ish value VBIG = 1e12 # Block definition: # a more intuitive and quicker way to set patches, # according to this sketch: https://www.openfoam.com/documentation/user-guide/blockMesh.php # the same for all blocks FACE_MAP: dict[OrientType, tuple[int, int, int, int]] = { "bottom": (0, 1, 2, 3), "top": (4, 5, 6, 7), "left": (4, 0, 3, 7), "right": (5, 1, 2, 6), "front": (4, 5, 1, 0), "back": (7, 6, 2, 3), } SIDES_MAP: list[OrientType] = [ "front", "right", "back", "left", ] # Connects block axis (direction) and orients # (read: Direction 0 goes from right to left, etc. DIRECTION_MAP: dict[DirectionType, tuple[OrientType, OrientType]] = { 0: ("left", "right"), 1: ("front", "back"), 2: ("bottom", "top"), } # pairs of corner indexes along axes AXIS_PAIRS = ( ((0, 1), (3, 2), (7, 6), (4, 5)), # x ((0, 3), (1, 2), (5, 6), (4, 7)), # y ((0, 4), (1, 5), (2, 6), (3, 7)), # z ) # pairs of corner indexes that define edges (and not diagonals) EDGE_PAIRS = list(AXIS_PAIRS[0]) + list(AXIS_PAIRS[1]) + list(AXIS_PAIRS[2]) # number formatting def vector_format(vector) -> str: """Output for point/vertex definitions""" # ACHTUNG, keep about the same order of magnitude than TOL return f"({vector[0]:.8f} {vector[1]:.8f} {vector[2]:.8f})" MESH_HEADER = ( "/*---------------------------------------------------------------------------*\\\n" "| ========= | |\n" "| \\ / F ield | OpenFOAM: The Open Source CFD Toolbox |\n" "| \\ / O peration | Script: {script:<40s}|\n" "| \\ / A nd | Time: {timestamp:<42s}|\n" "| \\/ M anipulation | |\n" "\\*---------------------------------------------------------------------------*/\n" "FoamFile\n" "{{\n" " version 2.0;\n" " format ascii;\n" " class dictionary;\n" " object blockMeshDict;\n" "}}\n" "// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * //\n\n" ) MESH_FOOTER = ( "// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * //\n" "// Created with classy_blocks: https://github.com/damogranlabs/classy_blocks //\n" "// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * //\n" ) ================================================ FILE: src/classy_blocks/util/frame.py ================================================ from typing import ClassVar, Generic, Optional, TypeVar from classy_blocks.cbtyping import DirectionType from classy_blocks.util import constants BeamT = TypeVar("BeamT") class Frame(Generic[BeamT]): """ A two-dimensional dictionary for holding data between each end of hexahedra edges (an edge is called 'beam' generically to distinguish it from actual Edges) An edge/wire/whatever object between vertices 0 and 1 can be accessed as frame[0][1]. Diagonals are not available. Arguments only provide classes for type hinting: - beam_class: a class that is associated with a pair of corners Numbering and axes reflect that of block definition. After the Frame is created, entities must be added separately with appropriate methods.""" valid_pairs: ClassVar[list[set[int]]] = [set(pair) for pair in constants.EDGE_PAIRS] def __init__(self) -> None: self.beams: list[dict[int, Optional[BeamT]]] = [{} for _ in range(8)] # create wires and connections for quicker addressing for axis in (0, 1, 2): for pair in constants.AXIS_PAIRS[axis]: self.add_beam(pair[0], pair[1], None) def add_beam(self, corner_1: int, corner_2: int, beam: Optional[BeamT]) -> None: """Adds an element between given corners; raises an exception if the given pair does not represent a beam""" if {corner_1, corner_2} not in self.valid_pairs: raise ValueError( f"Invalid combination of corners. Valid pairs: {self.valid_pairs}, got: {corner_1, corner_2}" ) self.beams[corner_1][corner_2] = beam self.beams[corner_2][corner_1] = beam def get_axis_beams(self, axis: DirectionType) -> list[BeamT]: """Returns all non-None beams from given axis""" beams = [] for pair in constants.AXIS_PAIRS[axis]: beam = self.beams[pair[0]][pair[1]] if beam is not None: beams.append(beam) return beams def get_all_beams(self) -> list[tuple[int, int, BeamT]]: """Returns all non-None entries in self.beams""" beams = [] listed = [] for corner_1, pairs in enumerate(self.beams): for corner_2, beam in pairs.items(): pair = {corner_1, corner_2} if pair in listed: continue if beam is not None: beams.append((corner_1, corner_2, beam)) listed.append(pair) return beams def __getitem__(self, index): return self.beams[index] ================================================ FILE: src/classy_blocks/util/functions.py ================================================ """Matheratical functions for general everyday household use""" from itertools import chain from typing import Literal, Optional, Union import numpy as np import scipy import scipy.linalg from numba import jit # type: ignore from classy_blocks.cbtyping import NPPointListType, NPPointType, NPVectorType, PointListType, PointType, VectorType from classy_blocks.util import constants def vector(x: float, y: float, z: float) -> NPVectorType: """A shortcut for creating 3D-space vectors; in case you need a lot of manual np.array([...])""" return np.array([x, y, z], dtype=constants.DTYPE) def deg2rad(deg: float) -> float: """Convert degrees (input) to radians""" return deg * np.pi / 180.0 def rad2deg(rad: float) -> float: """convert radians (input) to degrees""" return rad * 180.0 / np.pi def norm(matrix: Union[PointType, PointListType]) -> float: """a shortcut to scipy.linalg.norm()""" # for arrays of vectors: # matrix = np.asarray(matrix, dtype=constants.DTYPE) # return scipy.linalg.norm(matrix, axis=len(np.shape(matrix))-1) return float(scipy.linalg.norm(matrix)) def unit_vector(vect: VectorType) -> NPVectorType: """Returns a vector of magnitude 1 with the same direction""" vect = np.asarray(vect, dtype=constants.DTYPE) return vect / norm(vect) def angle_between(vect_1: VectorType, vect_2: VectorType) -> float: """Returns the angle between vectors in radians: >>> angle_between((1, 0, 0), (0, 1, 0)) 1.5707963267948966 >>> angle_between((1, 0, 0), (1, 0, 0)) 0.0 >>> angle_between((1, 0, 0), (-1, 0, 0)) 3.141592653589793 Kudos: https://stackoverflow.com/questions/2827393/ """ v1_u = unit_vector(vect_1) v2_u = unit_vector(vect_2) return np.arccos(np.clip(np.dot(v1_u, v2_u), -1.0, 1.0)) @jit(nopython=True, cache=True) def _rotation_matrix(axis: NPVectorType, angle: float): axis = axis / np.sqrt(np.dot(axis, axis)) a = np.cos(angle / 2.0) b, c, d = -axis * np.sin(angle / 2.0) aa, bb, cc, dd = a * a, b * b, c * c, d * d bc, ad, ac, ab, bd, cd = b * c, a * d, a * c, a * b, b * d, c * d return np.array( [ [aa + bb - cc - dd, 2 * (bc + ad), 2 * (bd - ac)], [2 * (bc - ad), aa + cc - bb - dd, 2 * (cd + ab)], [2 * (bd + ac), 2 * (cd - ab), aa + dd - bb - cc], ] ) def rotation_matrix(axis: VectorType, angle: float): """Returns the rotation matrix associated with counterclockwise rotation about the given axis by theta radians.""" # Kudos to https://stackoverflow.com/questions/6802577/rotation-of-3d-vector axis = np.asarray(axis, dtype=constants.DTYPE) return _rotation_matrix(axis, angle) @jit(nopython=True, cache=True) def _rotate(point: NPPointType, angle: float, axis: NPVectorType, origin: NPPointType) -> NPPointType: # Rotation matrix associated with counterclockwise rotation about # the given axis by theta radians. Kudos to # https://stackoverflow.com/questions/6802577/rotation-of-3d-vector rotated_point = np.dot(_rotation_matrix(axis, angle), point - origin) return rotated_point + origin def rotate(point: PointType, angle: float, axis: VectorType, origin: PointType) -> NPPointType: """Rotate a point around an axis@origin by a given angle [radians]""" point = np.asarray(point, dtype=constants.DTYPE) axis = np.asarray(axis, dtype=constants.DTYPE) origin = np.asarray(origin, dtype=constants.DTYPE) return _rotate(point, angle, axis, origin) def scale(point: PointType, ratio: float, origin: Optional[PointType]) -> NPPointType: """Scales a point around origin by specified ratio; if not specified, origin is taken as [0, 0, 0].""" point = np.asarray(point, dtype=constants.DTYPE) origin = np.asarray(origin, dtype=constants.DTYPE) return origin + (point - origin) * ratio def to_polar(point: PointType, axis: Literal["x", "z"] = "z") -> NPVectorType: """Convert (x, y, z) point to (radius, angle, height); the axis of the new polar coordinate system can be chosen ('x' or 'z')""" if axis not in ["x", "z"]: raise ValueError(f"`axis` must be 'x' or 'z', got {axis}") if axis == "z": radius = (point[0] ** 2 + point[1] ** 2) ** 0.5 angle = np.arctan2(point[1], point[0]) height = point[2] else: # axis == 'x' radius = (point[1] ** 2 + point[2] ** 2) ** 0.5 angle = np.arctan2(point[2], point[1]) height = point[0] return vector(radius, angle, height) def to_cartesian(point: PointType, direction: Literal[1, -1] = 1, axis: Literal["x", "z"] = "z") -> NPPointType: """Converts a point given in (r, theta, z) coordinates to cartesian coordinate system. optionally, axis can be aligned with either cartesian axis x* or z and rotation sense can be inverted with direction=-1 *when axis is 'x': theta goes from 0 at y-axis toward z-axis """ if direction not in [-1, 1]: raise ValueError(f"`direction` must be '-1' or '1', got {direction}") if axis not in ["x", "z"]: raise ValueError(f"`axis` must be 'x' or 'z', got {axis}") radius = point[0] angle = direction * point[1] height = point[2] if axis == "z": return vector(radius * np.cos(angle), radius * np.sin(angle), height) # axis == 'x' return vector(height, radius * np.cos(angle), radius * np.sin(angle)) def lin_map(x: float, x_min: float, x_max: float, out_min: float, out_max: float, limit: bool = False) -> float: """map x that should take values from x_min to x_max to values out_min to out_max""" r = float(x - x_min) * float(out_max - out_min) / float(x_max - x_min) + float(out_min) if limit: return sorted([out_min, r, out_max])[1] else: return r @jit(nopython=True, cache=True) def arc_length_3point(p_start: NPPointType, p_btw: NPPointType, p_end: NPPointType) -> float: """Returns length of arc defined by 3 points""" ### Meticulously transcribed from # https://develop.openfoam.com/Development/openfoam/-/blob/master/src/mesh/blockMesh/blockEdges/arcEdge/arcEdge.C vect_a = p_btw - p_start vect_b = p_end - p_start # Find centre of arcEdge asqr = vect_a.dot(vect_a) bsqr = vect_b.dot(vect_b) adotb = vect_a.dot(vect_b) denom = asqr * bsqr - adotb * adotb # https://develop.openfoam.com/Development/openfoam/-/blob/master/src/OpenFOAM/primitives/Scalar/floatScalar/floatScalar.H if denom < 1e-18: raise ValueError("Invalid arc points!") fact = 0.5 * (bsqr - adotb) / denom centre = p_start + 0.5 * vect_a + fact * (np.cross(np.cross(vect_a, vect_b), vect_a)) # Position vectors from centre rad_start = p_start - centre rad_btw = p_btw - centre rad_end = p_end - centre mag1 = np.linalg.norm(rad_start) mag3 = np.linalg.norm(rad_end) # The radius from r1 and from r3 will be identical radius = rad_end # Determine the angle angle = np.arccos((rad_start.dot(rad_end)) / (mag1 * mag3)) # Check if the vectors define an exterior or an interior arcEdge if np.dot(np.cross(rad_start, rad_btw), np.cross(rad_start, rad_end)) < 0: angle = 2 * np.pi - angle return angle * np.linalg.norm(radius) @jit(nopython=True, cache=True) def divide_arc(center: NPPointType, point_1: NPPointType, point_2: NPPointType, count: int) -> NPPointListType: radius = np.linalg.norm(center - point_1) step = (point_2 - point_1) / (count + 1) result = np.empty((count, 3)) for i in range(count): secant_point = point_1 + step * (i + 1) secant_vector = secant_point - center secant_length = np.linalg.norm(secant_vector) result[i] = center + radius * secant_vector / secant_length return result @jit(nopython=True, cache=True) def arc_mid(center: NPPointType, point_1: NPPointType, point_2: NPPointType) -> PointType: """Returns the midpoint of the specified arc in 3D space""" return divide_arc(center, point_1, point_2, 1)[0] def mirror(points: Union[PointType, PointListType], normal: VectorType, origin: PointType): """ Mirrors one or more 3D points over a plane defined by origin and normal. Parameters ---------- points : point (3,) or array of points (N, 3) Points to mirror (N can be 1 for a single point). origin : (3,) array_like A point on the plane. normal : (3,) array_like Plane normal (need not be normalized). Returns ------- mirrored : (3,) ndarray (N, 3) ndarray or A single mirrored point if a single point was given, otherwise an array of mirrored points. """ points = np.asarray(points).astype(constants.DTYPE) shape = np.shape(points) points = np.atleast_2d(points) origin = np.asarray(origin, dtype=constants.DTYPE) normal = unit_vector(normal) v = points - origin # (N, 3) dist = np.dot(v, normal) # (N,) mirrored = points - 2 * dist[:, np.newaxis] * normal if shape == (3,): # return the same shape as passed return mirrored[0] return mirrored def point_to_plane_distance(origin: PointType, normal: VectorType, point: PointType) -> float: origin = np.asarray(origin) normal = unit_vector(normal) point = np.asarray(point) if norm(origin - point) < constants.TOL: # point and origin are coincident return norm(origin - point) return abs(np.dot(point - origin, normal)) def is_point_on_plane(origin: PointType, normal: VectorType, point: PointType) -> bool: """Calculated distance between a point and a plane, defined by origin and normal vector""" return point_to_plane_distance(origin, normal, point) < constants.TOL def point_to_line_distance(origin: PointType, direction: VectorType, point: PointType) -> float: """Calculates distance from a line, defined by a point and normal, and an arbitrary point in 3D space""" origin = np.asarray(origin) point = np.asarray(point) direction = np.asarray(direction) return norm(np.cross(point - origin, direction)) / norm(direction) def polyline_length(points: NPPointListType) -> float: """Calculates length of a polyline, given by a list of points""" if len(np.shape(points)) != 2 or len(points[0]) != 3: raise ValueError("Provide a list of points in 3D space!") if np.shape(points)[0] < 2: raise ValueError("Use at least 2 points for a polyline!") return np.sum(np.sqrt(np.sum((points[:-1] - points[1:]) ** 2, axis=1))) def flatten_2d_list(twodim: list[list]) -> list: """Flattens a list of lists to a 1d-list""" return list(chain.from_iterable(twodim)) ================================================ FILE: src/classy_blocks/util/tools.py ================================================ """Misc utilities""" import dataclasses import os from classy_blocks.base.exceptions import CornerPairError from classy_blocks.cbtyping import OrientType from classy_blocks.util.constants import SIDES_MAP from classy_blocks.util.frame import Frame def report(text, end=None): """TODO: improve (verbosity, logging, ...)""" if end is None: end = os.linesep print(text, end=end) @dataclasses.dataclass class EdgeLocation: """A helper class that maps top/bottom/side faces of an operation and corner indexes""" corner_1: int corner_2: int side: OrientType @property def start_corner(self) -> int: """Returns start corner for this location""" diff = abs(self.corner_1 - self.corner_2) corner_min = min(self.corner_1, self.corner_2) corner_max = max(self.corner_1, self.corner_2) if diff in (1, 4): # neighbours on top/bottom face (diff == 1) or # side corners (diff == 4) return corner_min % 4 if diff == 3: # the last corner of a face (0...3 or 4...7): return corner_max % 4 raise CornerPairError(f"Given pair: {self.corner_1}-{self.corner_2}") edge_map = Frame[EdgeLocation]() for i in range(4): corner_1 = i corner_2 = (i + 1) % 4 # bottom face edge_map.add_beam(corner_1, corner_2, EdgeLocation(corner_1, corner_2, "bottom")) # top face edge_map.add_beam(corner_1 + 4, corner_2 + 4, EdgeLocation(corner_1 + 4, corner_2 + 4, "top")) # side edges edge_map.add_beam(corner_1, corner_1 + 4, EdgeLocation(corner_1, corner_1 + 4, SIDES_MAP[i])) ================================================ FILE: src/classy_blocks/write/__init__.py ================================================ ================================================ FILE: src/classy_blocks/write/formats.py ================================================ from classy_blocks.items.block import Block from classy_blocks.items.edges.edge import Edge from classy_blocks.items.patch import Patch, Side from classy_blocks.items.vertex import Vertex from classy_blocks.lists.face_list import ProjectedFace from classy_blocks.util.constants import vector_format def indent(text: str, levels: int) -> str: """Indents 'text' by 'levels' tab characters""" return "\t" * levels + text + "\n" def format_vertex(vertex: Vertex) -> str: """Returns a string representation to be written to blockMeshDict""" point = vector_format(vertex.position) comment = f"// {vertex.index}" if len(vertex.projected_to) > 0: return f"project {point} ({' '.join(vertex.projected_to)}) {comment}" return f"{point} {comment}" def format_block(block: Block) -> str: if all(axis.is_simple for axis in block.axes): fmt_grading = ( "simpleGrading ( " + block.axes[0].wires.format_single() + " " + block.axes[1].wires.format_single() + " " + block.axes[2].wires.format_single() + " )" ) else: fmt_grading = ( "edgeGrading ( " + block.axes[0].wires.format_all() + " " + block.axes[1].wires.format_all() + " " + block.axes[2].wires.format_all() + " )" ) fmt_hidden = "" if block.visible else "// " fmt_vertices = "( " + " ".join(str(v.index) for v in block.vertices) + " )" fmt_count = "( " + " ".join([str(axis.count) for axis in block.axes]) + " )" fmt_comments = f"// {block.index} {block.comment}" return f"{fmt_hidden}hex {fmt_vertices} {block.cell_zone} {fmt_count} {fmt_grading} {fmt_comments}" def format_edge(edge: Edge) -> str: return edge.description def format_side(side: Side) -> str: return "(" + " ".join([str(v.index) for v in side.vertices]) + ")" def format_patch(patch: Patch) -> str: # inlet # { # type patch; # faces # ( # (0 1 2 3) # ); # } out = f"{patch.name}\n" out += indent("{", 1) out += indent(f"type {patch.kind};", 2) for option in patch.settings: out += indent(f"{option};", 2) out += indent("faces", 2) out += indent("(", 2) for side in patch.sides: out += indent(format_side(side), 3) out += indent(");", 2) out += indent("}", 1) return out def format_face(face: ProjectedFace) -> str: return f"\tproject {format_side(face.side)} {face.label}\n" ================================================ FILE: src/classy_blocks/write/vtk.py ================================================ from classy_blocks.construct.flat.sketch import Sketch from classy_blocks.items.block import Block from classy_blocks.items.vertex import Vertex from classy_blocks.optimize.grid import QuadGrid def mesh_to_vtk(path: str, vertices: list[Vertex], blocks: list[Block]) -> None: """Generates a simple VTK file where each block is a hexahedral cell; useful for debugging blockMesh's FATAL_ERRORs""" # A sample VTK file with all cell types; only hexahedrons are used (cell type 12) # vtk DataFile Version 2.0 # classy_blocks debug output # ASCII # DATASET UNSTRUCTURED_GRID # POINTS 27 float # 0 0 0 1 0 0 2 0 0 0 1 0 1 1 0 2 1 0 # 0 0 1 1 0 1 2 0 1 0 1 1 1 1 1 2 1 1 # 0 1 2 1 1 2 2 1 2 0 1 3 1 1 3 2 1 3 # 0 1 4 1 1 4 2 1 4 0 1 5 1 1 5 2 1 5 # 0 1 6 1 1 6 2 1 6 # CELLS 11 60 # 8 0 1 4 3 6 7 10 9 # 8 1 2 5 4 7 8 11 10 # 4 6 10 9 12 # 4 5 11 10 14 # 6 15 16 17 14 13 12 # 6 18 15 19 16 20 17 # 4 22 23 20 19 # 3 21 22 18 # 3 22 19 18 # 2 26 25 # 1 24 # CELL_TYPES 11 # 12 # 12 # 10 # 10 # 7 # 6 # 9 # 5 # 5 # 3 # 1 # CELL_DATA 11 # SCALARS block_ids float 1 # LOOKUP_TABLE default # 0 # 1 # 2 # 3 # 4 # 5 # 6 # 7 # 8 # 9 # 10 with open(path, "w", encoding="utf-8") as output: n_blocks = len(blocks) header = "# vtk DataFile Version 2.0\n" + "classy_blocks debug output\n" + "ASCII\n" output.write(header) # points output.write("\nDATASET UNSTRUCTURED_GRID\n") output.write(f"POINTS {len(vertices)} float\n") for vertex in vertices: output.write(f"{vertex.position[0]} {vertex.position[1]} {vertex.position[2]}\n") # cells output.write(f"\nCELLS {n_blocks} {9 * n_blocks}\n") for block in blocks: output.write("8") for vertex in block.vertices: output.write(f" {vertex.index}") output.write("\n") # cell types output.write(f"\nCELL_TYPES {n_blocks}\n") for _ in blocks: output.write("12\n") # cell data output.write(f"\nCELL_DATA {n_blocks}\n") output.write("SCALARS block_ids float 1\n") output.write("LOOKUP_TABLE default\n") for i in range(n_blocks): output.write(f"{i}\n") def sketch_to_vtk(sketch: Sketch, filename: str) -> None: """Outputs a sketch to VTK""" grid = QuadGrid.from_sketch(sketch) points = grid.points indexes = grid.addressing n_quads = len(indexes) with open(filename, "w") as f: f.write("# vtk DataFile Version 2.0\n") f.write("Quadrangle mesh\n") f.write("ASCII\n") f.write("DATASET POLYDATA\n") f.write(f"POINTS {len(points)} float\n") for pt in points: f.write(f"{pt[0]} {pt[1]} {pt[2]}\n") f.write(f"\nPOLYGONS {len(indexes)} {len(indexes) * 5}\n") for quad in indexes: f.write(f"4 {quad[0]} {quad[1]} {quad[2]} {quad[3]}\n") # cell data f.write(f"\nCELL_DATA {n_quads}\n") f.write("SCALARS block_ids float 1\n") f.write("LOOKUP_TABLE default\n") for i in range(n_quads): f.write(f"{i}\n") ================================================ FILE: src/classy_blocks/write/writer.py ================================================ import datetime import sys from dataclasses import asdict from typing import Callable from classy_blocks.assemble.dump import AssembledDump from classy_blocks.assemble.settings import Settings from classy_blocks.cbtyping import GeometryType from classy_blocks.util import constants from classy_blocks.write.formats import format_block, format_edge, format_face, format_patch, format_vertex def format_geometry(geometry: GeometryType) -> str: # nothing to output? if len(geometry.items()) == 0: return "" out = "geometry\n{\n" for name, properties in geometry.items(): out += f"\t{name}\n\t{{\n" for prop in properties: out += f"\t\t{prop};\n" out += "\t}\n" out += "};\n\n" return out def format_list(name: str, items: list, formatter: Callable) -> str: out = f"{name}\n(\n" for item in items: out += f"\t{formatter(item)}\n" out += ");\n\n" return out def format_settings(settings: Settings): out = "" if settings.default_patch: out += "defaultPatch\n{\n" out += f"\tname {settings.default_patch['name']};\n" out += f"\ttype {settings.default_patch['kind']};\n" out += "}\n\n" # a list of merged patches out += "mergePatchPairs\n(\n" for pair in settings.merged_patches: out += f"\t({pair[0]} {pair[1]})\n" out += ");\n\n" for key, value in asdict(settings).items(): if key in ("patch_settings", "merged_patches", "default_patch", "geometry"): continue key_words = key.split("_") dict_key = key_words[0] + "".join(word.capitalize() for word in key_words[1:]) if value is not None: out += f"{dict_key} {value};\n" out += "\n" return out class MeshWriter: def __init__(self, dump: AssembledDump, settings: Settings): self.dump = dump self.settings = settings def write(self, output_path: str): with open(output_path, "w", encoding="utf-8") as output: output.write(constants.MESH_HEADER.format(script=sys.argv[0], timestamp=str(datetime.datetime.now()))) output.write(format_geometry(self.settings.geometry)) output.write(format_list("vertices", self.dump.vertices, format_vertex)) output.write(format_list("blocks", self.dump.blocks, format_block)) output.write(format_list("edges", self.dump.edges, format_edge)) output.write(format_list("boundary", list(self.dump.patches), format_patch)) output.write(format_list("faces", self.dump.face_list.faces, format_face)) output.write(format_settings(self.settings)) output.write(constants.MESH_FOOTER) ================================================ FILE: tests/__init__.py ================================================ ================================================ FILE: tests/fixtures/__init__.py ================================================ ================================================ FILE: tests/fixtures/block.py ================================================ from typing import get_args from classy_blocks.cbtyping import DirectionType from classy_blocks.construct.flat.face import Face from classy_blocks.construct.operations.loft import Loft from classy_blocks.grading.define.collector import ChopCollector from classy_blocks.items.block import Block from classy_blocks.items.edges.factory import factory from classy_blocks.items.vertex import Vertex from tests.fixtures.data import DataTestCase class BlockTestCase(DataTestCase): """Block item tests""" def make_vertices(self, index: int) -> list[Vertex]: """Generates Vertex objects for testing""" data = self.get_single_data(index) points = data.points indexes = data.indexes return [Vertex(p, indexes[i]) for i, p in enumerate(points)] def make_block(self, index: int) -> Block: """The test subject""" block_data = self.get_single_data(index) vertices = self.make_vertices(index) block = Block(index, vertices) for edge_data in block_data.edges: corner_1 = edge_data[0] corner_2 = edge_data[1] vertex_1 = vertices[corner_1] vertex_2 = vertices[corner_2] edge = factory.create(vertex_1, vertex_2, edge_data[2]) block.add_edge(corner_1, corner_2, edge) collector = ChopCollector() for i in get_args(DirectionType): for chop in block_data.chops[i]: collector.chop_axis(i, chop) block.set_chops(collector) return block def make_loft(self, index: int) -> Loft: """Creates a Loft for tests that require an operation""" vertices = self.make_vertices(index) face_1 = Face([vertices[i].position for i in (0, 1, 2, 3)]) face_2 = Face([vertices[i].position for i in (4, 5, 6, 7)]) return Loft(face_1, face_2) ================================================ FILE: tests/fixtures/data.py ================================================ """a test mesh: 3 blocks, extruded in z-direction (these indexes refer to 'fl' and 'cl' variables, not vertex.mesh_index) ^ y-axis | | 7---6 | 2 | 3---2---5 | 0 | 1 | 0---1---4 ---> x-axis After adding the blocks, the following mesh indexes are in mesh.vertices: Bottom 'floor': ^ y-axis | | 13--12 | 2 | 3---2---9 | 0 | 1 | 0---1---8 ---> x-axis Top 'floor': ^ y-axis | | 15--14 | 2 | 7---6--11 | 0 | 1 | 4---5--10 ---> x-axis""" import dataclasses import unittest import numpy as np from classy_blocks.construct import edges from classy_blocks.grading.define.chop import Chop fl: list[list[float]] = [ # points on the 'floor'; z=0 [0, 0, 0], # 0 [1, 0, 0], # 1 [1, 1, 0], # 2 [0, 1, 0], # 3 [2, 0, 0], # 4 [2, 1, 0], # 5 [2, 2, 0], # 6 [1, 2, 0], # 7 ] fl_indexes = [0, 1, 2, 3, 8, 9, 12, 13] cl = [[p[0], p[1], 1] for p in fl] # points on ceiling; z = 1 cl_indexes = [4, 5, 6, 7, 10, 11, 14, 15] @dataclasses.dataclass class TestOperationData: """to store predefined data for test block creation""" # points from which to create the block point_indexes: list[int] # edges; parameters correspond to block.add_edge() args edges: list = dataclasses.field(default_factory=list) # chop counts (for each axis, use None to not chop) chops: list[list[Chop]] = dataclasses.field(default_factory=lambda: [[], [], []]) # calls to set_patch() patches: list = dataclasses.field(default_factory=list) # other thingamabobs description: str = "" cell_zone: str = "" @property def points(self): # to create vertices return np.array([fl[i] for i in self.point_indexes] + [cl[i] for i in self.point_indexes]) @property def indexes(self): # to create vertices return [fl_indexes[i] for i in self.point_indexes] + [cl_indexes[i] for i in self.point_indexes] test_data = [ TestOperationData( point_indexes=[0, 1, 2, 3], edges=[ # edges [0, 1, edges.Arc([0.5, -0.25, 0])], [1, 2, edges.Spline([[1.1, 0.25, 0], [1.05, 0.5, 0], [1.1, 0.75, 0]])], ], chops=[ # chops [Chop(count=6)], [], [], ], patches=[["left", "inlet"], [["bottom", "top", "front", "back"], "walls", "wall"]], description="Test", ), TestOperationData( point_indexes=[1, 4, 5, 2], edges=[ [3, 0, edges.Arc([0.5, -0.1, 1])], # duplicated edge in block 2 that must not be included [0, 1, edges.Arc([0.5, 0, 0])], # collinear point; invalid edge must be dropped ], chops=[ [Chop(count=5)], [Chop(count=6)], [], ], patches=[ [["bottom", "top", "right", "front"], "walls", "wall"], ], ), TestOperationData( point_indexes=[2, 5, 6, 7], chops=[ [], [Chop(count=8)], [Chop(count=7)], ], patches=[["back", "outlet"], [["bottom", "top", "left", "right"], "walls"]], ), ] class DataTestCase(unittest.TestCase): """Test case with ready-made block data""" @staticmethod def get_single_data(index: int) -> TestOperationData: """Returns a list of predefined blocks for testing""" return test_data[index] @staticmethod def get_all_data() -> list[TestOperationData]: """Returns all prepared block data""" return test_data ================================================ FILE: tests/fixtures/mesh.py ================================================ from typing import get_args from classy_blocks.cbtyping import DirectionType from classy_blocks.construct.operations.box import Box from classy_blocks.mesh import Mesh from tests.fixtures.data import DataTestCase class MeshTestCase(DataTestCase): """A test case where all blocks are already in the mesh after setUp""" def make_box(self, index): data = self.get_single_data(index) box = Box(data.points[0], data.points[6]) for i in get_args(DirectionType): box.chop(i, count=10) return box def setUp(self): super().setUp() self.mesh = Mesh() self.boxes = [] for i in range(3): box = self.make_box(i) self.mesh.add(box) self.mesh.assemble() ================================================ FILE: tests/helpers/__init__.py ================================================ ================================================ FILE: tests/helpers/collect_outputs.py ================================================ #!/usr/bin/env python # Run from main directory (a.k.a. "./tests/helpers/collect_outputs.py") """Collects output files from all examples and stores them into tests/outputs; TODO: THIS IS NOT A TEST CASE! TODO: Proper CI/CD testing scenario and environment""" import glob import os import shutil import subprocess current_dir = os.getcwd() outputs_dir = "tests/outputs/" case_dir = "examples/case" def collect_dict(path): # >>> path = examples/chaining/tank.py # don't import __init__s if path.endswith("__init__.py"): return # >>> ['examples', 'chaining', 'tank.py'] exploded_path = path.split("/") # >>> 'tank' example_file = exploded_path[-1] example_name = example_file[:-3] # >>> 'examples/chaining' example_dir = "/".join(exploded_path[:-1]) + "/" # >>> 'tests/outputs/examples/chaining' output_dir = outputs_dir + example_dir # >>> 'tests/outputs/examples/chaining/tank' output_path = output_dir + example_name print(f"Running {output_path} > {output_path}") os.chdir(example_dir) subprocess.run(["python", example_file], check=False) os.chdir(current_dir) # create mesh and confirm checkMesh output os.chdir(case_dir) subprocess.run("blockMesh", check=True, capture_output=True) result = subprocess.run("checkMesh", check=True, capture_output=True) # check_result must contain 'Mesh OK.' assert result.stdout.splitlines(keepends=False)[-4] == b"Mesh OK.", f"checkMesh failed: {path}; {result.stdout}" # change back to this dir to prepare the next example os.chdir(current_dir) # copy this 'checked' example to outputs to test against # (currently not implemented as it doesn't seem os.makedirs(output_dir, exist_ok=True) shutil.copyfile("examples/case/system/blockMeshDict", output_path) if __name__ == "__main__": examples = glob.glob("examples/*/*.py") for example_path in examples: collect_dict(example_path) ================================================ FILE: tests/test_assemble.py ================================================ import unittest from parameterized import parameterized from classy_blocks.construct.operations.box import Box from classy_blocks.construct.operations.operation import Operation from classy_blocks.construct.shapes.cylinder import Cylinder from classy_blocks.lookup.cell_registry import CellRegistry from classy_blocks.lookup.connection_registry import HexConnectionRegistry, get_key from classy_blocks.lookup.face_registry import HexFaceRegistry from classy_blocks.lookup.point_registry import HexPointRegistry class RegistryTests(unittest.TestCase): def get_shape(self) -> Cylinder: return Cylinder([0, 0, 0], [0, 0, 1], [1, 0, 0]) def get_hex_point_registry(self) -> HexPointRegistry: registry = HexPointRegistry.from_operations(self.get_shape().operations) return registry @parameterized.expand( ( ([0, 0, 0], 0), ([0, 0, 1], 4), ([1, 0, 0], 18), ([1, 0, 1], 20), ([0, 1, 0], 22), ([0, 1, 1], 23), ) ) def test_find_point_index(self, point, index): # points are taken from an actual blockMesh'ed cylinder registry = self.get_hex_point_registry() self.assertEqual(registry.find_point_index(point), index) def test_connection(self): point_reg = self.get_hex_point_registry() conn_reg = HexConnectionRegistry(point_reg.unique_points, point_reg.cell_addressing) point_1 = [0, 0, 0] point_2 = [0, 0.8, 0] index_1 = point_reg.find_point_index(point_1) index_2 = point_reg.find_point_index(point_2) self.assertTrue(get_key(index_1, index_2) in conn_reg.connections) @parameterized.expand(((0, 5), (18, 4))) def test_junction(self, point_index, neighbour_count): point_reg = self.get_hex_point_registry() conn_reg = HexConnectionRegistry(point_reg.unique_points, point_reg.cell_addressing) self.assertEqual(len(conn_reg.get_connected_indexes(point_index)), neighbour_count) @parameterized.expand( ( (0, "top", {0, 1}), (0, "bottom", {0, 2}), (1, "bottom", {0, 1}), (1, "top", {1}), (2, "top", {0, 2}), (0, "left", {0}), (0, "right", {0}), (0, "front", {0}), (0, "back", {0}), ) ) def test_face_registry(self, cell, side, indexes): box_ground = Box([0, 0, 0], [1, 1, 1]) box_up = box_ground.copy().translate([0, 0, 1]) box_down = box_ground.copy().translate([0, 0, -1]) preg = HexPointRegistry.from_operations([box_ground, box_up, box_down]) freg = HexFaceRegistry(preg.cell_addressing) self.assertSetEqual(freg.get_cells(cell, side), indexes) @parameterized.expand( ( ([0, 0, 0], {0}), ([1, 0, 0], {0, 1}), ([2, 0, 0], {1}), ([1, 1, 0], {0, 1, 2, 3}), ) ) def test_cell_registry(self, point, cells) -> None: boxes: list[Operation] = [ Box([0, 0, 0], [1, 1, 1]), # lower left Box([0, 0, 0], [1, 1, 1]).translate([1, 0, 0]), # lower right Box([0, 0, 0], [1, 1, 1]).translate([1, 1, 0]), # upper right Box([0, 0, 0], [1, 1, 1]).translate([0, 1, 0]), # upper left ] preg = HexPointRegistry.from_operations(boxes) creg = CellRegistry(preg.cell_addressing) point_index = preg.find_point_index(point) self.assertSetEqual(creg.get_near_cells(point_index), cells) ================================================ FILE: tests/test_bugs/__init__.py ================================================ ================================================ FILE: tests/test_bugs/test_grading.py ================================================ import unittest from typing import get_args import numpy as np import classy_blocks as cb from classy_blocks.cbtyping import DirectionType from classy_blocks.write import formats class GradingBugTests(unittest.TestCase): def test_invert_grading(self): # Bug case; two blocks with separate bottom faces share the same top face. # Grading in one direction must be inverted # /|\ # / / \ \ # / / \ \ # / / \ \ # /____/ ^ \____\ # base common neighbour box = cb.Box([0, 0, 0], [1, 1, 1]) base_face = box.bottom_face neighbour_face = base_face.copy().translate([4, 0, 0]) common_face = base_face.copy().rotate(np.pi / 2, [0, 1, 0]).translate([2, 0, 2]) left_loft = cb.Loft(base_face, common_face) right_loft = cb.Loft(neighbour_face, common_face.copy().shift(2).invert()) for axis in get_args(DirectionType): left_loft.chop(axis, start_size=0.05, total_expansion=5) right_loft.chop(2, count=10) mesh = cb.Mesh() mesh.add(left_loft) mesh.add(right_loft) mesh.assemble() mesh.grade() self.assertIn("simpleGrading ( 5 5 5 )", formats.format_block(mesh.blocks[0])) self.assertIn("simpleGrading ( 0.2 5 1 )", formats.format_block(mesh.blocks[1])) ================================================ FILE: tests/test_construct/__init__.py ================================================ ================================================ FILE: tests/test_construct/test_assembly.py ================================================ import unittest import numpy as np from parameterized import parameterized from classy_blocks.cbtyping import NPPointListType from classy_blocks.construct.assemblies.joints import JointBase, LJoint, NJoint, TJoint from classy_blocks.mesh import Mesh from classy_blocks.util import functions as f class AssemblyTests(unittest.TestCase): def setUp(self): self.center_point = [0, 0, 0] self.start_point = [0, -1, 0] self.radius_point = [0, -1, 0.3] def fanout(self, count: int) -> NPPointListType: angles = np.linspace(0, 2 * np.pi, num=count, endpoint=False) return np.array([f.rotate(self.start_point, angle, [0, 0, 1], self.center_point) for angle in angles]) def get_joint_points(self, joint: JointBase) -> NPPointListType: points = [] for i, shape in enumerate(joint.shapes): if i % 2 == 0: points.append(shape.operations[0].bottom_face.points[0].position) return np.array(points) def test_t_joint(self): joint = TJoint(self.start_point, self.center_point, self.radius_point) joint_points = self.get_joint_points(joint) expected_points = np.take(self.fanout(4), (0, 1, 3), axis=0) np.testing.assert_almost_equal(joint_points, expected_points) def test_l_joint(self): joint = LJoint(self.start_point, self.center_point, self.radius_point) joint_points = self.get_joint_points(joint) expected_points = np.take(self.fanout(4), (0, 1), axis=0) np.testing.assert_almost_equal(joint_points, expected_points) @parameterized.expand(((3,), (4,), (5,), (6,))) def test_n_joint(self, branches): joint = NJoint(self.start_point, self.center_point, self.radius_point, branches=branches) joint_points = self.get_joint_points(joint) expected_points = self.fanout(branches) np.testing.assert_almost_equal(joint_points, expected_points) def test_operations(self): joint = NJoint(self.start_point, self.center_point, self.radius_point, branches=3) self.assertEqual(len(joint.operations), 3 * 2 * 6) def test_center(self): joint = NJoint(self.start_point, self.center_point, self.radius_point, branches=3) np.testing.assert_equal(joint.center, self.center_point) def test_chop(self): mesh = Mesh() joint = TJoint(self.start_point, self.center_point, self.radius_point) cell_size = 0.1 joint.chop_axial(start_size=cell_size) joint.chop_radial(start_size=cell_size) joint.chop_tangential(start_size=cell_size) mesh.add(joint) mesh.assemble() mesh.grade() # TODO: missing assert? def test_set_patches(self): branches = 5 joint = NJoint(self.start_point, self.center_point, self.radius_point, branches) joint.set_outer_patch("walls") for i in range(branches): joint.set_hole_patch(i, f"outlet_{i}") ================================================ FILE: tests/test_construct/test_chaining.py ================================================ import unittest import numpy as np from classy_blocks.base.exceptions import ( CylinderCreationError, ElbowCreationError, ExtrudedRingCreationError, FrustumCreationError, ) from classy_blocks.construct.flat.face import Face from classy_blocks.construct.shapes.cylinder import Cylinder from classy_blocks.construct.shapes.elbow import Elbow from classy_blocks.construct.shapes.frustum import Frustum from classy_blocks.construct.shapes.rings import ExtrudedRing from classy_blocks.construct.shapes.sphere import Hemisphere from classy_blocks.mesh import Mesh from classy_blocks.util.constants import TOL class ElbowChainingTests(unittest.TestCase): """Chaining of elbow to everything elbow-chainable""" def setUp(self): self.elbow = Elbow( [0, 0, 0], # center_point_1 [1, 0, 0], # radius_point_1 [0, 1, 0], # normal_1 -np.pi / 2, # sweep angle [2, 0, 0], # arc_center [0, 0, 1], # rotation_axis 1.0, # radius_2 ) self.mesh = Mesh() def check_success(self, chained_shape, end_center): """adds the chained stuff to mesh and checks the number of vertices as a measurement of success""" self.mesh.add(self.elbow) self.mesh.add(chained_shape) self.mesh.assemble() self.assertEqual(len(self.mesh.blocks), 24) self.assertEqual(len(self.mesh.vertices), 3 * 17) np.testing.assert_allclose(chained_shape.sketch_2.center, end_center, atol=1e-7) def test_to_elbow_end(self): """Chain an elbow to an elbow on an end sketch""" chained = Elbow.chain( self.elbow, # source -np.pi / 2, # sweep_angle [2, 0, 0], # arc_center [0, 0, 1], # rotation_axis 1, # radius_2 False, ) # start_face self.check_success(chained, [4, 0, 0]) def test_chain_on_invalid_start_face(self): with self.assertRaises(ElbowCreationError): elbow = self.elbow.copy() # set invalid base shape for chaining elbow.sketch_1 = Face([[0, 0, 0], [1, 0, 0], [0, 1, 0], [1, 1, 0]]) Elbow.chain(elbow, np.pi / 2, [2, 0, 0], [0, 0, 1], 1, True) def test_to_elbow_start(self): """Chain an elbow to an elbow on a start sketch""" chained = Elbow.chain(self.elbow, np.pi / 2, [2, 0, 0], [0, 0, 1], 1, True) self.check_success(chained, [2, -2, 0]) def test_to_cylinder_start(self): """Chain an elbow to a cylinder on an end sketch""" chained = Cylinder.chain(self.elbow, 1) self.check_success(chained, [3, 2, 0]) def test_to_cylinder_end(self): """Chain an elbow to a cylinder on a start sketch""" chained = Cylinder.chain(self.elbow, 1, start_face=True) self.check_success(chained, [0, -1, 0]) def test_to_cylinder_negative(self): """Raise an exception when chaining with a negative length""" with self.assertRaises(CylinderCreationError): Cylinder.chain(self.elbow, -1) def test_chain_frustum_invalid_length(self): with self.assertRaises(FrustumCreationError): Frustum.chain(self.elbow, -1, 0.5) def test_to_frustum_start(self): chained = Frustum.chain(self.elbow, 1, 0.5) self.check_success(chained, [3, 2, 0]) def test_to_frustum_end(self): chained = Frustum.chain(self.elbow, 1, 0.5, start_face=True) self.check_success(chained, [0, -1, 0]) def test_to_sphere_end(self): chained = Hemisphere.chain(self.elbow, start_face=False) np.testing.assert_equal(chained.center_point, self.elbow.sketch_2.center) def test_to_sphere_start(self): chained = Hemisphere.chain(self.elbow, start_face=True) np.testing.assert_equal(chained.center_point, self.elbow.sketch_1.center) class RingChainingTests(unittest.TestCase): """Chaining of extruded rings""" def setUp(self): self.ring = ExtrudedRing( [0, 0, 0], # center_point_1 [1, 0, 0], # center_point_2 [0, 1, 0], # radius_point_1 0.8, # inner_radius 7, # n_segments: deliberately use non-default ) self.mesh = Mesh() def check_success(self, chained_shape, end_center): """adds the chained stuff to mesh and checks the number of vertices as a measurement of success""" self.mesh.add(self.ring) self.mesh.add(chained_shape) self.mesh.assemble() self.assertEqual(len(self.mesh.blocks), 2 * self.ring.sketch_1.n_segments) self.assertEqual(len(self.mesh.vertices), 3 * 2 * self.ring.sketch_1.n_segments) np.testing.assert_allclose(chained_shape.sketch_2.center, end_center, atol=TOL) def test_chain_invalid_length(self): with self.assertRaises(ExtrudedRingCreationError): ExtrudedRing.chain(self.ring, -1) def test_chain_end(self): """Chain an extruded ring on end face""" chained = ExtrudedRing.chain(self.ring, 1) self.check_success(chained, [2, 0, 0]) def test_chain_start(self): """Chain an extruded ring on start face""" chained = ExtrudedRing.chain(self.ring, 1, start_face=True) self.check_success(chained, [-1, 0, 0]) class ExpandContractTests(unittest.TestCase): """Tests of shapes and their methods expand/contract/fill""" def setUp(self): self.mesh = Mesh() def check_success(self, shape_1, shape_2, n_blocks, n_vertices): self.mesh.add(shape_1) self.mesh.add(shape_2) self.mesh.assemble() self.assertEqual(len(self.mesh.blocks), n_blocks) self.assertEqual(len(self.mesh.vertices), n_vertices) def test_expand_cylinder(self): """Expand a ring from a cylinder""" cylinder = Cylinder([0, 0, 0], [1, 0, 0], [0, 1, 0]) expanded = ExtrudedRing.expand(cylinder, 0.25) self.check_success(cylinder, expanded, 20, 2 * (17 + 8)) def test_expand_ring(self): """Expand a ring from a ring""" ring = ExtrudedRing([0, 0, 0], [1, 0, 0], [0, 1, 0], 0.8, 9) expanded = ExtrudedRing.expand(ring, 0.25) self.check_success(ring, expanded, 18, 3 * 9 * 2) def test_contract_ring_invalid_radius(self): with self.assertRaises(ExtrudedRingCreationError): ring = ExtrudedRing([0, 0, 0], [1, 0, 0], [0, 1, 0], 0.6, 9) _ = ExtrudedRing.contract(ring, 2) def test_contract_ring_zero_radius(self): with self.assertRaises(ExtrudedRingCreationError): ring = ExtrudedRing([0, 0, 0], [1, 0, 0], [0, 1, 0], 0.6, 9) _ = ExtrudedRing.contract(ring, 0) def test_contract_ring(self): """Contract a ring from another ring""" ring = ExtrudedRing([0, 0, 0], [1, 0, 0], [0, 1, 0], 0.6, 9) contracted = ExtrudedRing.contract(ring, 0.2) self.check_success(ring, contracted, 18, 2 * 3 * 9) def test_fill_assert(self): """Make sure the source ring is made from 8 segments""" with self.assertRaises(CylinderCreationError): Cylinder.fill(ExtrudedRing([0, 0, 0], [1, 0, 0], [0, 1, 0], 0.4, 9)) def test_fill(self): """Fill an ExtrudedRing with a Cylinder""" ring = ExtrudedRing([0, 0, 0], [1, 0, 0], [0, 1, 0], 0.4) fill = Cylinder.fill(ring) self.check_success(ring, fill, 20, 2 * (17 + 8)) ================================================ FILE: tests/test_construct/test_curves/__init__.py ================================================ ================================================ FILE: tests/test_construct/test_curves/test_analytic.py ================================================ import unittest from math import cos, sin import numpy as np from parameterized import parameterized from classy_blocks.cbtyping import NPPointType from classy_blocks.construct.curves.analytic import AnalyticCurve, CircleCurve, LineCurve from classy_blocks.util import functions as f from classy_blocks.util.constants import TOL class AnalyticCurveTests(unittest.TestCase): def setUp(self): self.radius = 3 def circle(t: float) -> NPPointType: # Test curve: a circle with radius 1 return np.array([self.radius * cos(t), self.radius * sin(t), 0]) self.circle = circle self.curve = AnalyticCurve(circle, (0, np.pi)) self.places = int(np.log10(1 / TOL)) - 1 def test_arc_length_halfcircle(self): # use less strict checking because of discretization error self.assertAlmostEqual(self.curve.get_length(0, np.pi), self.radius * np.pi, places=2) def test_arc_length_quartercircle(self): self.assertAlmostEqual(self.curve.get_length(0, np.pi / 2), self.radius * np.pi / 2, places=2) def test_length_property(self): self.assertAlmostEqual(self.curve.length, self.radius * np.pi, places=2) @parameterized.expand( ( ([3, 0, 0], 0), ([0, 3, 0], np.pi / 2), ([-3, 0, 0], np.pi), ) ) def test_closest_param(self, position, param): self.assertAlmostEqual(self.curve.get_closest_param(position), param, places=4) def test_discretize(self): count = 20 discretized_points = self.curve.discretize(count=count) analytic_points = [self.circle(t) for t in np.linspace(0, np.pi, count)] np.testing.assert_almost_equal(discretized_points, analytic_points) def test_transform(self): """Raise an error when trying to transform an analytic curve""" with self.assertRaises(NotImplementedError): self.curve.translate([1, 1, 1]) @parameterized.expand( ( (0, [0, 1, 0]), (np.pi / 2, [-1, 0, 0]), (np.pi, [0, -1, 0]), ) ) def test_tangent(self, param, tangent): np.testing.assert_almost_equal(self.curve.get_tangent(param), tangent, decimal=self.places) @parameterized.expand( ( (0, [-1, 0, 0]), (np.pi / 2, [0, -1, 0]), (np.pi, [1, 0, 0]), ) ) def test_normal(self, param, normal): np.testing.assert_almost_equal(self.curve.get_normal(param), normal) @parameterized.expand( ( (0,), (np.pi / 2,), (np.pi,), ) ) def test_binormal(self, param): np.testing.assert_almost_equal(self.curve.get_binormal(param), [0, 0, 1]) class LineCurveTests(unittest.TestCase): def setUp(self): self.point_1 = [0, 0, 0] self.point_2 = [1, 1, 0] self.bounds = (0, 1) @property def curve(self) -> LineCurve: return LineCurve(self.point_1, self.point_2, self.bounds) def test_init(self): _ = self.curve @parameterized.expand( ( (0, [0, 0, 0]), (0.5, [0.5, 0.5, 0]), (1, [1, 1, 0]), ) ) def test_values_within_bounds(self, param, value): np.testing.assert_almost_equal(self.curve.get_point(param), value) @parameterized.expand( ( (-1,), (2,), ) ) def test_values_outside_bounds(self, param): with self.assertRaises(ValueError): _ = self.curve.get_point(param) def test_translate(self): curve = self.curve curve.translate([1, 0, 0]) np.testing.assert_almost_equal(curve.get_point(0), [1, 0, 0]) def test_rotate(self): curve = self.curve curve.rotate(np.pi / 4, [0, 0, 1], [0, 0, 0]) np.testing.assert_almost_equal(curve.get_point(1), [0, 2**0.5, 0]) def test_scale(self): curve = self.curve curve.scale(2, [0, 0, 0]) np.testing.assert_almost_equal(curve.get_point(1), [2, 2, 0]) def test_scale_nocenter(self): curve = self.curve curve.scale(2) np.testing.assert_almost_equal(curve.get_point(0), [-0.5, -0.5, 0]) def test_center(self): np.testing.assert_almost_equal(self.curve.center, [0.5, 0.5, 0]) def test_get_param_at_length(self): self.assertAlmostEqual(self.curve.get_param_at_length(0.5 * 2**0.5), 0.5) class CircleCurveTests(unittest.TestCase): def setUp(self): self.origin = [1, 1, 0] self.rim = [2, 1, 0] self.normal = [0, 0, 1] self.bounds = (0, 2 * np.pi) @property def curve(self) -> CircleCurve: return CircleCurve(self.origin, self.rim, self.normal, self.bounds) @parameterized.expand(((0, [2, 1, 0]), (np.pi / 2, [1, 2, 0]), (np.pi, [0, 1, 0]), (3 * np.pi / 2, [1, 0, 0]))) def test_circle_points(self, param, point): np.testing.assert_almost_equal(self.curve.get_point(param), point) def test_translate(self): curve = self.curve curve.translate([-1, 0, 0]) np.testing.assert_almost_equal(curve.get_point(0), [1, 1, 0]) def test_rotate(self): curve = self.curve curve.rotate(np.pi / 2, [0, 0, 1]) np.testing.assert_almost_equal(curve.get_point(0), [1, 2, 0]) def test_scale(self): curve = self.curve curve.scale(2) np.testing.assert_almost_equal(f.norm(curve.get_point(0) - curve.center), 2) ================================================ FILE: tests/test_construct/test_curves/test_discrete.py ================================================ import unittest import numpy as np from parameterized import parameterized from classy_blocks.base.exceptions import ArrayCreationError from classy_blocks.construct.curves.discrete import DiscreteCurve class DiscreteCurveTests(unittest.TestCase): def setUp(self): self.points = [ [0, 0, 0], [1, 1, 0], [2, 4, 0], [3, 9, 0], ] self.segment_lengths = np.array([2**0.5, 10**0.5, 26**0.5]) @property def curve(self) -> DiscreteCurve: return DiscreteCurve(self.points) def test_single_point(self): """Only one point was provided""" with self.assertRaises(ArrayCreationError): _ = DiscreteCurve([[0, 0, 0]]) def test_wrong_shape(self): """Points are not in 3-dimensions""" with self.assertRaises(ArrayCreationError): _ = DiscreteCurve([[0, 0], [1, 0]]) @parameterized.expand(((-1, 1), (0, 5))) def test_discretize_wrong_params(self, param_from, param_to): """Invalid params passed to discretize() method""" with self.assertRaises(ValueError): self.curve.discretize(param_from, param_to) def test_discretize(self): """Call discretize() without params""" np.testing.assert_equal(self.curve.discretize(), self.points) def test_discretize_partial(self): """Discretize with given params""" np.testing.assert_equal(self.curve.discretize(1, 3), self.points[1:4]) def test_discretize_inverted(self): """Discretize with param_from bigger than param_to""" discretized = self.curve.discretize(3, 1) expected = np.flip(self.points[1:4], axis=0) np.testing.assert_equal(discretized, expected) def test_length(self): self.assertEqual(self.curve.length, sum(self.segment_lengths)) @parameterized.expand( ( (0, 1), (0, 2), (0, 3), ) ) def test_get_length(self, param_from, param_to): """Length of single segment""" length = sum(self.segment_lengths[param_from:param_to]) self.assertEqual(self.curve.get_length(param_from, param_to), length) @parameterized.expand( ( ([0, -1, 0], 0), ([0.8, 0.8, 0], 1), ([1.6, 3.5, 0], 2), ([10, 10, 0], 3), ) ) def test_get_closest_param(self, point, index): self.assertEqual(self.curve.get_closest_param(point), index) def test_translate(self): """A simple translation to test .parts property""" curve = self.curve curve.translate([0, 0, 1]) np.testing.assert_equal(curve.get_point(0), [0, 0, 1]) def test_center(self): np.testing.assert_almost_equal(self.curve.center, [1.5, 3.5, 0]) def test_scale(self): scaled = self.curve.scale(2, [0, 0, 0]) np.testing.assert_array_almost_equal( scaled.discretize(), [ [0, 0, 0], [2, 2, 0], [4, 8, 0], [6, 18, 0], ], ) ================================================ FILE: tests/test_construct/test_curves/test_interpolated.py ================================================ import unittest import numpy as np from parameterized import parameterized from classy_blocks.construct.curves.interpolated import LinearInterpolatedCurve, SplineInterpolatedCurve class LinearInterpolatedCurveTests(unittest.TestCase): def setUp(self): # a simple square wave self.points = [ [0, 0, 0], [0, 1, 0], [1, 1, 0], [1, 0, 0], [2, 0, 0], ] @property def curve(self) -> LinearInterpolatedCurve: return LinearInterpolatedCurve(self.points) def test_discretize(self): np.testing.assert_array_equal(self.curve.discretize(count=len(self.points)), self.points) @parameterized.expand( ( (0, 0.25), (0, 0.5), (0, 0.75), (0.5, 1), (0.75, 1), (0, 1), ) ) def test_get_length(self, param_from, param_to): length = (param_to - param_from) * 4 self.assertAlmostEqual(self.curve.get_length(param_from, param_to), length) def test_length(self): self.assertEqual(self.curve.length, 4) def test_get_point(self): np.testing.assert_array_equal(self.curve.get_point(0.125), [0, 0.5, 0]) @parameterized.expand( ( # the easy ones ([-1, 3, 0], 1 / 4), ([0.2, 2, 0], 1.2 / 4), ([0.8, 2, 0], 1.8 / 4), ([1.1, -0.5, 0], 3.1 / 4), # more difficult samples ([1.1, 0.1, 0], 3.1 / 4), ) ) def test_get_closest_param(self, point, param): self.assertAlmostEqual(self.curve.get_closest_param(point), param, places=3) def test_transform(self): curve = self.curve curve.translate([1, 1, 1]) np.testing.assert_array_equal(curve.get_point(0), [1, 1, 1]) def test_param_at_length(self): self.assertAlmostEqual(self.curve.get_param_at_length(1), 0.25) def test_shear(self): curve = LinearInterpolatedCurve(self.points, equalize=False) curve.shear([0, 1, 0], [0, 0, 0], [1, 0, 0], np.pi / 4) expected = [ [0, 0, 0], [1, 1, 0], [2, 1, 0], [1, 0, 0], [2, 0, 0], ] np.testing.assert_almost_equal(curve.discretize(count=5), expected) class SplineInterpolatedCurveTests(unittest.TestCase): def setUp(self): # a simple square wave self.points = np.array( [ [0, 0, 0], [1, 1, 0], [2, 0, 0], [3, -1, 0], [4, 0, 0], ] ) @property def curve(self) -> SplineInterpolatedCurve: return SplineInterpolatedCurve(self.points) def test_length(self): # spline must be longer than linear segments combined self.assertAlmostEqual(self.curve.length, 4 * 2**0.5) self.assertLess(self.curve.length, 8) @parameterized.expand( ( (0,), (0.25,), (0.5,), (0.75,), (1,), ) ) def test_through_points(self, param): """Make sure the curve goes through original points""" index = int(4 * param) np.testing.assert_almost_equal(self.curve.get_point(param), self.points[index]) def test_transform(self): curve = self.curve curve.translate([0, 0, 1]) np.testing.assert_equal(curve.get_point(0), [0, 0, 1]) def test_center_warning(self): with self.assertWarns(Warning): _ = self.curve.center @parameterized.expand( ( (0 / 3,), (0.5 / 3,), (1 / 3,), (1.5 / 3,), (2 / 3,), (2.5 / 3,), (3 / 3,), ) ) def test_interpolation_values_line(self, param): self.points = [ [0, 0, 0], [1, 1, 0], [2, 2, 0], [3, 3, 0], ] curve = self.curve np.testing.assert_almost_equal(curve.get_point(param), [3 * param, 3 * param, 0]) def test_get_param_at(self): points = np.array( [ [0.01675, 0.02764244, 0.01288988], [0.01589286, 0.02764244, 0.01288988], [0.01503571, 0.02764244, 0.01288988], [0.01417857, 0.02764244, 0.01288988], [0.01332143, 0.02764244, 0.01288988], [0.01246429, 0.02764244, 0.01288988], [0.01160714, 0.02764244, 0.01288988], [0.01075, 0.02764244, 0.01288988], [0.00989286, 0.02764244, 0.01288988], [0.00903571, 0.02764244, 0.01288988], [0.00817857, 0.02764244, 0.01288988], [0.00732143, 0.02764244, 0.01288988], [0.00646429, 0.02764244, 0.01288988], [0.00560714, 0.02764244, 0.01288988], [0.00475, 0.02764244, 0.01288988], ] ) curve = LinearInterpolatedCurve(points) self.assertAlmostEqual(curve.get_param_at_length(0.0015), 0.125, places=5) ================================================ FILE: tests/test_construct/test_curves/test_interpolators.py ================================================ import unittest import numpy as np from parameterized import parameterized from classy_blocks.construct.curves.interpolators import LinearInterpolator from classy_blocks.construct.series import Series class LinearInterpolatorTests(unittest.TestCase): def setUp(self): # a simple square wave self.points = Series( [ [0, 0, 0], [0, 1, 0], [1, 1, 0], [1, 0, 0], [2, 0, 0], ] ) @parameterized.expand( ( (0, [0, 0, 0]), (1 / 4, [0, 1, 0]), (2 / 4, [1, 1, 0]), (0.5 / 4, [0, 0.5, 0]), ) ) def test_points(self, param, result): intfun = LinearInterpolator(self.points, True) np.testing.assert_almost_equal(intfun(param), result) def test_extrapolate_exception(self): intfun = LinearInterpolator(self.points, False) with self.assertRaises(ValueError): _ = intfun(-1) ================================================ FILE: tests/test_construct/test_edge_data.py ================================================ import unittest import numpy as np from classy_blocks.base import transforms as tr from classy_blocks.base.exceptions import EdgeCreationError from classy_blocks.construct import edges from classy_blocks.construct.curves.discrete import DiscreteCurve from classy_blocks.util import functions as f class EdgeDataTests(unittest.TestCase): """Manipulation of edge data""" def test_line_create(self): """Create a Line edge data""" _ = edges.Line() def test_arc_transform(self): """Create and transform Arc edge data""" arc = edges.Arc([0.5, 0.25, 0]) arc.scale(2, [0, 0, 0]) np.testing.assert_array_almost_equal(arc.point.position, [1, 0.5, 0]) def test_origin_transform(self): """Transform Origin edge data""" origin = edges.Origin([1, 1, 1], 2) origin.transform([tr.Scaling(2, [0, 0, 0]), tr.Translation([0, -2, 0])]) np.testing.assert_array_almost_equal(origin.origin.position, [2, 0, 2]) def test_angle_scale(self): """Axis in Angle edge must not be scaled""" angle = edges.Angle(1, [1, 1, 0]) angle.scale(2, [0, 0, 0]) # axis is normalized np.testing.assert_array_almost_equal(angle.axis.position, f.unit_vector([1, 1, 0])) def test_angle_translate(self): """Axis in Angle edge must not be scaled""" angle = edges.Angle(1, [1, 1, 0]) angle.translate([2, 3, 4]) np.testing.assert_array_almost_equal(angle.axis.position, f.unit_vector([1, 1, 0])) def test_angle_rotate(self): """Axis in angle edge must be rotated""" angle = edges.Angle(1, [1, 1, 0]) angle.rotate(-np.pi / 4, [0, 0, 1], [0, 0, 0]) np.testing.assert_array_almost_equal(angle.axis.position, f.unit_vector([1, 0, 0])) def test_spline_transform(self): """Create and transform spline edge""" points = np.array([[0, 1, 0], [2**0.5 / 2, 2**0.5 / 2, 0], [1, 0, 0]]) spline = edges.Spline(points) spline.scale(2, [0, 0, 0]) np.testing.assert_array_almost_equal(spline.curve.discretize(), 2 * points) def test_project_create_single(self): """Create an edge, projected to a single surface""" edge = edges.Project("terrain") self.assertEqual(edge.label, ["terrain"]) def test_project_create_double(self): """Create an edge, projected to 2 surfaces""" edge = edges.Project(["terrain", "walls"]) self.assertEqual(edge.label, ["terrain", "walls"]) def test_project_create_multiple(self): """Create an edge, projected to more than 2 surfaces""" with self.assertRaises(EdgeCreationError): _ = edges.Project(["terrain", "walls", "sky"]) def test_add_same(self): """Add the same label to a project edge""" edge = edges.Project("terrain") edge.add_label("terrain") self.assertEqual(edge.label, ["terrain"]) def test_add_different_single(self): """Add a different label to a project edge""" edge = edges.Project("terrain") edge.add_label("walls") self.assertEqual(edge.label, ["terrain", "walls"]) def test_add_different_list(self): """Add a different list of labels to a project edge""" edge = edges.Project("terrain") edge.add_label(["walls"]) self.assertEqual(edge.label, ["terrain", "walls"]) def test_add_too_many(self): """Add too many labels to a project edge""" edge = edges.Project("terrain") with self.assertRaises(EdgeCreationError): edge.add_label(["walls", "sky"]) def test_default_transform(self): """Issue a warning error when transforming an edge with a default center""" edge = edges.Line() with self.assertWarns(Warning): edge.rotate(1, [0, 0, 1]) def test_default_repr(self): self.assertEqual(edges.Line().representation, "line") class CurveEdgeTests(unittest.TestCase): @property def points(self): return np.array( [ [0, 0, 0], [0.5, 0.5, 0], [1, 0.5, 0], [1.5, 0, 0], ] ) @property def curve(self) -> DiscreteCurve: return DiscreteCurve(self.points) def test_on_curve_translate(self): edge = edges.OnCurve(self.curve) edge.translate([0, 0, 1]) np.testing.assert_equal(edge.curve.discretize(), self.points + np.array([0, 0, 1])) def test_on_curve_rotate(self): edge = edges.OnCurve(self.curve) edge.rotate(np.pi / 2, [0, 0, 1], [0, 0, 0]) np.testing.assert_equal( edge.curve.discretize(), [f.rotate(p, np.pi / 2, [0, 0, 1], [0, 0, 0]) for p in self.points] ) def test_on_curve_rotate_nocenter(self): edge = edges.OnCurve(self.curve) edge.rotate(np.pi / 2, [0, 0, 1]) default_center = np.average(self.points, axis=0) np.testing.assert_equal( edge.curve.discretize(), [f.rotate(p, np.pi / 2, [0, 0, 1], default_center) for p in self.points] ) def test_spline_repr(self): edge = edges.Spline(self.points) self.assertEqual(edge.representation, "spline") def test_polyline_repr(self): edge = edges.PolyLine(self.points) self.assertEqual(edge.representation, "polyLine") def test_spline_center(self): edge = edges.Spline(self.points) np.testing.assert_equal(edge.center, np.average(self.points, axis=0)) ================================================ FILE: tests/test_construct/test_flat/__init__.py ================================================ ================================================ FILE: tests/test_construct/test_flat/test_annulus.py ================================================ import unittest import numpy as np from parameterized import parameterized from classy_blocks.base.exceptions import AnnulusCreationError from classy_blocks.construct.flat.sketches.annulus import Annulus class AnnulusTests(unittest.TestCase): """Critical stuff on Annuli""" def test_invalid_inner_radius(self): with self.assertRaises(AnnulusCreationError): Annulus([2, 2, 2], [3, 3, 2], [0, 0, 1], 2) # (3, 3) - (2, 2) = 1.414.., which is < 2 def test_coplanar_assert(self): """Assert the defining points produce a planar annulus or the 'center' will be calculated differently than it was defined""" with self.assertRaises(AnnulusCreationError): Annulus([2, 2, 2], [0, 0, 0], [0, 0, 1], 0.5, 5) @parameterized.expand(((3,), (4,), (5,), (6,), (7,), (8,), (9,), (16,))) def test_center(self, n_segments): """Test that center is calculated back exactly as it was defined""" center = [2.0, 2.0, 0.0] ann = Annulus(center, [3, 1, 0], [1, 1, 0], 0.5, n_segments) np.testing.assert_array_almost_equal(center, ann.center) ================================================ FILE: tests/test_construct/test_flat/test_disk.py ================================================ import unittest from unittest.mock import patch import numpy as np from parameterized import parameterized from classy_blocks.construct.edges import Origin from classy_blocks.construct.flat.sketches.disk import HalfDisk, OneCoreDisk, Oval, QuarterDisk, WrappedDisk from classy_blocks.construct.shape import ExtrudedShape from classy_blocks.construct.shapes.sphere import get_named_points from classy_blocks.mesh import Mesh from classy_blocks.util import functions as f from classy_blocks.util.constants import TOL class QuarterDiskTests(unittest.TestCase): @property def qdisk(self): return QuarterDisk([0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 0.0, 1.0]) def assert_coincident(self, qdisk: QuarterDisk): pairs = ( # (i, j, k): # core[0].faces[i], shell[j].faces[k] (1, 0, 0), # S1: core face's corner 1 and shell's faces[0]'s corner 0 (2, 0, 3), # D (2, 1, 0), # D (3, 1, 3), # S2 ) for data in pairs: core_point = qdisk.core[0].point_array[data[0]] shell_point = qdisk.shell[data[1]].point_array[data[2]] np.testing.assert_array_almost_equal(core_point, shell_point) def test_quarter_translate(self): """Check that the coincident points remain coincident after translate""" qcrc = self.qdisk.translate([1, 1, 1]) self.assert_coincident(qcrc) def test_quarter_rotate(self): """Check that the coincident points remain coincident after translate""" qcrc = self.qdisk.rotate(np.pi / 3, [0, 0, 1], [1, 1, 1]) self.assert_coincident(qcrc) def test_quarter_scale_origin(self): """Check that the coincident points remain coincident after translate""" qcrc = self.qdisk.translate([1, 1, 1]) qcrc.scale(0.5, [10, 10, 10]) self.assert_coincident(qcrc) def test_quarter_scale_origin_default(self): """Check that the coincident points remain coincident after translate""" qcrc = self.qdisk.translate([1, 1, 1]) qcrc.scale(0.5) self.assert_coincident(qcrc) def test_quarter_combined(self): """Check that the coincident points remain coincident after a combination of transforms""" qcrc = self.qdisk.translate([-1, 0, 0]) qcrc.rotate(np.pi / 3, [0, 0, 1], [1, 1, 1]) qcrc.scale(2) self.assert_coincident(qcrc) @parameterized.expand(((0,), (1,), (2,))) def test_face(self, i_face): """Check that quarter disk's faces are properly constructed""" # That is, each face has 4 different points points = self.qdisk.faces[i_face].point_array self.assertGreater(f.norm(points[1] - points[0]), TOL) self.assertGreater(f.norm(points[2] - points[1]), TOL) self.assertGreater(f.norm(points[3] - points[2]), TOL) self.assertGreater(f.norm(points[0] - points[3]), TOL) def test_points_keys(self): """Check positions of points in the @points property""" self.assertSetEqual({"O", "S1", "P1", "D", "P2", "S2", "P3"}, set(get_named_points(self.qdisk).keys())) @parameterized.expand( ( ("O", [0, 0, 0]), ("S1", [0.5, 0, 0]), ("P1", [1, 0, 0]), ("D", [2**0.5 / 4, 2**0.5 / 4, 0]), ("P2", [2**0.5 / 2, 2**0.5 / 2, 0]), ("P3", [0, 1, 0]), ("S2", [0, 0.5, 0]), ) ) @patch("classy_blocks.construct.flat.sketches.disk.DiskBase.core_ratio", new=0.5) @patch("classy_blocks.construct.flat.sketches.disk.DiskBase.diagonal_ratio", new=0.5) def test_point_position(self, key, position): """Check that the points are symmetrical with respect to diagonal""" qdisk = QuarterDisk([0, 0, 0], [1, 0, 0], [0, 0, 1]) points = get_named_points(qdisk) self.assertTrue(f.norm(points[key] - position) < TOL) class DisksTests(unittest.TestCase): def test_one_core_disk(self): disk = OneCoreDisk([0, 0, 0], [1, 0, 0], [0, 0, 1]) self.assertEqual(len(disk.grid[0]), 1) self.assertEqual(len(disk.grid[1]), 4) def test_one_core_disk_origo(self): disk = OneCoreDisk([0, 0, 0], [1, 0, 0], [0, 0, 1]) np.testing.assert_array_almost_equal(disk.origo, [0, 0, 0]) def test_one_core__disk_edges(self): disk = OneCoreDisk([0, 0, 0], [2, 0, 0], [0, 0, 1]) disk.translate([1, 1, 1]) edge = disk.faces[1].edges[1] if not isinstance(edge, Origin): raise AssertionError("Wrong edge type!") np.testing.assert_almost_equal(edge.origin.position, [1, 1, 1]) def test_half_disk(self): disk = HalfDisk([0, 0, 0], [1, 0, 0], [0, 0, 1]) self.assertEqual(len(disk.grid[0]), 2) self.assertEqual(len(disk.grid[1]), 4) def test_wrapped_disk(self): disk = WrappedDisk([0, 0, 0], [2, 0, 0], 1, [0, 0, 1]) self.assertEqual(len(disk.grid[0]), 1) self.assertEqual(len(disk.grid[1]), 4) self.assertEqual(len(disk.grid[2]), 4) def test_wrapped_disk_edges(self): disk = WrappedDisk([0, 0, 0], [2, 0, 0], 1, [0, 0, 1]) disk.translate([1, 1, 1]) edge = disk.faces[1].edges[1] if not isinstance(edge, Origin): raise AssertionError("Wrong edge type!") np.testing.assert_almost_equal(edge.origin.position, [1, 1, 1]) class OvalTests(unittest.TestCase): @property def oval(self) -> Oval: center_1 = [0, 0, 0] center_2 = [0, 1, 0] normal = [0, 0, 1] return Oval(center_1, center_2, normal, 0.5) def test_construct(self): self.assertEqual(len(self.oval.faces), 16) def test_planar(self): for face in self.oval.faces: for point in face.point_array: self.assertEqual(point[2], 0) def test_centers(self): oval = self.oval np.testing.assert_almost_equal(oval.center, (np.array(oval.center_1) + np.array(oval.center_2)) / 2) def test_grid(self): oval = self.oval self.assertEqual(len(oval.grid[0]), 6) self.assertEqual(len(oval.grid[1]), 10) class ChopTests(unittest.TestCase): def setUp(self): self.mesh = Mesh() def test_one_core_disk(self): sketch = OneCoreDisk([0, 0, 0], [1, 0, 0], [0, 0, 1]) extrude = ExtrudedShape(sketch, 1) extrude.chop(0, count=10) extrude.chop(1, count=5) extrude.chop(2, count=1) self.mesh.add(extrude) self.mesh.assemble() ================================================ FILE: tests/test_construct/test_flat/test_face.py ================================================ import unittest from typing import cast import numpy as np from classy_blocks.base.exceptions import FaceCreationError from classy_blocks.base.transforms import Rotation from classy_blocks.construct import edges from classy_blocks.construct.flat.face import Face from classy_blocks.util import functions as f class FaceTests(unittest.TestCase): def setUp(self): self.points = [[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [1.0, 1.0, 0.0], [0.0, 1.0, 0.0]] def test_face_too_few_points(self): """Raise an exception if less than 4 points is provided""" with self.assertRaises(FaceCreationError): Face(self.points[:3]) def test_face_invalid_points(self): """Raise an exception if given points does not have correct shape""" with self.assertRaises(FaceCreationError): Face([p[:2] for p in self.points]) def test_face_center(self): """The center property""" np.testing.assert_array_equal(Face(self.points).center, [0.5, 0.5, 0]) def test_reverse(self): """Reverse face points""" face = Face(self.points) face_points = np.copy(face.point_array) face.invert() np.testing.assert_array_equal(np.flip(face_points, axis=0), face.point_array) def test_translate_face(self): """Face translation, one custom edge""" face_edges = [ edges.Arc([0.5, -0.25, 0]), None, None, None, ] translate_vector = f.vector(1, 1, 1) original_face = Face(self.points, face_edges) translated_face = original_face.copy().translate(translate_vector) # check points for i in range(4): p1 = original_face.points[i].position p2 = translated_face.points[i].position np.testing.assert_almost_equal(p1, p2 - translate_vector) # check arc edge translated_arc = cast(edges.Arc, translated_face.edges[0]) orig_arc = cast(edges.Arc, original_face.edges[0]) np.testing.assert_almost_equal(translated_arc.point.position - translate_vector, orig_arc.point.position) def test_rotate_face_center(self): """Face rotation""" # only test that the Face.rotate function works properly; # other machinery (translate, transform...) are tested in # test_translate_face above origin = [0.5, 0.5, 0] angle = np.pi / 3 axis = np.array([1.0, 1.0, 1.0]) original_face = Face(self.points) rotated_face = original_face.copy().rotate(angle, axis) for i in range(4): original_point = original_face.points[i].position rotated_point = rotated_face.points[i].position np.testing.assert_almost_equal(rotated_point, f.rotate(original_point, angle, axis, origin)) def test_rotate_face_custom_origin(self): """Face rotation""" # only test that the Face.rotate function works properly; # other machinery (translate, transform...) are tested in # test_translate_face above origin = [2.0, 2.0, 2.0] angle = np.pi / 3 axis = np.array([1.0, 1.0, 1.0]) original_face = Face(self.points) rotated_face = original_face.copy().rotate(angle, axis, origin) for i in range(4): original_point = original_face.points[i].position rotated_point = rotated_face.points[i].position np.testing.assert_almost_equal(rotated_point, f.rotate(original_point, angle, axis, origin)) def test_rotate_default_origin(self): """Rotate a face using the default origin""" # Default origin will use rotation around center; # construct a face aroung center original_face = Face(self.points) original_face.translate(-original_face.center) rotated_face = original_face.copy().transform([Rotation([0, 0, 1], np.pi / 2)]) for i in range(4): np.testing.assert_almost_equal(original_face.points[i].position, rotated_face.points[(i + 3) % 4].position) def test_scale_face_center(self): """Scale face from its center""" face = Face(self.points) face.scale(2) scaled_points = [[-0.5, -0.5, 0], [1.5, -0.5, 0], [1.5, 1.5, 0], [-0.5, 1.5, 0]] np.testing.assert_array_almost_equal(face.point_array, scaled_points) def test_scale_face_custom_origin(self): """Scale face from custom origin""" face = Face(self.points).scale(2, [0, 0, 0]) scaled_points = np.array(self.points) * 2 np.testing.assert_array_almost_equal(face.point_array, scaled_points) def test_scale_face_edges(self): """Scale face with one custom edge and check its new data""" face = Face(self.points, [edges.Arc([0.5, -0.25, 0]), None, None, None]).scale(2, origin=[0, 0, 0]) arc = cast(edges.Arc, face.edges[0]) np.testing.assert_array_equal(arc.point.position, [1, -0.5, 0]) def test_add_edge(self): """Replace a Line edge with something else""" face = Face(self.points) face.add_edge(0, edges.Project("terrain")) self.assertEqual(face.edges[0].kind, "project") with self.assertRaises(FaceCreationError): face.add_edge(4, edges.Project("terrain")) def test_replace_edge(self): face = Face(self.points) face.add_edge(0, edges.Project("terrain")) face.add_edge(0, None) self.assertEqual(face.edges[0].kind, "line") def test_reorient_indexes(self): face = Face(self.points) face.reorient([2, 2, 0]) orig_points = np.array(self.points) new_points = np.array([point.position for point in face.points]) np.testing.assert_array_equal(np.roll(orig_points, 2, axis=0), new_points) def test_reorient_normal(self): face = Face(self.points) orig_normal = face.normal face.reorient([2, 2, 0]) new_normal = face.normal np.testing.assert_array_equal(orig_normal, new_normal) def test_update(self): face = Face(self.points) new_points = np.array(self.points) + f.vector(1, 1, 1) face.update(new_points) np.testing.assert_array_equal(face.point_array, new_points) def test_wrong_edge_count(self): with self.assertRaises(FaceCreationError): _ = Face(self.points, [edges.Arc([1, 1, 1]), None, None, None, None]) def test_check_coplanar_raise(self): points = self.points points[-1] = [1.0, 1.0, 1.0] with self.assertRaises(FaceCreationError): _ = Face(self.points, check_coplanar=True) def test_remove_edge(self): face = Face(self.points) face.add_edge(0, edges.Project("test1")) face.add_edge(1, edges.Project("test2")) face.add_edge(2, edges.Project("test3")) face.remove_edges([1]) self.assertIsInstance(face.edges[1], edges.Line) def test_remove_edges(self): face = Face(self.points) face.add_edge(0, edges.Project("test1")) face.add_edge(1, edges.Project("test2")) face.add_edge(2, edges.Project("test3")) face.remove_edges() for i in range(4): self.assertIsInstance(face.edges[i], edges.Line) def test_shear(self): face = Face(self.points) face.shear([0, 1, 0], [0, 0, 0], [1, 0, 0], np.pi / 4) np.testing.assert_almost_equal(face.point_array, [[0, 0, 0], [1, 0, 0], [2, 1, 0], [1, 1, 0]]) ================================================ FILE: tests/test_construct/test_flat/test_sketch.py ================================================ import unittest import numpy as np from classy_blocks.construct.flat.sketches.grid import Grid from classy_blocks.construct.flat.sketches.mapped import MappedSketch class MappedSketchTests(unittest.TestCase): @property def positions(self): return [ [0, 0, 0], [1, 0, 0], [2, 0, 0], [0, 1, 0], [1.5, 1.5, 0], # a moved vertex [2, 1, 0], [0, 2, 0], [1, 2, 0], [2, 2, 0], ] @property def quads(self): return [ [0, 1, 4, 3], [1, 2, 5, 4], [3, 4, 7, 6], [4, 5, 8, 7], ] @property def sketch(self): return MappedSketch(self.positions, self.quads) def test_faces(self): self.assertEqual(len(self.sketch.faces), 4) def test_grid(self): self.assertEqual(len(self.sketch.grid), 1) def test_update(self): sketch = MappedSketch(self.positions, self.quads) updated_positions = self.positions updated_positions[0] = [0.1, 0.1, 0.1] sketch.update(updated_positions) np.testing.assert_equal(sketch.faces[0].point_array[0], [0.1, 0.1, 0.1]) def test_merge(self): sketch_1_pos = self.positions[:6] sketch_2_pos = self.positions[3:] sketch_2_quad = (np.asarray(self.quads[2:]) - 3).tolist() sketch_1 = MappedSketch(sketch_1_pos, self.quads[:2]) sketch_2 = MappedSketch(sketch_2_pos, sketch_2_quad) sketch_1.merge(sketch_2) np.testing.assert_equal( np.asarray([face.point_array for face in sketch_1.faces]), np.asarray([face.point_array for face in self.sketch.faces]), ) self.assertEqual(sketch_1.indexes, self.sketch.indexes) class GridSketchTests(unittest.TestCase): def test_construct(self): grid = Grid([0, 0, 0], [1, 1, 0], 3, 3) self.assertEqual(len(grid.faces), 9) def test_center(self): grid = Grid([0, 0, 0], [1, 1, 0], 3, 3) np.testing.assert_almost_equal(grid.center, [0.5, 0.5, 0]) ================================================ FILE: tests/test_construct/test_operation/__init__.py ================================================ ================================================ FILE: tests/test_construct/test_operation/test_box.py ================================================ import unittest import numpy as np from parameterized import parameterized from classy_blocks.construct.operations.box import Box class BoxTests(unittest.TestCase): """Creation of boxes from all 8 diagonals""" @parameterized.expand( ( ([1, 1, 1],), ([-1, 1, 1],), ([-1, -1, 1],), ([1, -1, 1],), ([1, 1, -1],), ([-1, 1, -1],), ([-1, -1, -1],), ([1, -1, -1],), ) ) def test_create_box(self, diagonal_point): """Create a box from an arbitrary set of diagonal points""" box = Box([0.0, 0.0, 0.0], diagonal_point) # the diagonal must be the same in all cases np.testing.assert_array_almost_equal(box.point_array[6] - box.point_array[0], [1, 1, 1]) ================================================ FILE: tests/test_construct/test_operation/test_connector.py ================================================ import unittest import numpy as np from classy_blocks.construct.operations.box import Box from classy_blocks.construct.operations.connector import Connector class ConnectorTests(unittest.TestCase): def setUp(self): # basic box, center at origin self.box_1 = Box([-0.5, -0.5, -0.5], [0.5, 0.5, 0.5]) # a 'nicely' positioned box self.box_2 = self.box_1.copy().rotate(np.pi / 2, [0, 0, 1], [0, 0, 0]).translate([2, 0, 0]) # an ugly box a.k.a. border case # self.box_3 def test_create_normal(self): _ = Connector(self.box_1, self.box_2) def test_create_inverted(self): _ = Connector(self.box_2, self.box_1) def test_direction(self): connector = Connector(self.box_1, self.box_2) box_vector = self.box_2.center - self.box_1.center connector_vector = connector.top_face.center - connector.bottom_face.center self.assertGreater(np.dot(box_vector, connector_vector), 0) def test_direction_inverted(self): connector = Connector(self.box_2, self.box_1) box_vector = self.box_2.center - self.box_1.center connector_vector = connector.top_face.center - connector.bottom_face.center self.assertLess(np.dot(box_vector, connector_vector), 0) ================================================ FILE: tests/test_construct/test_operation/test_extrude.py ================================================ import unittest import numpy as np from classy_blocks.construct.edges import Arc from classy_blocks.construct.flat.face import Face from classy_blocks.construct.operations.extrude import Extrude from classy_blocks.util import functions as f class ExtrudeTests(unittest.TestCase): def setUp(self): self.points = [[0, 0, 0], [1, 0, 0], [1, 1, 0], [0, 1, 0]] self.edges = [Arc([0.5, 0.1, 0]), None, None, None] self.amount = [0, 0, 1] @property def face(self) -> Face: return Face(self.points, self.edges) @property def extrude(self) -> Extrude: return Extrude(self.face, self.amount) def test_extrude_box(self): """Create an Extrude""" ext = self.extrude for i in range(4): np.testing.assert_array_almost_equal( ext.top_face.points[i].position - ext.bottom_face.points[i].position, self.amount ) def test_extrude_slanted(self): """Extrude with a different vector""" self.amount = [1, 1, 1] ext = self.extrude for i in range(4): np.testing.assert_array_almost_equal( ext.top_face.points[i].position - ext.bottom_face.points[i].position, self.amount ) def test_extrude_edges(self): """Test that extrude copies edges""" n_arc = 0 for data in self.extrude.edges.get_all_beams(): if data is not None: edge = data[2] if edge.kind == "arc": n_arc += 1 self.assertEqual(n_arc, 2) def test_extrude_amount(self): """Extrude by amount""" self.points = np.asarray(self.points) / 2 self.amount = 0.1 ext = self.extrude for i in range(4): self.assertAlmostEqual( f.norm(ext.top_face.points[i].position - ext.bottom_face.points[i].position), self.amount ) ================================================ FILE: tests/test_construct/test_operation/test_operation.py ================================================ import unittest import numpy as np from parameterized import parameterized from classy_blocks.base.exceptions import EdgeCreationError from classy_blocks.base.transforms import Mirror from classy_blocks.construct.edges import Arc, Project, Spline from classy_blocks.construct.flat.face import Face from classy_blocks.construct.operations.extrude import Extrude from classy_blocks.construct.operations.loft import Loft from classy_blocks.util import functions as f from tests.fixtures.block import BlockTestCase class OperationTests(BlockTestCase): """Stuff on Operation""" def setUp(self): self.loft = self.make_loft(0) def test_add_side_edge_invalid_corner_index(self): """Fail if the user supplies an inappropriate corner to add_side_edge()""" with self.assertRaises(EdgeCreationError): self.loft.add_side_edge(4, Arc([0, 1, 0])) def test_set_patch_single(self): """Set patch of a single side""" self.loft.set_patch("left", "terrain") self.assertEqual(self.loft.patch_names["left"], "terrain") def test_set_patch_multiple(self): """Set patch of multiple sides""" self.loft.set_patch(["left", "bottom", "top"], "terrain") self.assertEqual(self.loft.patch_names["left"], "terrain") self.assertEqual(self.loft.patch_names["bottom"], "terrain") self.assertEqual(self.loft.patch_names["top"], "terrain") @parameterized.expand( ( (0, 4, Arc), (0, 1, Project), (1, 2, Project), (2, 3, Project), (3, 0, Project), ) ) def test_edges(self, corner_1, corner_2, edge_data_class): """An ad-hoc Frame object with edges""" self.loft.project_side("bottom", "terrain", edges=True) self.loft.add_side_edge(0, Arc([0.1, 0.1, 0.5])) edges = self.loft.edges self.assertIsInstance(edges[corner_1][corner_2], edge_data_class) @parameterized.expand(("bottom", "top", "left", "right", "front", "back")) def test_faces(self, side): """A dict of fresh faces""" self.loft.get_face(side) def test_patch_from_corner_empty(self): """No patches defined at any corner""" self.assertSetEqual(self.loft.get_patches_at_corner(0), set()) def test_patch_from_corner_single(self): """A single Patch at a specified corner""" self.loft.set_patch("bottom", "terrain") for i in (0, 1, 2, 3): self.assertSetEqual(self.loft.get_patches_at_corner(i), {"terrain"}) def test_patch_from_corner_multiple(self): """Multiple patches from faces on this corner""" self.loft.set_patch("bottom", "terrain") self.loft.set_patch("front", "wall") self.loft.set_patch("left", "atmosphere") self.assertSetEqual(self.loft.get_patches_at_corner(0), {"terrain", "wall", "atmosphere"}) self.assertSetEqual(self.loft.get_patches_at_corner(1), {"terrain", "wall"}) def test_chop(self): """Chop and check""" self.loft.chop(0, count=10) self.assertEqual(len(self.loft.chops[0]), 1) def test_unchop_single(self): """Chop, unchop and check""" self.loft.chop(0, count=10) self.loft.unchop(0) self.assertEqual(len(self.loft.chops[0]), 0) def test_unchop_all(self): """Chop, unchop and check""" self.loft.chop(0, count=10) self.loft.chop(1, count=10) self.loft.unchop() self.assertEqual(len(self.loft.chops[0]), 0) self.assertEqual(len(self.loft.chops[1]), 0) def test_set_cell_zone(self): """Make sure the set_cell_zone method exists""" self.loft.set_cell_zone("test") self.assertEqual(self.loft.cell_zone, "test") def test_center(self): """Center of the operation""" np.testing.assert_array_almost_equal(self.loft.center, [0.5, 0.5, 0.5]) @parameterized.expand(("bottom", "top", "front", "back", "left", "right")) def test_set_patch_name(self, side): """Set patch names and retrieve it in operation.patch_names""" self.loft.set_patch(side, "test") self.assertEqual(self.loft.patch_names[side], "test") @parameterized.expand( ( ([0, 0, 10], "top"), ([0, 0, -10], "bottom"), ([-10, 0, 0], "left"), ([10, 0, 0], "right"), ([0, -10, 0], "front"), ([0, 10, 0], "back"), ) ) def test_get_closest_side(self, point, orient): self.assertEqual(self.loft.get_closest_side(point), orient) @parameterized.expand( ( ([0, 0, 10],), ([0, 0, -10],), ([-10, 0, 0],), ([10, 0, 0],), ([0, -10, 0],), ([0, 10, 0],), ) ) def test_get_closest_face(self, point): face = self.loft.get_face(self.loft.get_closest_side(point)) np.testing.assert_array_equal(face.point_array, self.loft.get_closest_face(point).point_array) @parameterized.expand( ( ([0, 0, 10], "top"), ([0, 0, -10], "bottom"), ([-10, 0, 0], "left"), ([10, 0, 0], "right"), ([0, -10, 0], "front"), ([0, 10, 0], "back"), ) ) def test_get_normal_face(self, point, orient): normal_face = self.loft.get_normal_face(point) np.testing.assert_array_equal(normal_face.center, self.loft.get_face(orient).center) def test_from_series_error(self): """Raise an error when creating from a single face""" with self.assertRaises(ValueError): Loft.from_series([self.loft.top_face]) def test_from_series_2(self): loft_1 = self.loft loft_2 = Loft.from_series([loft_1.bottom_face, loft_1.top_face]) np.testing.assert_equal(loft_1.center, loft_2.center) def test_from_series_3(self): loft = self.loft faces = [loft.bottom_face, loft.bottom_face.copy().translate([0, 0, 0.5]), loft.top_face] new_loft = Loft.from_series(faces) for i in range(4): self.assertIsInstance(new_loft.side_edges[i], Arc) def test_from_series_4(self): loft = self.loft faces = [ loft.bottom_face, loft.bottom_face.copy().translate([0, 0, 0.33]), loft.bottom_face.copy().translate([0, 0, 0.66]), loft.top_face, ] new_loft = Loft.from_series(faces) for i in range(4): self.assertIsInstance(new_loft.side_edges[i], Spline) class OperationProjectionTests(BlockTestCase): """Operation: projections""" def setUp(self): self.loft = self.make_loft(0) def count_edges(self, kind: str) -> int: """Returns the number of non-Line edges in self.loft""" n_edges = 0 for beam in self.loft.edges.get_all_beams(): edge_data = beam[2] if edge_data.kind == kind: n_edges += 1 return n_edges def test_project_side_top(self): """Project a top face, no edges, no points""" self.loft.project_side("top", "terrain") self.assertEqual(self.loft.top_face.projected_to, "terrain") def test_project_side_bottom(self): """Project a bottom face, no edges, no points""" self.loft.project_side("bottom", "terrain") self.assertEqual(self.loft.bottom_face.projected_to, "terrain") @parameterized.expand(("front", "right", "back", "left")) def test_project_sides(self, side): """Project sides (without top and bottom), no points, no edges""" self.loft.project_side(side, "terrain") index = self.loft.get_index_from_side(side) self.assertEqual(self.loft.side_projects[index], "terrain") def test_no_projected_edges(self): """Make sure there are no other than Line edges in a plain operation""" self.assertEqual(self.count_edges("line"), 12) @parameterized.expand(("top", "bottom", "left", "right", "front", "back")) def test_project_sides_edges(self, side): """When projecting a side with edges=true, 4 Projected edges must be created""" self.loft.project_side(side, "terrain", edges=True) self.assertEqual(self.count_edges("project"), 4) @parameterized.expand(("top", "bottom", "left", "right", "front", "back")) def test_project_sides_points(self, side): """Project 4 points when projecting sides with points=True""" self.loft.project_side(side, "terrain", points=True) n_projected = 0 for point in self.loft.top_face.points + self.loft.bottom_face.points: if point.projected_to == ["terrain"]: n_projected += 1 self.assertEqual(n_projected, 4) def test_project_side(self): """Project side without edges""" self.loft.project_side("bottom", "terrain", edges=False) self.assertEqual(self.loft.bottom_face.projected_to, "terrain") def test_project_side_edges(self): """Project side with edges""" self.loft.project_side("bottom", "terrain", edges=True) self.assertEqual(self.loft.bottom_face.projected_to, "terrain") for edge in self.loft.bottom_face.edges: self.assertTrue(isinstance(edge, Project)) def test_project_two_sides_bottom(self): """Project two sides with a common edge to two different geometries""" self.loft.project_side("bottom", "terrain", edges=True) self.loft.project_side("front", "walls", edges=True) self.assertListEqual(self.loft.bottom_face.edges[0].label, ["terrain", "walls"]) for edge in self.loft.bottom_face.edges: self.assertTrue(isinstance(edge, Project)) def test_project_two_sides_top(self): """Project two sides with a common edge to two different geometries""" self.loft.project_side("front", "terrain", edges=True) self.loft.project_side("top", "walls", edges=True) self.assertListEqual(self.loft.top_face.edges[0].label, ["terrain", "walls"]) for edge in self.loft.top_face.edges: self.assertTrue(isinstance(edge, Project)) def test_project_corner_top(self): """Project a vertex""" self.loft.project_corner(0, "terrain") self.assertEqual(self.loft.bottom_face.points[0].projected_to, ["terrain"]) def test_project_corner_bottom(self): """Project a vertex""" self.loft.project_corner(4, "terrain") self.assertEqual(self.loft.top_face.points[0].projected_to, ["terrain"]) @parameterized.expand( ( (0, 1), # 1 (1, 2), # 2 (2, 3), # 3 (3, 0), # 4 (4, 5), # 5 (5, 6), # 6 (6, 7), # 7 (7, 4), # 8 (0, 4), # 9 (1, 5), # 10 (2, 6), # 11 (3, 7), # 12 (1, 0), # 13 (2, 1), # 14 (3, 2), # 15 (0, 3), # 16 (5, 4), # 17 (6, 5), # 18 (7, 6), # 19 (4, 7), # 20 (4, 0), # 21 (5, 1), # 22 (6, 2), # 23 (7, 3), # 24 ) ) def test_project_edge(self, corner_1, corner_2): """Find the same edge in the frame as projected pair""" self.loft.project_edge(corner_1, corner_2, "test") # find the edge in the frame and check if the corners are appropriate found = False for beam in self.loft.edges.get_all_beams(): data = beam[2] if data.kind == "project": self.assertIn(beam[0], {corner_1, corner_2}) self.assertIn(beam[1], {corner_1, corner_2}) self.assertEqual(data.label, ["test"]) found = True break self.assertTrue(found, f"Edge between {corner_1} and {corner_2} not found!") def test_project_edge_twice(self): """Project the same edge with two different geometries""" self.loft.project_edge(0, 1, "terrain") self.loft.project_edge(0, 1, "walls") self.assertListEqual(self.loft.bottom_face.edges[0].label, ["terrain", "walls"]) @parameterized.expand( ( ("terrain", "walls", ["terrain", "walls"]), ("terrain", "terrain", ["terrain"]), ("terrain", ["terrain", "walls"], ["terrain", "walls"]), (["terrain"], ["terrain"], ["terrain"]), (["terrain"], ["walls"], ["terrain", "walls"]), ) ) def test_project_edge_multiple(self, first, second, result): """Project the same edge with two equal geometries""" self.loft.project_edge(0, 1, first) self.loft.project_edge(0, 1, second) self.assertListEqual(self.loft.bottom_face.edges[0].label, result) class OperationTransformTests(unittest.TestCase): """Loft inherits directly from Operation with no additional whatchamacallit; Loft tests therefore validate Operation""" def setUp(self): bottom_points = [ [0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [1.0, 1.0, 0.0], [0.0, 1.0, 0.0], ] bottom_edges = [Arc([0.5, -0.25, 0]), None, None, None] bottom_face = Face(bottom_points, bottom_edges) top_face = bottom_face.copy().translate([0, 0, 1]).rotate(np.pi / 4, [0, 0, 1], [0, 0, 0]) # create a mid face to take points from mid_face = bottom_face.copy().translate([0, 0, 0.5]).rotate(np.pi / 3, [0, 0, 1], [0, 0, 0]) self.loft = Loft(bottom_face, top_face) for i, point in enumerate(mid_face.point_array): self.loft.add_side_edge(i, Arc(point)) def test_construct(self): """Create a Loft object""" _ = self.loft def test_translate(self): translate_vector = np.array([0, 0, 1]) original_op = self.loft translated_op = self.loft.copy().translate(translate_vector) np.testing.assert_almost_equal( original_op.bottom_face.point_array + translate_vector, translated_op.bottom_face.point_array ) np.testing.assert_almost_equal( original_op.edges[0][4].point.position + translate_vector, translated_op.edges[0][4].point.position, ) def test_rotate(self): axis = [0.0, 1.0, 0.0] origin = [0.0, 0.0, 0.0] angle = np.pi / 2 original_op = self.loft rotated_op = self.loft.copy().rotate(angle, axis, origin) def extrude_direction(op): return f.unit_vector(op.top_face.point_array[0] - op.bottom_face.point_array[0]) np.testing.assert_almost_equal( f.angle_between(extrude_direction(original_op), extrude_direction(rotated_op)), angle ) def test_rotate_default_origin(self): axis = [0.0, 1.0, 0.0] angle = np.pi / 2 original_op = self.loft rotated_op = self.loft.copy().rotate(angle, axis) def extrude_direction(op): return f.unit_vector(op.top_face.point_array[0] - op.bottom_face.point_array[0]) np.testing.assert_almost_equal( f.angle_between(extrude_direction(original_op), extrude_direction(rotated_op)), angle ) def test_mirror_bottom(self): original_loft = self.loft mirrored_loft = self.loft.copy().mirror([0, 0, 1]) for i in range(4): # bottom faces are coincident np.testing.assert_almost_equal( original_loft.bottom_face.point_array[i], mirrored_loft.top_face.point_array[i] ) def test_mirror_top(self): original_loft = self.loft mirrored_loft = self.loft.copy().mirror([0, 0, 1]) for i in range(4): # top and bottom faces are exactly 2 units apart orig_pos = original_loft.top_face.point_array[i] mirrored_pos = mirrored_loft.bottom_face.point_array[i] np.testing.assert_almost_equal(f.norm(orig_pos - mirrored_pos), 2) def test_index_from_side(self): with self.assertRaises(RuntimeError): _ = self.loft.get_index_from_side("top") def test_mirror(self): extrude = Extrude(self.loft.bottom_face, [0, 0, 1]) mirror = extrude.copy().mirror([0, 0, 1], [0, 0, 0]) np.testing.assert_equal(extrude.bottom_face.center, mirror.top_face.center) def test_mirror_transform(self): extrude = Extrude(self.loft.bottom_face, [0, 0, 1]) with self.assertWarns(Warning): extrude.copy().transform([Mirror([0, 0, 1], [0, 0, 0])]) def test_mirror_transform_no_origin(self): extrude = Extrude(self.loft.bottom_face, [0, 0, 1]) mirror = extrude.copy().transform([Mirror([0, 0, 1])]).invert() np.testing.assert_equal(extrude.bottom_face.center, mirror.top_face.center) ================================================ FILE: tests/test_construct/test_operation/test_wedge.py ================================================ import unittest from classy_blocks.construct.flat.face import Face from classy_blocks.construct.operations.wedge import Wedge class WedgeTests(unittest.TestCase): def setUp(self): self.points = [[0, 1, 0], [1, 1, 0], [1, 2, 0], [0, 2, 0]] @property def base(self) -> Face: return Face(self.points) @property def wedge(self) -> Wedge: return Wedge(self.base) def test_wedge_construction_mirror(self): """Check that bottom and top faces are mirrored around XY-plane""" wedge = self.wedge for i in range(4): self.assertAlmostEqual(wedge.bottom_face.points[i].position[2], -wedge.top_face.points[i].position[2]) def test_wedge_construction_revolve(self): """Check that y-values of face points are the same""" wedge = self.wedge for i in range(4): self.assertAlmostEqual(wedge.bottom_face.points[i].position[1], wedge.top_face.points[i].position[1]) def test_inner_patch(self): wedge = self.wedge wedge.set_inner_patch("test") self.assertEqual(wedge.patch_names["front"], "test") def test_outer_patch(self): wedge = self.wedge wedge.set_outer_patch("test") self.assertEqual(wedge.patch_names["back"], "test") def test_chop(self): """Automatically chop to count=1 in revolved direction""" self.assertEqual(self.wedge.chops[2][0].count, 1) ================================================ FILE: tests/test_construct/test_point.py ================================================ import unittest import numpy as np from parameterized import parameterized from classy_blocks.base.exceptions import PointCreationError from classy_blocks.construct.point import Point from classy_blocks.util.constants import TOL class PointTests(unittest.TestCase): """Border cases for Point object""" @property def point(self) -> Point: """Test subject""" return Point([1, 1, 1]) @parameterized.expand( [ ((1, 1),), # To few arguments! ((1, 1, 1, 1),), # To much arguments!" ] ) def test_invalid_creation_parameters(self, position): with self.assertRaises(PointCreationError): Point(list(position)) def test_default_rotate_origin(self): """Rotation without an origin""" np.testing.assert_array_almost_equal(self.point.rotate(np.pi, [0, 0, 1]).position, [-1, -1, 1]) def test_default_scale_origin(self): """Rotation without an origin""" np.testing.assert_array_almost_equal(self.point.scale(2).position, [2, 2, 2]) def test_center(self): """Center property""" np.testing.assert_array_equal(self.point.center, self.point.position) def test_points_equal(self): """Points are equal when they are close enough""" delta = TOL / 10 other = Point([1 + delta, 1 + delta, 1 + delta]) self.assertTrue(self.point == other) def test_points_not_equal(self): """Points are not equal when they are more than TOL apart""" delta = 2 * TOL other = Point([1 + delta, 1 + delta, 1 + delta]) self.assertFalse(self.point == other) def test_project_double(self): point = self.point point.project("terrain") point.project("terrain") self.assertEqual(len(point.projected_to), 1) def test_project_twice(self): """Multiple calls to project() must add geometry to the projections list""" point = self.point point.project("terrain") point.project("also_terrain") self.assertListEqual(point.projected_to, ["also_terrain", "terrain"]) def test_project_twice_mixed(self): point = self.point point.project("terrain") point.project(["also_also_terrain", "also_terrain"]) self.assertListEqual(point.projected_to, ["also_also_terrain", "also_terrain", "terrain"]) def test_mirror_default_origin(self): point = self.point point.mirror([1, 1, 1]) np.testing.assert_almost_equal(point.position, [-1, -1, -1]) ================================================ FILE: tests/test_construct/test_shape.py ================================================ import unittest import numpy as np from classy_blocks.base.exceptions import CylinderCreationError, FrustumCreationError from classy_blocks.base.transforms import Mirror from classy_blocks.construct.edges import Line from classy_blocks.construct.flat.face import Face from classy_blocks.construct.flat.sketches.disk import Disk, OneCoreDisk from classy_blocks.construct.shape import ExtrudedShape, LoftedShape, RevolvedShape, ShapeCreationError from classy_blocks.construct.shapes.cylinder import Cylinder from classy_blocks.construct.shapes.elbow import Elbow from classy_blocks.construct.shapes.frustum import Frustum from classy_blocks.construct.shapes.rings import ExtrudedRing, RevolvedRing from classy_blocks.construct.shapes.sphere import EighthSphere, Hemisphere from classy_blocks.util import functions as f class ShapeTests(unittest.TestCase): """Common shape methods and properties""" def setUp(self): self.cylinder = Cylinder([0, 0, 0], [1, 0, 0], [0, 1, 0]) def test_translate(self): """Translate a cylinder (uses the 'parts' property)""" self.cylinder.translate([1, 0, 0]) np.testing.assert_array_equal(self.cylinder.sketch_1.center, [1, 0, 0]) def test_translate_sketches(self): """Translate the cylinder and see what happens to sketches""" cyl = self.cylinder.translate([1, 0, 0]) np.testing.assert_almost_equal(cyl.sketch_1.center, [1, 0, 0]) def test_cylinder_center(self): """Center of a cylinder""" np.testing.assert_almost_equal(self.cylinder.center, [0.5, 0, 0]) def test_set_start_patch(self): """Start patch on all operations""" self.cylinder.set_start_patch("test") for operation in self.cylinder.operations: self.assertEqual(operation.bottom_face.patch_name, "test") def test_set_end_patch(self): """Start patch on all operations""" self.cylinder.set_end_patch("test") for operation in self.cylinder.operations: self.assertEqual(operation.top_face.patch_name, "test") def test_outer_patch(self): """Start patch on all operations""" self.cylinder.set_outer_patch("test") for operation in self.cylinder.shell: self.assertEqual(operation.patch_names[self.cylinder.outer_patch], "test") def test_inner_patch_extruded(self): """Inner patch on an extruded ring""" ring = ExtrudedRing([0, 0, 0], [1, 0, 0], [0, 1, 0], 0.4) ring.set_inner_patch("test") for operation in ring.shell: self.assertEqual(operation.patch_names["left"], "test") def test_inner_patch_revolved(self): face = Face([[0, 0, 0], [1, 0, 0], [1, 1, 0], [0, 1, 0]]) face.translate([0, 1, 0]) ring = RevolvedRing([0, 0, 0], [1, 0, 0], face) ring.set_inner_patch("test") for operation in ring.shell: self.assertEqual(operation.patch_names["front"], "test") def test_start_patch_revolved(self): face = Face([[0, 0, 0], [1, 0, 0], [1, 1, 0], [0, 1, 0]]) face.translate([0, 1, 0]) ring = RevolvedRing([0, 0, 0], [1, 0, 0], face) ring.set_start_patch("test") for operation in ring.shell: self.assertEqual(operation.patch_names["left"], "test") def test_end_patch_revolved(self): face = Face([[0, 0, 0], [1, 0, 0], [1, 1, 0], [0, 1, 0]]) face.translate([0, 1, 0]) ring = RevolvedRing([0, 0, 0], [1, 0, 0], face) ring.set_end_patch("test") for operation in ring.shell: self.assertEqual(operation.patch_names["right"], "test") class ElbowTests(unittest.TestCase): """Tests of the Elbow shape""" def setUp(self): self.center_point_1 = f.vector(1.0, 1.0, 1.0) self.radius_point_1 = f.vector(2.0, 1.0, 1.0) self.normal_1 = f.vector(0, 1, 0) self.sweep_angle = -np.pi / 3 self.arc_center = f.vector(3.0, 1.0, 1.0) self.rotation_axis = f.vector(1.0, 1.0, 2.0) self.radius_2 = 0.4 @property def radius_1(self) -> float: """Start radius""" return f.norm(self.radius_point_1 - self.center_point_1) @property def elbow(self) -> Elbow: """The test subject""" return Elbow( self.center_point_1, self.radius_point_1, self.normal_1, self.sweep_angle, self.arc_center, self.rotation_axis, self.radius_2, ) def test_radius_1(self): """Radius of the start sketch""" self.assertAlmostEqual(self.elbow.sketch_1.radius, self.radius_1) def test_radius_2(self): """Radius of the end sketch""" self.assertAlmostEqual(self.elbow.sketch_2.radius, self.radius_2) def test_radius_mid_nonuniform(self): """Radius of the middle sketch""" # should be between 1 and 2 self.assertAlmostEqual(self.elbow.sketch_mid[0].radius, (self.radius_1 + self.radius_2) / 2) def test_radius_mid_uniform(self): """Radius of the middle sketch with a uniform cross-section""" # should be the same as 1 and 2 self.radius_2 = f.norm(self.center_point_1 - self.radius_point_1) self.assertAlmostEqual(self.elbow.sketch_mid[0].radius, self.radius_2) def test_sketch_positions(self): """Sketch positions after Elbow transforms""" elbow = self.elbow center_1 = elbow.sketch_1.center center_2 = f.rotate(center_1, self.sweep_angle, self.rotation_axis, self.arc_center) np.testing.assert_array_almost_equal(elbow.sketch_2.center, center_2) class RevolvedRingTests(unittest.TestCase): """RevolvedRing creation and manipulation""" def setUp(self): self.face = Face([[0, 1, 0], [1, 1, 0], [1, 2, 0], [0, 2, 0]]) self.axis_point_1 = [0, 0, 0] self.axis_point_2 = [1, 0, 0] self.n_segments = 8 self.ring = RevolvedRing(self.axis_point_1, self.axis_point_2, self.face, self.n_segments) def test_set_inner_patch(self): """Inner faces of the ring""" self.ring.set_inner_patch("inner") for operation in self.ring.operations: self.assertEqual(operation.patch_names["front"], "inner") def test_set_outer_patch(self): """Outer faces of the ring""" self.ring.set_outer_patch("outer") for operation in self.ring.operations: self.assertEqual(operation.patch_names["back"], "outer") def test_chop_ring_tangential(self): self.ring.chop_tangential(count=10) self.ring.chop_radial(count=1) self.ring.chop_axial(count=1) for operation in self.ring.operations: self.assertEqual(operation.chops[self.ring.tangential_axis][0].count, 10) def test_transform(self): ring_1 = self.ring ring_2 = ring_1.copy().translate([1, 0, 0]) np.testing.assert_almost_equal(ring_2.center - ring_1.center, [1, 0, 0]) class ExtrudedRingTests(unittest.TestCase): def test_transform(self): ring_1 = ExtrudedRing([0, 0, 0], [1, 0, 0], [0, 1, 0], 0.8) ring_2 = ring_1.copy().translate([1, 0, 0]) np.testing.assert_almost_equal(ring_2.center - ring_1.center, [1, 0, 0]) class SphereTests(unittest.TestCase): def test_sphere_radii(self): """Check that all radii, defined by outer points, are as specified""" center = f.vector(1, 1, 1) sphere = Hemisphere(center, [2, 2, 1], [0, 0, 1]) radius = 2**0.5 for loft in sphere.shell: for point in loft.get_face("right").point_array: self.assertAlmostEqual(f.norm(point - center), radius) def test_start_patch(self): sphere = Hemisphere([0, 0, 0], [1, 0, 0], [0, 0, 1]) sphere.set_start_patch("flat") n_patches = 0 for operation in sphere.operations: if len(operation.patch_names) > 0: n_patches += 1 self.assertEqual(n_patches, 12) def test_outer_patch(self): sphere = Hemisphere([0, 0, 0], [1, 0, 0], [0, 0, 1]) sphere.set_outer_patch("dome") for operation in sphere.grid[1]: self.assertEqual(operation.patch_names["right"], "dome") def test_core(self): sphere = EighthSphere([0, 0, 0], [1, 0, 0], [0, 0, 1]) self.assertEqual(len(sphere.core), 1) def test_center(self): sphere = EighthSphere([0, 0, 0], [1, 0, 0], [0, 0, 1]) np.testing.assert_equal(sphere.center, [0, 0, 0]) def test_geometry(self): sphere = Hemisphere([0, 0, 0], [1, 0, 0], [0, 0, 1]) geometry = sphere.geometry keys = list(geometry.keys()) self.assertEqual(sphere.geometry[keys[0]][-1], "radius 1.0") class FrustumTests(unittest.TestCase): def test_non_perpendicular_axis_radius(self): with self.assertRaises(FrustumCreationError): Frustum([0, 0, 0], [1, 1, 0], [0, 1, 0], 0.4) def test_curved_side(self): """Create a Frustum with curved side edges""" frustum = Frustum([0, 0, 0], [1, 0, 0], [0, 1, 0], 0.4, 0.333) for operation in frustum.shell: edges = operation.edges self.assertEqual(edges[1][5].kind, "arc") self.assertEqual(edges[2][6].kind, "arc") class CylinderTests(unittest.TestCase): def setUp(self): self.axis_point_1 = [0.0, 0.0, 0.0] self.axis_point_2 = [1.0, 0.0, 0.0] self.radius_point_1 = [0.0, 1.0, 0.0] self.cylinder = Cylinder(self.axis_point_1, self.axis_point_2, self.radius_point_1) def test_non_perpendicular_axis_radius(self): with self.assertRaises(CylinderCreationError): Cylinder([0, 0, 0], [1, 0, 0], [1, 0, 0]) def test_edges(self): """Bug check: check that all edges are translated equally""" for face in self.cylinder.sketch_2.shell: # in Disk, 2nd edge of shell's face is Origin self.assertEqual(face.edges[1].origin.position[0], 1) def test_core(self): """Make sure cylinder's core is represented correctly""" self.assertEqual(len(self.cylinder.core), 4) def test_chop_radial_start_size(self): """Radial chop and start_size corrections""" self.cylinder.chop_radial(start_size=0.1) self.assertNotEqual(self.cylinder.shell[0].chops[0][0].start_size, 0.1) def test_chop_radial_end_size(self): """Radial chop and end_size corrections""" self.cylinder.chop_radial(end_size=0.1) self.assertNotEqual(self.cylinder.shell[0].chops[0][0].end_size, 0.1) def test_mirror(self): cyl_1 = self.cylinder cyl_2 = self.cylinder.copy().mirror(-cyl_1.sketch_1.normal, cyl_1.sketch_1.center) np.testing.assert_almost_equal( cyl_2.sketch_2.center - cyl_2.sketch_1.center, cyl_1.sketch_1.center - cyl_1.sketch_2.center ) def _test_mirror_transform(self): cyl_1 = self.cylinder cyl_2 = self.cylinder.copy().transform([Mirror(-cyl_1.sketch_1.normal, cyl_1.sketch_1.center)]) np.testing.assert_almost_equal( cyl_2.sketch_2.center - cyl_2.sketch_1.center, cyl_1.sketch_1.center - cyl_1.sketch_2.center ) def test_remove_inner_edges(self): cylinder = self.cylinder cylinder.remove_inner_edges() for operation in cylinder.core: for edge in operation.bottom_face.edges: self.assertIsInstance(edge, Line) for edge in operation.top_face.edges: self.assertIsInstance(edge, Line) def test_symmetry_patches(self): with self.assertRaises(RuntimeError): self.cylinder.set_symmetry_patch("test") class LoftedShapeTests(unittest.TestCase): @property def sketch(self): return OneCoreDisk([0, 0, 1], [1, 0, 1], [0, 0, 1]) def test_end_sketch_exception(self): sketch_1 = self.sketch sketch_2 = Disk([0, 0, 1], [1, 0, 1], [0, 0, 1]) with self.assertRaises(ShapeCreationError): _ = LoftedShape(sketch_1, sketch_2) def test_test_mid_sketch_exception(self): sketch_1 = self.sketch sketch_mid = Disk([0, 0, 0.5], [1, 0, 0.5], [0, 0, 0.5]) sketch_2 = self.sketch.copy().translate([0, 0, 1]) with self.assertRaises(ShapeCreationError): _ = LoftedShape(sketch_1, sketch_2, sketch_mid=sketch_mid) def test_grid(self): shape = LoftedShape(self.sketch, self.sketch.copy().translate([0, 0, 1])) for i in (0, 1): self.assertEqual(len(shape.grid[i]), len(self.sketch.grid[i])) def test_chop_0(self): shape = LoftedShape(self.sketch, self.sketch.copy().translate([0, 0, 1])) shape.chop(0, start_size=0.1) for i in self.sketch.chops[0]: self.assertEqual(len(shape.operations[i].chops[0]), 1) def test_chop_2(self): shape = LoftedShape(self.sketch, self.sketch.copy().translate([0, 0, 1])) shape.chop(2, start_size=0.1) self.assertEqual(len(shape.operations[0].chops[2]), 1) def test_extruded_shape_scalar(self): shape = ExtrudedShape(self.sketch, 1) np.testing.assert_almost_equal(shape.sketch_2.center, shape.sketch_1.center + f.vector(0, 0, 1)) def test_extruded_shape_vector(self): shape = ExtrudedShape(self.sketch, [0, 0, 1]) np.testing.assert_almost_equal(shape.sketch_2.center, shape.sketch_1.center + f.vector(0, 0, 1)) def test_revolved_shape(self): shape = RevolvedShape(self.sketch, np.pi / 2, [0, 1, 0], [2, 0, 0]) np.testing.assert_almost_equal(shape.sketch_2.normal, [1, 0, 0]) ================================================ FILE: tests/test_construct/test_shell.py ================================================ import numpy as np from parameterized import parameterized from classy_blocks.base.exceptions import DisconnectedChopError, PointNotCoincidentError, SharedPointNotFoundError from classy_blocks.cbtyping import OrientType from classy_blocks.construct.flat.face import Face from classy_blocks.construct.operations.box import Box from classy_blocks.construct.operations.loft import Loft from classy_blocks.construct.shapes.shell import AwareFace, AwareFaceStore, SharedPoint, SharedPointStore, Shell from classy_blocks.util import functions as f from tests.fixtures.block import DataTestCase class ShellTestsBase(DataTestCase): def setUp(self): super().setUp() data = self.get_single_data(0) self.bottom_face = Face(data.points[:4]) self.top_face = Face(data.points[4:]) self.loft = Loft(self.bottom_face, self.top_face) def get_face(self, orient) -> Face: return self.loft.get_face(orient) def get_point(self, orient, index): return self.loft.get_face(orient).points[index] class SharedPointTests(ShellTestsBase): def get_shared_point(self, orient: OrientType, index: int): face = self.get_face(orient) point = face.points[index] sp = SharedPoint(point) sp.add(face, index) return sp def test_equal(self): face1 = self.bottom_face bp1 = SharedPoint(face1.points[1]) bp1.add(face1, 1) face2 = self.loft.get_face("right") bp2 = SharedPoint(face2.points[1]) bp2.add(face2, 1) self.assertEqual(bp1, bp2) def test_add(self): """Add a legit face/index to a BoundPoint""" point = self.get_point("bottom", 0) bp = SharedPoint(point) bp.add(self.bottom_face, 0) self.assertEqual(len(bp.faces), 1) def test_duplicated(self): """Do not add duplicate faces""" face = self.get_face("bottom") point = face.points[0] bp = SharedPoint(point) bp.add(face, 0) bp.add(face, 0) self.assertEqual(len(bp.faces), 1) def test_noncoincident(self): """Raise an exception when trying to add a bound point at a different location""" bp = SharedPoint(self.get_point("bottom", 0)) with self.assertRaises(PointNotCoincidentError): bp.add(self.bottom_face, 1) def test_normal_single(self): """Normal when a single face is present in a bound point""" point = self.get_point("bottom", 0) bp = SharedPoint(point) bp.add(self.bottom_face, 0) np.testing.assert_array_almost_equal(bp.normal, self.bottom_face.normal) def test_normal_multiple(self): """Normal with multiple faces present""" bp = SharedPoint(self.get_point("bottom", 1)) bp.add(self.bottom_face, 1) bp.add(self.get_face("right"), 1) np.testing.assert_array_almost_equal(bp.normal, f.unit_vector([1, 0, 1])) def test_is_single(self): sp = self.get_shared_point("bottom", 0) self.assertFalse(sp.is_shared) def test_is_shared(self): sp = self.get_shared_point("bottom", 0) sp.add(self.get_face("front"), 3) self.assertTrue(sp.is_shared) class SharedpointStoreTests(ShellTestsBase): def setUp(self): super().setUp() self.sps = SharedPointStore() def test_sps_find_by_point_success(self): point = self.get_point("bottom", 0) shp = SharedPoint(point) shp.add(self.bottom_face, 0) self.sps.shared_points = [shp] self.assertEqual(self.sps.find_by_point(point), shp) def test_sps_find_by_point_failure(self): point = self.get_point("bottom", 0) shp = SharedPoint(point) shp.add(self.bottom_face, 0) self.sps.shared_points = [shp] with self.assertRaises(SharedPointNotFoundError): self.sps.find_by_point(self.get_point("bottom", 1)) def test_sps_add_from_face_new(self): self.sps.add_from_face(self.get_face("bottom"), 0) self.assertEqual(len(self.sps.shared_points), 1) def test_sps_add_from_face_duplicate(self): """Return an existing object when adding the same bound point twice""" self.assertEqual( id(self.sps.add_from_face(self.get_face("bottom"), 0)), id(self.sps.add_from_face(self.get_face("bottom"), 0)), ) class AwareFaceTests(SharedPointTests): def get_aware_face(self, orient): face = self.get_face(orient) shared_points = [] for i, point in enumerate(face.points): shared_point = SharedPoint(point) shared_point.add(face, i) shared_points.append(shared_point) return AwareFace(face, shared_points) def test_get_offset_points(self): # A simple test on a flat face; normal direction is # tested in BoundPoint face = self.get_face("bottom") aware_face = self.get_aware_face("bottom") face_points = [p.position for p in face.translate([0, 0, 1]).points] bound_offset = aware_face.get_offset_points(1) np.testing.assert_array_almost_equal(face_points, bound_offset) def test_get_offset_face(self): face_offset = self.get_aware_face("bottom").get_offset_face(1) bound_offset = self.get_face("bottom").translate([0, 0, 1]) np.testing.assert_array_almost_equal( [p.position for p in face_offset.points], [p.position for p in bound_offset.points] ) def test_is_solitary(self): aware_face = self.get_aware_face("bottom") self.assertTrue(aware_face.is_solitary) def test_is_not_solitary(self): aware_face = self.get_aware_face("bottom") aware_face.shared_points[0].add(self.get_face("front"), 3) self.assertFalse(aware_face.is_solitary) class AwareFaceStoreTests(SharedPointTests): def get_aws(self, orients: list[OrientType]) -> AwareFaceStore: faces = [self.get_face(orient) for orient in orients] return AwareFaceStore(faces) def test_get_point_store_single(self): store = self.get_aws(["top"]) self.assertEqual(len(store.point_store.shared_points), 4) def test_get_point_store_double_separate(self): store = self.get_aws(["top", "bottom"]) self.assertEqual(len(store.point_store.shared_points), 8) def test_get_point_store_double_joined(self): store = self.get_aws(["top", "front"]) self.assertEqual(len(store.point_store.shared_points), 6) def test_get_point_store_cube(self): store = self.get_aws(["front", "back", "left", "right", "top", "bottom"]) self.assertEqual(len(store.point_store.shared_points), 8) @parameterized.expand( ( (["bottom"],), (["bottom", "top"],), (["bottom", "top", "front"],), (["bottom", "top", "left", "right", "front", "back"],), ) ) def test_get_aware_faces(self, orients): store = self.get_aws(orients) self.assertEqual(len(store.aware_faces), len(orients)) class ShellTests(ShellTestsBase): def get_shell_faces(self, orients: list[OrientType]): box = Box([0, 0, 0], [1, 1, 1]) faces = [] for orient in orients: face = box.get_face(orient) if orient in ("front", "top", "left"): face.invert() faces.append(face) return faces def get_shell(self, orients: list[OrientType]): return Shell(self.get_shell_faces(orients), 0.5) @parameterized.expand( ( (["top"],), (["bottom", "top"],), (["bottom", "top", "left", "right", "front", "back"],), ) ) def test_operations_count(self, orients): self.assertEqual(len(self.get_shell(orients).operations), len(orients)) def test_chop(self): shell = self.get_shell(["left", "top"]) shell.chop(count=10) self.assertEqual(len(shell.operations[0].chops[2]), 1) def test_set_outer_patch(self) -> None: orients: list[OrientType] = ["front", "right"] shell = self.get_shell(orients) shell.set_outer_patch("roof") for operation in shell.operations: self.assertEqual(operation.patch_names["top"], "roof") def test_chop_disconnected(self): shell = self.get_shell(["bottom", "top"]) with self.assertRaises(DisconnectedChopError): shell.chop(coun=10) def test_grid(self): shell = self.get_shell(["front", "right", "left"]) self.assertListEqual(shell.operations, shell.grid[0]) ================================================ FILE: tests/test_construct/test_stack.py ================================================ import unittest import numpy as np from parameterized import parameterized import classy_blocks as cb class StackTests(unittest.TestCase): @property def round_base(self) -> cb.OneCoreDisk: return cb.OneCoreDisk([0, 0, 0], [1, 0, 0], [0, 0, 1]) @property def square_base(self) -> cb.Grid: return cb.Grid([0, 0, 0], [1, 1, 0], 2, 5) def test_construct_extruded_vector(self): stack = cb.ExtrudedStack(self.round_base, [0, 0, 1], 4) self.assertEqual(len(stack.grid), 4) self.assertEqual(stack.shapes[-1].operations[0].top_face.center[2], 1) def test_construct_extruded_amount(self): stack = cb.ExtrudedStack(self.round_base, 1, 4) self.assertEqual(len(stack.grid), 4) self.assertEqual(stack.shapes[-1].operations[0].top_face.center[2], 1) def test_construct_revolved(self): _ = cb.RevolvedStack(self.round_base, np.pi / 6, [0, 1, 0], [2, 0, 0], 4) def test_construct_transformed(self): _ = cb.TransformedStack( self.round_base, [cb.Translation([0, 0, 1]), cb.Rotation([0, 0, 1], np.pi / 6, [0, 0, 0])], 4, [cb.Translation([0, 0, 0.5]), cb.Rotation([0, 0, 1], np.pi / 12, [0, 0, 0])], ) def test_chop(self): stack = cb.ExtrudedStack(self.round_base, 1, 4) stack.chop(count=10) for shape in stack.shapes: self.assertEqual(len(shape.grid[0][0].chops[2]), 1) def test_center(self): stack = cb.ExtrudedStack(self.round_base, 1, 4) np.testing.assert_almost_equal(stack.center, [0, 0, 0.5]) def test_stack_transform(self): stack = cb.ExtrudedStack(self.round_base, 1, 4) stack.translate([0, 0, 1]) np.testing.assert_almost_equal(stack.center, [0, 0, 1.5]) def test_grid_square_axis0(self): stack = cb.ExtrudedStack(self.square_base, 1, 3) self.assertEqual(len(stack.grid), 3) def test_grid_square_axis1(self): stack = cb.ExtrudedStack(self.square_base, 1, 3) self.assertEqual(len(stack.grid[0]), 5) def test_grid_square_axis2(self): stack = cb.ExtrudedStack(self.square_base, 1, 5) self.assertEqual(len(stack.grid[0][0]), 2) def test_grid_round_axis0(self): stack = cb.ExtrudedStack(self.round_base, 1, 3) self.assertEqual(len(stack.grid), 3) def test_grid_round_axis1(self): stack = cb.ExtrudedStack(self.round_base, 1, 3) self.assertEqual(len(stack.grid[0]), 2) def test_grid_round_axis2(self): stack = cb.ExtrudedStack(self.round_base, 1, 3) self.assertEqual(len(stack.grid[0][0]), 1) # core self.assertEqual(len(stack.grid[0][1]), 4) # shell @parameterized.expand( ( # x-axis (0, 0, 15), (0, 1, 15), # y-axis (1, 0, 6), (1, 1, 6), (1, 2, 6), (1, 3, 6), (1, 4, 6), # z-axis: stacked shapes (2, 0, 10), (2, 1, 10), (2, 2, 10), ) ) def test_get_slice_square(self, axis, index, count): stack = cb.ExtrudedStack(self.square_base, 1, 3) self.assertEqual(len(stack.get_slice(axis, index)), count) ================================================ FILE: tests/test_grading/__init__.py ================================================ ================================================ FILE: tests/test_grading/test_catalogue.py ================================================ from classy_blocks.base.exceptions import BlockNotFoundError, NoInstructionError from classy_blocks.construct.shapes.cylinder import Cylinder from classy_blocks.grading.analyze.catalogue import Instruction, RowCatalogue from classy_blocks.mesh import Mesh from tests.fixtures.block import BlockTestCase class InstructionTests(BlockTestCase): def setUp(self): self.instruction = Instruction(self.make_block(0)) def test_not_defined(self): self.instruction.directions[1] = True self.assertFalse(self.instruction.is_defined) def test_defined(self): self.instruction.directions = [True] * 3 self.assertTrue(self.instruction.is_defined) def test_hash(self): _ = {0: self.instruction} class RowCatalogueTests(BlockTestCase): def setUp(self): self.mesh = Mesh() self.mesh.add(Cylinder([0, 0, 0], [1, 0, 0], [0, 1, 0])) dump = self.mesh.assemble() self.catalogue = RowCatalogue(dump.block_list) def test_row_blocks_exception(self): with self.assertRaises(BlockNotFoundError): # Try to find a block that's not a part of this mesh self.catalogue.get_row_blocks(self.make_block(1), 0) def test_find_instruction_exception(self): with self.assertRaises(NoInstructionError): self.catalogue._find_instruction(self.make_block(1)) ================================================ FILE: tests/test_grading/test_collector.py ================================================ import unittest from classy_blocks.grading.define.collector import Chop, ChopCollector class CollectorTests(unittest.TestCase): def test_chop_edge_single(self): c = ChopCollector() c.chop_edge(0, 1, Chop(count=10)) edge_chops = 0 for beam in c.edge_chops.get_all_beams(): if beam: edge_chops += 1 self.assertEqual(edge_chops, 1) def test_chop_edge_multiple(self): c = ChopCollector() c.chop_edge(0, 1, Chop(count=10)) c.chop_edge(3, 2, Chop(count=10)) edge_chops = 0 for beam in c.edge_chops.get_all_beams(): if beam: edge_chops += 1 self.assertEqual(edge_chops, 2) def test_is_not_edge_chopped(self): c = ChopCollector() c.chop_axis(0, Chop(count=10)) self.assertFalse(c.is_edge_chopped) def test_is_edge_chopped(self): c = ChopCollector() c.chop_edge(0, 1, Chop(count=10)) self.assertTrue(c.is_edge_chopped) ================================================ FILE: tests/test_grading/test_edge_grading.py ================================================ import unittest from typing import get_args import numpy as np from classy_blocks.base.exceptions import InconsistentGradingsError, UndefinedGradingsError from classy_blocks.cbtyping import DirectionType, GradingSpecType from classy_blocks.construct.edges import Arc from classy_blocks.construct.operations.box import Box from classy_blocks.construct.operations.connector import Connector from classy_blocks.construct.operations.extrude import Extrude from classy_blocks.construct.operations.loft import Loft from classy_blocks.mesh import Mesh from classy_blocks.write import formats class EdgeGradingExampleTests(unittest.TestCase): def setUp(self): """An example case, but thoroughly tested""" mesh = Mesh() start = Box([0, 0, 0], [1, 1, 0.1]) start.chop(0, start_size=0.1) start.chop(1, length_ratio=0.5, start_size=0.01, c2c_expansion=1.2, preserve="start_size") start.chop(1, length_ratio=0.5, end_size=0.01, c2c_expansion=1 / 1.2, preserve="end_size") start.chop(2, count=1) mesh.add(start) expand_start = start.get_face("right") expand = Loft(expand_start, expand_start.copy().translate([1, 0, 0]).scale(2)) expand.chop(2, start_size=0.1) mesh.add(expand) contract_start = expand.get_face("top") contract = Loft(contract_start, contract_start.copy().translate([1, 0, 0]).scale(0.25)) contract.chop(2, start_size=0.1) mesh.add(contract) # rotate the end block to test grading on non-aligned blocks end = Extrude(contract.get_face("top"), 1) end.rotate(np.pi, [0, 0, 1]) end.chop(2, start_size=0.1) mesh.add(end) mesh.assemble() mesh.grade() self.mesh = mesh def test_axis_simple_grading(self): # blocks 0 and 3 are simpleGrading because their edges are equal in each axis for i in (0, 3): for axis in self.mesh.blocks[i].axes: self.assertTrue(axis.is_simple) def test_axis_edge_grading(self): # blocks 1 and 2 are edgeGraded in axis 1 for i in (1, 2): self.assertFalse(self.mesh.blocks[i].axes[1].is_simple) def test_block_grading_simple(self): # blocks 0 and 3 can be simpleGraded for i in (0, 3): block_description = formats.format_block(self.mesh.blocks[i]) self.assertTrue("simpleGrading" in block_description) def test_block_grading_edge(self): # blocks 1 and 2 must be edge graded for i in (1, 2): block_description = formats.format_block(self.mesh.blocks[i]) self.assertTrue("edgeGrading" in block_description) class EdgeGradingTests(unittest.TestCase): """Border-cases tests and whatnot""" def setUp(self): self.mesh = Mesh() def prepare(self): self.mesh.assemble() self.mesh.grade() def test_inconsistent_wires(self): box_left = Box([0, 0, 0], [1, 1, 1]) box_right = box_left.copy().translate([2, 0, 0]) box_mid = box_left.copy().translate([1, 0, 0]) for box in (box_left, box_right, box_mid): box.chop(0, count=10) self.mesh.add(box) box_left.chop(1, count=10) # chop left and right unevenly box_left.chop(2, count=5) box_right.chop(2, count=15) with self.assertRaises(InconsistentGradingsError): self.prepare() def test_curved_block(self): """Edge-grade a perfect cube but with a curved edge""" box = Box([0, 0, 0], [1, 1, 1]) box.add_side_edge(0, Arc([-0.5, -0.5, 0.5])) box.chop(0, count=10) box.chop(1, count=10) box.chop(2, start_size=0.1, end_size=0.01, preserve="end_size") self.mesh.add(box) self.prepare() self.assertFalse(self.mesh.blocks[0].axes[2].is_simple) def test_no_preserve(self): """Do not edge grade anything without a 'preserve' keyword""" box = Box([0, 0, 0], [1, 1, 1]) box.add_side_edge(0, Arc([-0.5, -0.5, 0.5])) box.chop(0, count=10) box.chop(1, count=10) box.chop(2, start_size=0.1, end_size=0.01) self.mesh.add(box) self.prepare() self.assertTrue(self.mesh.blocks[0].axes[2].is_simple) def test_propagated_count(self): """Count of a block where grading was copied""" box_1 = Box([-1, -1, -1], [1, 1, 1]) box_2 = box_1.copy().rotate(np.pi / 4, [1, 1, 1], [0, 0, 0]).translate([4, 2, 0]) for i in get_args(DirectionType): box_1.chop(i, count=10) box_2.chop(i, count=10) connector = Connector(box_1, box_2) connector.chop(2, count=10) self.mesh.add(box_1) self.mesh.add(box_2) self.mesh.add(connector) self.prepare() for block in self.mesh.blocks: self.assertTrue(" ( 10 10 10 ) simpleGrading" in formats.format_block(block)) class BoxEdgeChopTests(unittest.TestCase): """Edge chopping on a single operation""" def setUp(self): self.box = Box([0, 0, 0], [1, 1, 1]) self.mesh = Mesh() self.mesh.add(self.box) def finalize(self): self.mesh.assemble() self.mesh.grade() def get_grading(self, direction: DirectionType, i_wire: int) -> list[GradingSpecType]: self.finalize() return self.mesh.blocks[0].axes[direction].wires[i_wire].grading.specification def test_edge_chop_solo_fail(self): """Cannot use edge chopping if the mesh is not fully defined""" self.box.chop_edge(0, 1, count=30) with self.assertRaises(UndefinedGradingsError): self.finalize() def test_edge_chop_solo_success(self): for i in get_args(DirectionType): self.box.chop(i, count=(i + 1) * 5) self.box.chop_edge(0, 1, start_size=0.05) self.finalize() # one edge is graded, the rest are simple self.assertAlmostEqual(self.get_grading(0, 0)[0][2], 9.043586, places=5) self.assertAlmostEqual(self.get_grading(0, 1)[0][2], 1) def test_edge_chop_multiple(self): """Do multiple chops and let the last chop define the remaining count automatically""" self.box.chop(0, count=20) self.box.chop(1, count=10) self.box.chop(2, count=10) self.box.chop_edge(0, 1, length_ratio=0.49, start_size=0.1) # 5 cells self.box.chop_edge(0, 1, length_ratio=0.51, end_size=0.05) # the rest self.finalize() self.assertEqual(self.get_grading(0, 0)[0][1], 5) self.assertEqual(self.get_grading(0, 0)[1][1], 15) def test_clear(self): for i in get_args(DirectionType): self.box.chop(i, count=(i + 1) * 5) self.box.chop_edge(0, 1, start_size=0.5) self.box.chop_edge(1, 2, count=30) self.box.chops.clear() self.assertListEqual(self.box.chops.axis_chops[0], []) self.assertListEqual(self.box.chops.axis_chops[1], []) self.assertListEqual(self.box.chops.axis_chops[2], []) self.assertFalse(self.box.chops.is_edge_chopped) ================================================ FILE: tests/test_grading/test_grading.py ================================================ import unittest import numpy as np from parameterized import parameterized from classy_blocks.base.exceptions import UndefinedGradingsError from classy_blocks.grading.define import relations as rel from classy_blocks.grading.define.chop import Chop, ChopRelation from classy_blocks.grading.define.grading import CollapsedGrading, Grading class GradingTests(unittest.TestCase): def setUp(self): self.g = Grading(1) def test_calculator_functions(self): expected_functions = [ # return_value | param1 | param2 (param0 = length) ["c2c_expansion", ["count", "end_size"], rel.get_c2c_expansion__count__end_size], ["c2c_expansion", ["count", "start_size"], rel.get_c2c_expansion__count__start_size], ["c2c_expansion", ["count", "total_expansion"], rel.get_c2c_expansion__count__total_expansion], ["count", ["end_size", "c2c_expansion"], rel.get_count__end_size__c2c_expansion], ["count", ["start_size", "c2c_expansion"], rel.get_count__start_size__c2c_expansion], ["count", ["total_expansion", "c2c_expansion"], rel.get_count__total_expansion__c2c_expansion], ["count", ["total_expansion", "start_size"], rel.get_count__total_expansion__start_size], ["end_size", ["start_size", "total_expansion"], rel.get_end_size__start_size__total_expansion], ["start_size", ["count", "c2c_expansion"], rel.get_start_size__count__c2c_expansion], ["start_size", ["end_size", "total_expansion"], rel.get_start_size__end_size__total_expansion], ["total_expansion", ["count", "c2c_expansion"], rel.get_total_expansion__count__c2c_expansion], ["total_expansion", ["start_size", "end_size"], rel.get_total_expansion__start_size__end_size], ] expected_functions = [ChopRelation(f[0], f[1][0], f[1][1], f[2]) for f in expected_functions] self.assertCountEqual(expected_functions, ChopRelation.get_possible_combinations()) @parameterized.expand( ( # [{keys}, count, total_expansion]; length=1 for all cases [{"count": 10, "total_expansion": 5}, 10, 5], [{"count": 10, "c2c_expansion": 1.1}, 10, 2.357947691], [{"count": 10, "c2c_expansion": 0.9}, 10, 0.387420489], [{"count": 10, "start_size": 0.2}, 10, 0.1903283012], [{"count": 10, "end_size": 0.2}, 10, 5.254123465509412], [{"count": 10, "end_size": 0.05}, 10, 0.2912203517], [{"total_expansion": 5, "c2c_expansion": 1.1}, 17, 5], [{"total_expansion": 0.2, "c2c_expansion": 0.9}, 16, 0.2], [{"total_expansion": 0.2, "start_size": 0.1}, 20, 0.2], [{"total_expansion": 5, "start_size": 0.1}, 4, 5], [{"total_expansion": 5, "end_size": 0.5}, 4, 5], [{"total_expansion": 0.2, "end_size": 0.1}, 4, 0.2], [{"c2c_expansion": 1.1, "start_size": 0.1}, 8, 1.9487171], [{"c2c_expansion": 0.95, "start_size": 0.1}, 14, 0.5133420832], [{"c2c_expansion": 1.1, "end_size": 0.1}, 26, 10.8347059433], [{"c2c_expansion": 0.95, "end_size": 0.1}, 9, 0.66342043128], [{"start_size": 0.1, "end_size": 0.05}, 14, 0.5], [{"start_size": 0.05, "end_size": 0.1}, 14, 2], ) ) def test_calculate(self, keys, count, total_expansion): chop = Chop(1, **keys) self.assertAlmostEqual(chop.calculate(1).count, count) self.assertAlmostEqual(chop.calculate(1).total_expansion, total_expansion, places=5) def add_chop(self, length_ratio, count, total_expansion): chop = Chop(length_ratio=length_ratio, count=count, total_expansion=total_expansion) self.g.add_chop(chop) def test_output_empty(self): with self.assertRaises(UndefinedGradingsError): _ = self.g.description def test_output_single(self): self.add_chop(1, 10, 3) self.assertEqual(str(self.g.description), "3") def test_output_multi(self): self.add_chop(0.25, 40, 2) self.add_chop(0.5, 20, 1) self.add_chop(0.25, 40, 0.5) expected_output = "((0.25 40 2)(0.5 20 1)(0.25 40 0.5))" self.assertEqual(str(self.g.description), expected_output) def test_copy_invert_simple(self): self.add_chop(1, 10, 5) self.assertAlmostEqual(self.g.specification[0][2], 5) g2 = self.g.copy(self.g.length, invert=True) self.assertAlmostEqual(g2.specification[0][2], 0.2) def test_add_division_zero_length(self): """Add a chop to zero-length grading""" with self.assertRaises(ValueError): self.g.length = 0 self.g.add_chop(Chop(count=10)) _ = self.g.specification def test_insuficient_data(self): """Add a chop with not enough data to calculate grading""" self.g.length = 1 with self.assertRaises(ValueError): # when using only 1 parameter, c2c_expansion is assumed 1; # when specifying that as well, another parameter must be provided self.g.add_chop(Chop(c2c_expansion=1.1)) _ = self.g.specification def test_over_defined(self): with self.assertRaises(ValueError): _ = Chop(count=10, start_size=0.05, end_size=0.1) def test_wrong_combination(self): """Add a chop with specified total_ and c2c_expansion""" with self.assertRaises(ValueError): # specified total_expansion and c2c_expansion=1 aren't compatible self.g.add_chop(Chop(total_expansion=5)) _ = self.g.specification def test_add_division_1(self): """double grading, set start_size and c2c_expansion""" self.g.length = 2 self.g.add_chop(Chop(length_ratio=0.5, start_size=0.1, c2c_expansion=1.1)) self.g.add_chop(Chop(length_ratio=0.5, end_size=0.1, c2c_expansion=1 / 1.1)) np.testing.assert_almost_equal( self.g.specification, [[0.5, 8, 1.9487171000000012], [0.5, 8, 0.5131581182307065]] ) def test_add_division_2(self): """single grading, set c2c_expansion and count""" self.g.add_chop(Chop(1, c2c_expansion=1.1, count=10)) np.testing.assert_almost_equal(self.g.specification, [[1, 10, 2.357947691000002]]) def test_add_division_3(self): """single grading, set count and start_size""" self.g.add_chop(Chop(1, count=10, start_size=0.05)) np.testing.assert_almost_equal(self.g.specification, [[1, 10, 3.433788027752166]]) def test_add_division_inverted(self): """Inverted chop, different result""" self.g.add_chop(Chop(0.5, count=10, start_size=0.05)) self.g.add_chop(Chop(0.5, count=10, end_size=0.05)) self.assertAlmostEqual(self.g.specification[0][2], 1 / self.g.specification[1][2]) @parameterized.expand(((0,), (1.1,))) def test_add_wrong_ratio(self, ratio): """Add a chop with an invalid length ratio""" with self.assertRaises(ValueError): self.g.add_chop(Chop(length_ratio=ratio, count=10)) def test_is_defined(self): self.g.add_chop(Chop(1, count=10, start_size=0.05)) self.assertTrue(self.g.is_defined) def test_is_not_defined(self): self.assertFalse(self.g.is_defined) def test_warn_ratio(self): """Issue a warning when length_ratios don't add up to 1""" self.g.add_chop(Chop(length_ratio=0.6, start_size=0.1)) self.g.add_chop(Chop(length_ratio=0.6, start_size=0.1)) with self.assertWarns(Warning): _ = self.g.description def test_invert_empty(self): """Invert a grading with no chops""" self.assertListEqual(self.g.copy(1, True).specification, []) def test_equal(self): """Two different gradings with same parameters are equal""" grad1 = Grading(1) grad2 = Grading(1) for g in (grad1, grad2): g.add_chop(Chop(length_ratio=0.5, start_size=0.1)) g.add_chop(Chop(length_ratio=0.5, end_size=0.1)) self.assertTrue(grad1 == grad2) def test_not_equal_divisionsn(self): """Two gradings with different lengths of specification""" grad1 = Grading(1) grad2 = Grading(1) for g in (grad1, grad2): g.add_chop(Chop(length_ratio=0.5, start_size=0.1)) g.add_chop(Chop(length_ratio=0.5, end_size=0.1)) grad1.add_chop(Chop(count=10)) self.assertFalse(grad1 == grad2) def test_not_equal(self): """Two gradings with equal lengths of specification""" grad1 = Grading(1) grad1.add_chop(Chop(length_ratio=0.5, start_size=0.15)) grad2 = Grading(1) grad2.add_chop(Chop(length_ratio=0.5, end_size=0.1)) self.assertFalse(grad1 == grad2) def test_copy_preserve_none(self): g1 = self.g g1.add_chop(Chop(start_size=0.01, end_size=0.1)) g2 = g1.copy(2) self.assertEqual(g1.count, g2.count) self.assertEqual(g1.specification[0][1], g2.specification[0][1]) def test_copy_preserve_start(self): g1 = self.g g1.add_chop(Chop(start_size=0.01, end_size=0.1, preserve="start_size")) g2 = g1.copy(2) self.assertEqual(g1.count, g2.count) self.assertEqual(g1.start_size, g2.start_size) self.assertLess(g1.specification[0][2], g2.specification[0][2]) def test_copy_preserve_end(self): g1 = self.g g1.add_chop(Chop(start_size=0.01, end_size=0.1, preserve="end_size")) g2 = g1.copy(2) self.assertEqual(g1.count, g2.count) self.assertEqual(g1.end_size, g2.end_size) self.assertGreater(g1.specification[0][2], g2.specification[0][2]) def test_start_size_exception(self): grading = Grading(1) with self.assertRaises(RuntimeError): _ = grading.start_size def test_end_size_exception(self): grading = Grading(1) with self.assertRaises(RuntimeError): _ = grading.end_size def test_grading_description_undefined(self): grading = Grading(1) self.assertEqual(str(grading), "Grading (0)") def test_grading_description_define(self): grading = Grading(1) grading.add_chop(Chop(count=10)) self.assertEqual(str(grading), "Grading (1 chops 1)") class CollapsedGradingTests(unittest.TestCase): def test_add_chop_count(self): g = CollapsedGrading() g.add_chop(Chop(count=10)) self.assertEqual(g.count, 10) def test_add_chop_nocount(self): # chops with no count specification can't be added g = CollapsedGrading() with self.assertRaises(RuntimeError): g.add_chop(Chop(start_size=0.1, end_size=1)) def test_count_multi(self): g = CollapsedGrading() g.add_chop(Chop(count=10)) g.add_chop(Chop(count=20)) self.assertEqual(g.count, 30) def test_clear(self): g = CollapsedGrading() g.add_chop(Chop(count=10)) g.clear() self.assertListEqual(g.chops, []) self.assertEqual(g.count, 0) def test_description(self): g = CollapsedGrading() g.add_chop(Chop(count=10)) self.assertEqual(g.description, "1") ================================================ FILE: tests/test_grading/test_inflation.py ================================================ import unittest import numpy as np from classy_blocks.construct.operations.box import Box from classy_blocks.grading.graders.inflation import InflationGrader from classy_blocks.mesh import Mesh class InflationGraderTests(unittest.TestCase): def get_grader(self, mesh: Mesh) -> InflationGrader: return InflationGrader(mesh, 0.002, 0.1, 1.2, 30) def test_flipped_blocks(self): # create two boxes side by side, one is flipped # upside-down; mesh = Mesh() box_1 = Box([0, 0, 0], [1, 1, 1]) box_1.set_patch("bottom", "wall") box_2 = Box([1, 0, 0], [2, 1, 1]) box_2.rotate(np.pi, [0, 1, 0]) box_2.set_patch("top", "wall") mesh.add(box_1) mesh.add(box_2) # the bottom-most patch (as-is) is wall mesh.modify_patch("wall", "wall") grader = self.get_grader(mesh) grader.grade() # now make sure the blocks are not double graded self.assertEqual(len(mesh.blocks[0].axes[2].wires.wires[0].grading.chops), 3) self.assertEqual(len(mesh.blocks[1].axes[2].wires.wires[0].grading.chops), 3) ================================================ FILE: tests/test_grading/test_layers.py ================================================ import unittest from classy_blocks.grading.graders.inflation import ( BufferLayer, BulkLayer, InflationLayer, InflationParams, LayerStack, sum_length, ) class ParamsTests(unittest.TestCase): def setUp(self): self.params = InflationParams(0.002, 0.1, 1.2, 30, 2) def test_bl_thickness(self): self.assertEqual(self.params.bl_thickness, 0.06) def test_inflation_count(self): self.assertEqual(self.params.inflation_count, 11) def test_inflation_end_size(self): self.assertAlmostEqual(self.params.inflation_end_size, 0.012383, places=5) def test_buffer_start_size(self): self.assertAlmostEqual(self.params.buffer_start_size, 0.012383 * 2, places=5) def test_buffer_count(self): self.assertEqual(self.params.buffer_count, 3) def test_sum_length_simple(self): self.assertAlmostEqual(sum_length(0.1, 10, 1), 1) def test_sum_length_graded(self): self.assertAlmostEqual(sum_length(0.1, 10, 2), 102.3) def test_buffer_thickness(self): self.assertAlmostEqual(self.params.buffer_thickness, 0.173369, places=5) class LayerStackTests(ParamsTests): def test_inflation_layer_count(self): layer = InflationLayer(self.params, 0.6) self.assertEqual(layer.count, 12) def test_inflation_layer_size(self): layer = InflationLayer(self.params, 0.6) self.assertLess(layer.length, self.params.bl_thickness + self.params.buffer_start_size) def test_inflation_layer_size_truncated(self): layer = InflationLayer(self.params, 0.05) self.assertLess(layer.length, 0.05 + self.params.buffer_start_size) def test_inflation_count_zero(self): layer = InflationLayer(self.params, 0) self.assertEqual(layer.count, 1) def test_buffer_count(self): layer = BufferLayer(self.params, 1) self.assertEqual(layer.count, 4) def test_buffer_length_truncated(self): max_length = 1 - self.params.bl_thickness layer = BufferLayer(self.params, max_length) self.assertLess(layer.length, max_length + self.params.bulk_cell_size) def test_buffer_length(self): layer = BufferLayer(self.params, 100) self.assertLess(layer.length, 100) def test_bulk_layer(self): layer = BulkLayer(self.params, 1) self.assertEqual(layer.count, 11) def test_stack_inflation_short(self): # block size is less than boundary layer thickness stack = LayerStack(self.params, 0.5 * self.params.bl_thickness) self.assertEqual(len(stack.layers), 1) def test_stack_inflation_exact(self): # block size is exactly boundary layer size; stack = LayerStack(self.params, self.params.bl_thickness) self.assertEqual(len(stack.layers), 1) def test_stack_buffer(self): stack = LayerStack(self.params, self.params.bl_thickness + 2 * self.params.buffer_start_size) self.assertEqual(len(stack.layers), 2) def test_stack_bulk(self): stack = LayerStack(self.params, 10) self.assertEqual(len(stack.layers), 3) def test_length_ratios(self): stack = LayerStack(self.params, 10) self.assertAlmostEqual(sum(layer.length_ratio for layer in stack.layers), 1) ================================================ FILE: tests/test_grading/test_probe.py ================================================ import unittest from typing import get_args import numpy as np from parameterized import parameterized from classy_blocks.cbtyping import DirectionType from classy_blocks.construct.flat.sketches.grid import Grid from classy_blocks.construct.operations.box import Box from classy_blocks.construct.shapes.cylinder import Cylinder from classy_blocks.construct.shapes.frustum import Frustum from classy_blocks.construct.stack import ExtrudedStack from classy_blocks.grading.analyze.catalogue import get_block_from_axis from classy_blocks.grading.analyze.probe import Probe from classy_blocks.mesh import Mesh class ProbeTests(unittest.TestCase): def get_stack(self) -> ExtrudedStack: # create a simple 4x4 grid for easy navigation base = Grid([0, 0, 0], [1, 1, 0], 4, 4) return ExtrudedStack(base, 1, 4) def get_flipped_stack(self) -> ExtrudedStack: stack = self.get_stack() for i in (5, 6, 8, 9): stack.operations[i].rotate(np.pi, [0, 1, 0]) return stack def get_cylinder(self) -> Cylinder: return Cylinder([0, 0, 0], [1, 0, 0], [0, 1, 0]) def get_frustum(self) -> Frustum: return Frustum([0, 0, 0], [1, 0, 0], [0, 1, 0], 0.3) def get_box(self) -> Box: return Box([0, 0, 0], [1, 1, 1]) def setUp(self): self.mesh = Mesh() def test_block_from_axis_fail(self): mesh_1 = self.mesh mesh_1.add(self.get_stack()) self.dump_1 = mesh_1.assemble() mesh_2 = Mesh() mesh_2.add(self.get_stack()) self.dump_2 = mesh_2.assemble() with self.assertRaises(RuntimeError): get_block_from_axis(mesh_1, mesh_2.blocks[0].axes[0]) @parameterized.expand((("min", 0.19305), ("max", 0.8), ("avg", 0.46677))) def test_get_row_length(self, take, length): self.mesh.add(self.get_frustum()) dump = self.mesh.assemble() probe = Probe(dump, self.mesh.settings) row = probe.get_rows(0)[0] self.assertAlmostEqual(row.get_length(take), length, places=4) @parameterized.expand(((0, 0), (0, 1), (0, 2), (1, 0), (1, 1), (2, 2))) def test_get_blocks_on_layer(self, block, axis): self.mesh.add(self.get_stack()) dump = self.mesh.assemble() probe = Probe(dump, self.mesh.settings) blocks = probe.get_row_blocks(self.mesh.blocks[block], axis) self.assertEqual(len(blocks), 16) @parameterized.expand(((0,), (1,), (2,), (3,), (4,), (5,), (6,), (7,), (8,), (9,), (10,), (11,))) def test_block_from_axis(self, index): self.mesh.add(self.get_cylinder()) self.mesh.assemble() for axis in get_args(DirectionType): block = self.mesh.blocks[index] self.assertEqual(block, get_block_from_axis(self.mesh, block.axes[axis])) @parameterized.expand(((0,), (1,), (2,))) def test_get_layers(self, axis): self.mesh.add(self.get_stack()) dump = self.mesh.assemble() probe = Probe(dump, self.mesh.settings) layers = probe.get_rows(axis) self.assertEqual(len(layers), 4) @parameterized.expand( ( # axis, layer, block indexes (0, 0, {5, 0, 3, 10}), (0, 1, {6, 1, 2, 9}), (0, 2, {4, 5, 6, 7, 8, 9, 10, 11}), (1, 0, {7, 1, 0, 4}), (1, 1, {8, 2, 3, 11}), (2, 0, set(range(12))), ) ) def test_get_blocks_cylinder(self, axis, row, blocks): self.mesh.add(self.get_cylinder()) dump = self.mesh.assemble() probe = Probe(dump, self.mesh.settings) indexes = set() for block in probe.rows.rows[axis][row].blocks: indexes.add(block.index) self.assertSetEqual(indexes, blocks) @parameterized.expand( ( # axis, layer, block indexes (1, 0, {0, 1, 2, 3}), (1, 1, {4, 5, 6, 7}), (1, 2, {8, 9, 10, 11}), (1, 3, {12, 13, 14, 15}), ) ) def test_get_blocks_inverted(self, axis, row, blocks): shape = self.get_flipped_stack().shapes[1] self.mesh.add(shape) dump = self.mesh.assemble() probe = Probe(dump, self.mesh.settings) indexes = set() for block in probe.rows.rows[axis][row].blocks: indexes.add(block.index) self.assertSetEqual(indexes, blocks) def test_flipped_simple(self): shape = self.get_stack().shapes[0] shape.grid[0][1].rotate(np.pi, [0, 0, 1]) self.mesh.add(shape) dump = self.mesh.assemble() probe = Probe(dump, self.mesh.settings) row = probe.get_rows(1)[0] self.assertListEqual([entry.flipped for entry in row.entries], [False, True, False, False]) @parameterized.expand( ( ((1,), 0, 1), ((1, 2), 0, 1), ((1, 2), 0, 2), ((1, 2, 5), 1, 1), ((0,), 0, 1), ((0,), 0, 2), ) ) def test_flipped_shape(self, flip_indexes, check_row, check_index): stack = self.get_stack().shapes[0] for i in flip_indexes: stack.operations[i].rotate(np.pi, [0, 0, 1]) self.mesh.add(stack) dump = self.mesh.assemble() probe = Probe(dump, self.mesh.settings) self.assertTrue(probe.get_rows(1)[check_row].entries[check_index].flipped) @parameterized.expand( ( (0, 4, True), (1, 5, True), (2, 6, True), (3, 7, True), (0, 1, True), (3, 2, True), (4, 5, True), (7, 6, True), (0, 3, False), (1, 2, False), (4, 7, False), (5, 6, False), ) ) def test_wire_boundaries_explicit(self, wire_start, wire_end, starts_at_wall): box = self.get_box() box.set_patch(["bottom", "left", "right"], "wallPatch") self.mesh.add(box) self.mesh.modify_patch("wallPatch", "wall") dump = self.mesh.assemble() probe = Probe(dump, self.mesh.settings) block = self.mesh.blocks[0] info = probe.get_wire_info(block.wires[wire_start][wire_end]) self.assertEqual(info.starts_at_wall, starts_at_wall) @parameterized.expand( ( (0, 4, True), (1, 5, True), (2, 6, True), (3, 7, True), (0, 1, True), (3, 2, True), (4, 5, True), (7, 6, True), (0, 3, False), (1, 2, False), (4, 7, False), (5, 6, False), ) ) def test_wire_boundaries_default(self, wire_start, wire_end, starts_at_wall): # same situation as the 'explicit' test but 'patch' patch types are defined # and walls are the default box = self.get_box() box.set_patch(["top", "front", "back"], "patchPatch") self.mesh.add(box) self.mesh.set_default_patch("defaultFaces", "wall") dump = self.mesh.assemble() probe = Probe(dump, self.mesh.settings) block = self.mesh.blocks[0] info = probe.get_wire_info(block.wires[wire_start][wire_end]) self.assertEqual(info.starts_at_wall, starts_at_wall) def test_wire_boundaries_cylinder(self): block_index = 0 wire_start = 0 wire_end = 1 # same as above tests but on a cylinder, not just a box cylinder = self.get_cylinder() cylinder.set_outer_patch("sides") self.mesh.add(cylinder) self.mesh.modify_patch("sides", "wall") dump = self.mesh.assemble() probe = Probe(dump, self.mesh.settings) block = self.mesh.blocks[block_index] info = probe.get_wire_info(block.wires[wire_start][wire_end]) self.assertEqual(info.starts_at_wall, False) ================================================ FILE: tests/test_grading/test_relations.py ================================================ # numbers are calculated with the calculator all this is 'borrowed' from # https://openfoamwiki.net/index.php/Scripts/blockMesh_grading_calculation # with a few differences: # - scipy.optimize. can be used here instead of barbarian bisection # - all floats are converted to integers by rounding down (only matters for border cases) import unittest from parameterized import parameterized from classy_blocks.grading.define import relations as rel from classy_blocks.grading.define.chop import ChopRelation from classy_blocks.util import functions as f class TestGradingRelations(unittest.TestCase): """Testing valid, border and invalid cases""" def assertAlmostEqual(self, *args, **kwargs): # noqa: N802 kwargs.pop("places", None) kwargs["places"] = 5 return super().assertAlmostEqual(*args, **kwargs) @parameterized.expand( ( ((1, 10, 1), 0.1), ((1, 10, 1.1), 0.06274539488), ) ) def test_get_start_size__count__c2c_expansion_valid(self, args, result): self.assertAlmostEqual(rel.get_start_size__count__c2c_expansion(*args), result, places=5) def test_get_start_size__count__c2c_expansion_zerolen(self): with self.assertRaises(ValueError): rel.get_start_size__count__c2c_expansion(0, 10, 1) def test_get_start_size__count__c2c_expansion_contracting(self): with self.assertRaises(ValueError): rel.get_start_size__count__c2c_expansion(1, 0.5, 1) def test_get_start_size__end_size__total_expansion_valid(self): self.assertAlmostEqual(rel.get_start_size__end_size__total_expansion(1, 0.1, 1), 0.1) def test_get_start_size__end_size__total_expansion_zerolen(self): with self.assertRaises(ValueError): rel.get_start_size__end_size__total_expansion(0, 0.1, 1) def test_get_start_size__end_size__total_expansion_zeroexp(self): with self.assertRaises(ValueError): rel.get_start_size__end_size__total_expansion(1, 0.1, 0) def test_get_end_size__start_size__total_expansion_valid(self): self.assertAlmostEqual(rel.get_end_size__start_size__total_expansion(1, 0.1, 10), 1) def test_get_end_size__start_size__total_expansion_neglen(self): with self.assertRaises(ValueError): rel.get_end_size__start_size__total_expansion(-1, 0.1, 0) @parameterized.expand( ( ((1, 1, 1), 2), ((1, 0.1, 1), 11), ((1, 0.1, 1.1), 8), ((1, 2, 1), 1), # border cases ((1, 1, 2), 2), ) ) def test_get_count__start__size__c2c_expansion_valid(self, args, result): self.assertAlmostEqual(rel.get_count__start_size__c2c_expansion(*args), result) @parameterized.expand( ( ((0, 0.1, 1.1),), ((1, 0, 1.1),), ((1, 0.95, 0),), ) ) def test_get_count__start__size__c2c_expansion_invalid(self, args): # invalid cases: # length < 0 # start_size = 0 # c2c_expansion = 0 with self.assertRaises(ValueError): rel.get_count__start_size__c2c_expansion(*args) @parameterized.expand( ( ((1, 0.1, 1), 11), ((1, 0.1, 1.1), 26), ((1, 0.1, 0.9), 8), ((1, 1, 1), 2), # border cases ((1, 1, 2), 2), ) ) def test_get_count__end_size__c2c_expansion_valid(self, args, result): self.assertAlmostEqual(rel.get_count__end_size__c2c_expansion(*args), result) def test_get_count__end_size__c2c_expansion_invalid(self): with self.assertRaises(ValueError): rel.get_count__end_size__c2c_expansion(1, 0.1, 1.5) def test_get_count__total_expansion__c2c_expansion_valid(self): self.assertAlmostEqual(rel.get_count__total_expansion__c2c_expansion(1, 3, 1.1), 12) def test_get_count__total_expansion__c2c_expansion_border(self): self.assertAlmostEqual(rel.get_count__total_expansion__c2c_expansion(1, 1, 1.1), 1) @parameterized.expand( ( ((1, 1, 1),), ((1, -1, 1.1),), ) ) def test_get_count__total_expansion__c2c_expansion_invalid(self, args): with self.assertRaises(ValueError): rel.get_count__total_expansion__c2c_expansion(*args) @parameterized.expand( ( ((1, 1, 0.1), 10), ((1, 2, 0.1), 7), ((1, 8, 0.1), 3), ((1, 0.9, 0.5), 3), # border cases ((1, 0.3, 1), 2), ) ) def test_get_count__total_expansion__start_size_valid(self, args, result): self.assertAlmostEqual(rel.get_count__total_expansion__start_size(*args), result) @parameterized.expand( ( ((1, 10, 0.1), 1), ((1, 2, 0.1), 9), ((1, 5, 0.1), 1.352395572), ((1, 2, 0.5), 1), ((1, 10, 0.05), 1.1469127), ((1, 1, 0.1), 1), # border cases ((1, 20, 0.1), 0.9181099911), ) ) def test_get_c2c_expansion__count__start_size_valid(self, args, result): self.assertAlmostEqual(rel.get_c2c_expansion__count__start_size(*args), result) @parameterized.expand( ( ((0, 1, 0.1),), # length = 0 ((1, 0, 0.1),), # count < 1 ((1, 10, 1.1),), # start_size > length ((1, 10, 0),), # start_size = 0 ((1, 10, 0.9),), ) ) def test_get_c2c_expansion__count__start_size_invalid(self, args): with self.assertRaises(ValueError): rel.get_c2c_expansion__count__start_size(*args) @parameterized.expand( ( ((1, 10, 0.1), 1), ((1, 10, 0.01), 0.6784573173), ((1, 10, 0.2), 1.202420088), ((1, 2, 0.5), 1), # border case ) ) def test_get_c2c_expansion__count__end_size_valid(self, args, result): self.assertAlmostEqual(rel.get_c2c_expansion__count__end_size(*args), result) @parameterized.expand( ( ((1, 0.5, 1),), ((1, 10, -0.5),), ((1, 10, 1),), ) ) def test_get_c2c_expansion__count__end_size_invalid(self, args): with self.assertRaises(ValueError): rel.get_c2c_expansion__count__end_size(*args) @parameterized.expand( ( ((1, 10, 5), 1.195813175), ((1, 10, 0.5), 0.9258747123), ((1, 10, 1), 1), # border case ) ) def test_get_c2c_expansion__count__total_expansion_valid(self, args, result): self.assertAlmostEqual(rel.get_c2c_expansion__count__total_expansion(*args), result) def test_get_c2c_expansion__count__total_expansion_invalid(self): c2cexp = rel.get_c2c_expansion__count__total_expansion(1, 1, 1) self.assertEqual(c2cexp, 1) @parameterized.expand((((1, 10, 1), 1), ((1, 1, 1), 1), ((1, 10, 1.1), 2.3579476), ((1, 1, 1), 1))) # border case def test_get_total_expansion__count__c2c_expansion_valid(self, args, result): self.assertAlmostEqual(rel.get_total_expansion__count__c2c_expansion(*args), result) def test_get_total_expansion__count__c2c_expansion_invalid(self): with self.assertRaises(ValueError): rel.get_total_expansion__count__c2c_expansion(1, 0.5, 1) @parameterized.expand( ( ((1, 1, 1), 1), ((1, 0.1, 0.01), 0.1), ((1, 0.1, 0.01), 0.1), ((1, 0.01, 0.1), 10), ) ) def test_get_total_expansion__start_size__end_size_valid(self, args, result): self.assertAlmostEqual(rel.get_total_expansion__start_size__end_size(*args), result) def test_get_total_expansion__start_size__end_size_invalid(self): with self.assertRaises(ValueError): rel.get_total_expansion__start_size__end_size(1, 0, 0.1) @parameterized.expand( ( (0.5, ">0"), (1, ">=0"), (1, ">=1"), (1.5, ">1"), (1.5, "<2"), (2, "<=2"), (2, "==2"), (3, "!=2"), ) ) def test_validate_count_valid(self, count, condition): rel._validate_count(count, condition) def test_validate_count_invalid_type(self): with self.assertRaises(TypeError): rel._validate_count("a", "==") def test_validate_count_invalid_operator(self): with self.assertRaises(ValueError): rel._validate_count(10, "xx") def test_validate_count_unknown_operator(self): with self.assertRaises(ValueError): rel._validate_count(10, ">x") class ChopRelationTests(unittest.TestCase): def test_from_function_invalid(self): """Raise an exception when an unknown relation is found""" with self.assertRaises(RuntimeError): _ = ChopRelation.from_function(f.angle_between) ================================================ FILE: tests/test_items/__init__.py ================================================ ================================================ FILE: tests/test_items/test_axis.py ================================================ from parameterized import parameterized from classy_blocks.items.block import Block from tests.fixtures.block import BlockTestCase class AxisTests(BlockTestCase): """Tests of the Axis object""" def add_blocks(self) -> tuple[Block, Block]: block_0 = self.make_block(0) block_1 = self.make_block(1) block_0.add_neighbour(block_1) block_1.add_neighbour(block_0) return block_0, block_1 def test_length_avg(self): """Average length""" # all edges are straight and of length 1, # except one that has a curved edge block = self.make_block(0) length = block.axes[0].wires.length self.assertAlmostEqual(length, 1.0397797556255037) def test_is_aligned_exception(self): """Raise an exception when axes are not aligned""" block_0 = self.make_block(0) block_1 = self.make_block(1) block_0.axes[0].add_neighbour(block_1.axes[0]) with self.assertRaises(RuntimeError): _ = block_0.axes[0].is_aligned(block_1.axes[0]) @parameterized.expand(((1,), (2,))) def test_is_aligned(self, axis): """Returns True when axes are aligned""" block_0 = self.make_block(0) block_1 = self.make_block(1) block_0.axes[axis].add_neighbour(block_1.axes[axis]) self.assertTrue(block_0.axes[axis].is_aligned(block_1.axes[axis])) def test_is_not_aligned(self): """Return False when axes are not aligned""" block_0 = self.make_block(0) # turn block_1 upside-down vertices_1 = self.make_vertices(1) vertices_1 = [vertices_1[i] for i in [7, 6, 5, 4, 3, 2, 1, 0]] block_1 = Block(1, vertices_1) block_0.add_neighbour(block_1) self.assertFalse(block_1.axes[1].is_aligned(block_0.axes[1])) def test_sequential_before(self): block_0, block_1 = self.add_blocks() calculated_before = set(before.wire for before in block_1.wires[0][1].before) expected_before = {block_0.wires[0][1]} self.assertSetEqual(calculated_before, expected_before) def test_sequential_after(self): block_0, block_1 = self.add_blocks() calculated_after = set(after.wire for after in block_0.wires[0][1].after) expected_after = {block_1.wires[0][1]} self.assertSetEqual(calculated_after, expected_after) def test_is_inline(self): block_0, block_1 = self.add_blocks() self.assertTrue(block_0.axes[0].is_inline(block_1.axes[0])) def test_is_not_inline(self): block_0 = self.make_block(0) block_2 = self.make_block(2) block_0.add_neighbour(block_2) block_2.add_neighbour(block_0) self.assertFalse(block_0.axes[0].is_inline(block_2.axes[0])) ================================================ FILE: tests/test_items/test_block.py ================================================ import numpy as np from parameterized import parameterized from classy_blocks.cbtyping import DirectionType from classy_blocks.construct.edges import Arc from classy_blocks.grading.define.chop import Chop from classy_blocks.grading.define.collector import ChopCollector from classy_blocks.items.block import Block from classy_blocks.items.vertex import Vertex from classy_blocks.items.wires.wire import Wire from classy_blocks.util import functions as f from tests.fixtures.block import BlockTestCase def _mkcollector(axis: DirectionType, chops: list[Chop]) -> ChopCollector: col = ChopCollector() for chop in chops: col.chop_axis(axis, chop) return col class BlockTests(BlockTestCase): """Block item tests""" @parameterized.expand( ( ((1, 2), (0, 3)), # corner_1, corner_2 of block_0 and corner_1, corner_2 of block_1 ((5, 6), (4, 7)), ((1, 5), (0, 4)), ((2, 6), (3, 7)), ) ) def test_add_neighbour_1_wires(self, this_corners, nei_corners): """Two blocks that share a 'side' a.k.a. face""" block_0 = self.make_block(0) block_1 = self.make_block(1) block_0.add_neighbour(block_1) # these two blocks share the whole face (1 2 6 5) # 4 wires altogether self.assertEqual( block_0.wires[this_corners[0]][this_corners[1]].coincidents, {block_1.wires[nei_corners[0]][nei_corners[1]]} ) def test_add_neighbour_1_axes(self): """Two blocks that share a 'side' a.k.a. face""" block_0 = self.make_block(0) block_1 = self.make_block(1) block_0.add_neighbour(block_1) # block_1 is block_0's neighbour in axes 1 and 2 self.assertSetEqual(block_0.axes[0].neighbours, set()) self.assertSetEqual(block_0.axes[1].neighbours, {block_1.axes[1]}) self.assertSetEqual(block_0.axes[2].neighbours, {block_1.axes[2]}) def test_add_neighbour_2_wires(self): """Two blocks that share an edge only""" block_0 = self.make_block(0) block_2 = self.make_block(2) block_0.add_neighbour(block_2) # there must be only 1 wire that only has 1 neighbour self.assertEqual(block_0.wires[2][6].coincidents, {block_2.wires[0][4]}) def test_add_neighbour_2_axes(self): """Two blocks that share an edge only""" block_0 = self.make_block(0) block_2 = self.make_block(2) block_0.add_neighbour(block_2) # block_2 is block_0'2 neighbour only on axis 2 self.assertSetEqual(block_0.axes[0].neighbours, set()) self.assertSetEqual(block_0.axes[1].neighbours, set()) self.assertSetEqual(block_0.axes[2].neighbours, {block_2.axes[2]}) def test_add_neighbour_3(self): """Where three blocks meet, there's a wire with 2 coincident wires""" block_0 = self.make_block(0) block_1 = self.make_block(1) block_2 = self.make_block(2) block_0.add_neighbour(block_1) block_0.add_neighbour(block_2) self.assertEqual(block_0.wires[2][6].coincidents, {block_1.wires[3][7], block_2.wires[0][4]}) def test_add_neighbour_twice(self): """Add the same neighbour twice""" block_0 = self.make_block(0) block_1 = self.make_block(1) block_0.add_neighbour(block_1) block_0.add_neighbour(block_1) self.assertEqual(len(block_0.axes[0].neighbours), 0) self.assertEqual(len(block_0.axes[1].neighbours), 1) self.assertEqual(len(block_0.axes[2].neighbours), 1) def test_add_self(self): """Add the same block as a neighbour""" block_0 = self.make_block(0) block_0.add_neighbour(block_0) self.assertEqual(len(block_0.axes[0].neighbours), 0) self.assertEqual(len(block_0.axes[1].neighbours), 0) self.assertEqual(len(block_0.axes[2].neighbours), 0) def test_add_neighbour_inverted(self): """Add a neighbour with an inverted axis""" data = self.get_single_data(0) points = data.points indexes = [7, 6, 5, 4, 3, 2, 1, 0] vertices = [Vertex(p, indexes[i]) for i, p in enumerate(points)] block_0 = Block(0, vertices) block_1 = self.make_block(1) block_0.add_neighbour(block_1) self.assertEqual(len(block_0.axes[0].neighbours), 0) self.assertEqual(len(block_0.axes[1].neighbours), 1) self.assertEqual(len(block_0.axes[2].neighbours), 1) self.assertFalse(block_0.axes[1].is_aligned(block_1.axes[1])) self.assertFalse(block_0.axes[2].is_aligned(block_1.axes[2])) def test_add_foreign(self): """Try to add a block that is not in contact as a neighbour""" block_0 = self.make_block(0) data = self.get_single_data(1) # displace points points = [np.array(p) + f.vector(10, 10, 10) for p in data.points] indexes = [8, 9, 10, 11, 12, 13, 14, 15] vertices = [Vertex(p, indexes[i]) for i, p in enumerate(points)] block_1 = Block(0, vertices) block_0.add_neighbour(block_1) self.assertEqual(len(block_0.axes[0].neighbours), 0) self.assertEqual(len(block_0.axes[1].neighbours), 0) self.assertEqual(len(block_0.axes[2].neighbours), 0) def test_axis_direction(self): block = self.make_block(0) axis = self.make_block(1).axes[0] with self.assertRaises(RuntimeError): block.get_axis_direction(axis) def test_repr(self): self.assertEqual(str(self.make_block(0)), "Block 0") class WireframeTests(BlockTestCase): """Workings of the Frame object in a Block""" def setUp(self): self.vertices = self.make_vertices(0) self.block = self.make_block(0) def test_wires_count(self): """Each corner has exactly 3 couples""" for wires in self.block.wires: self.assertEqual(len(wires), 3) def test_wire_indexes(self): """Check that indexes are generated exactly as the sketch dictates""" # hand-typed expected_indexes = ( (1, 3, 4), # 0 (0, 2, 5), # 1 (1, 3, 6), # 2 (0, 2, 7), # 3 (0, 5, 7), # 4 (1, 4, 6), # 5 (2, 5, 7), # 6 (3, 4, 6), # 7 ) for i, wires in enumerate(self.block.wires): generated = set(wires.keys()) expected = set(expected_indexes[i]) self.assertSetEqual(generated, expected) def test_axis_count(self): """Check that each axis has exactly 4 wires""" for axis in range(3): self.assertEqual(len(self.block.axes[axis].wires.wires), 4) @parameterized.expand(((0, 2), (1, 3), (0, 6), (1, 7))) def test_find_wire_fail(self, corner_1, corner_2): """There are no wires in face or volume diagonals""" with self.assertRaises(KeyError): _ = self.block.wires[corner_1][corner_2] @parameterized.expand(((0, 1), (1, 0), (0, 4), (4, 0))) def test_find_wire_success(self, corner_1, corner_2): """Find wire by __getitem__""" self.assertEqual(type(self.block.wires[corner_1][corner_2]), Wire) @parameterized.expand(((0,), (1,), (2,))) def test_get_axis_wires_length(self, axis): """Number of wires for each axis""" self.assertEqual(len(self.block.get_axis_wires(axis)), 4) @parameterized.expand(((0, 2), (1, 2), (2, 0))) def test_edge_list_empty(self, block_index, edge_count): """A block with only line edges must have an empty edge_list""" block = self.make_block(block_index) self.assertEqual(len(block.edge_list), edge_count) def test_index_exception(self): """Raise an exception when wrong indexes are provided to add_edge()""" block = self.make_block(0) with self.assertRaises(ValueError): block.add_edge(0, 9, Arc([1, 1, 1])) ================================================ FILE: tests/test_items/test_edge.py ================================================ import unittest import numpy as np from classy_blocks.base.exceptions import EdgeCreationError from classy_blocks.construct import edges from classy_blocks.construct.curves.interpolated import LinearInterpolatedCurve from classy_blocks.construct.point import Point from classy_blocks.items.edges.arcs.angle import AngleEdge, arc_from_theta from classy_blocks.items.edges.arcs.arc import ArcEdge from classy_blocks.items.edges.arcs.origin import OriginEdge, arc_from_origin from classy_blocks.items.edges.curve import OnCurveEdge, SplineEdge from classy_blocks.items.edges.edge import Edge from classy_blocks.items.edges.factory import factory from classy_blocks.items.edges.project import ProjectEdge from classy_blocks.items.vertex import Vertex from classy_blocks.util import functions as f class EdgeTransformTests(unittest.TestCase): """Transformations of all edge types""" def setUp(self): self.vertex_1 = Vertex([0, 0, 0], 0) self.vertex_2 = Vertex([1, 0, 0], 1) def test_arc_edge_translate(self): arc_edge = ArcEdge(self.vertex_1, self.vertex_2, edges.Arc([0.5, 0, 0])) arc_edge.translate([1, 1, 1]) np.testing.assert_almost_equal(arc_edge.data.point.position, [1.5, 1, 1]) def test_angle_edge_rotate_1(self): angle_edge = AngleEdge(self.vertex_1, self.vertex_2, edges.Angle(np.pi / 2, [0, 0, 1])) angle_edge.rotate(np.pi / 2, [0, 0, 1], [0, 0, 0]) np.testing.assert_almost_equal(angle_edge.data.axis.components, [0, 0, 1]) self.assertEqual(angle_edge.data.angle, np.pi / 2) def test_angle_edge_rotate_2(self): angle_edge = AngleEdge(self.vertex_1, self.vertex_2, edges.Angle(np.pi / 2, [0, 0, 1])) angle_edge.rotate(np.pi / 2, [1, 0, 0], [0, 0, 0]) np.testing.assert_almost_equal(angle_edge.data.axis.components, [0, -1, 0]) self.assertEqual(angle_edge.data.angle, np.pi / 2) def test_spline_edge_translate(self): spline_edge = SplineEdge( self.vertex_1, self.vertex_2, edges.Spline( [ [0.25, 0.1, 0], [0.5, 0.5, 0], [0.75, 0.1, 0], ] ), ) spline_edge.translate([1, 1, 1]) np.testing.assert_almost_equal( spline_edge.point_array, [ [1.25, 1.1, 1], [1.5, 1.5, 1], [1.75, 1.1, 1], ], ) def test_default_origin(self): """Issue a warning when transforming with a default origin""" edge = ArcEdge(self.vertex_1, self.vertex_2, edges.Arc([0.5, 0.2, 0])) with self.assertWarns(Warning): edge.rotate(1, [0, 0, 1]) class EdgeFactoryTests(unittest.TestCase): """Factory tests: edge creation""" def setUp(self): self.vertex_1 = Vertex([0, 0, 0], 0) self.vertex_2 = Vertex([1, 0, 0], 1) def test_arc(self): arc_point = [0.5, 0.2, 0] edg = factory.create(self.vertex_1, self.vertex_2, edges.Arc(arc_point)) self.assertIsInstance(edg, ArcEdge) np.testing.assert_array_almost_equal(arc_point, edg.data.point.position) def test_default_origin(self): origin = [0.5, -0.5, 0] flatness = 2 edg = factory.create(self.vertex_1, self.vertex_2, edges.Origin(origin, flatness)) self.assertIsInstance(edg, OriginEdge) np.testing.assert_array_almost_equal(origin, edg.data.origin.position) self.assertEqual(flatness, edg.data.flatness) def test_flat_origin(self): origin = [0.5, -0.5, 0] edg = factory.create(self.vertex_1, self.vertex_2, edges.Origin(origin)) self.assertIsInstance(edg, OriginEdge) np.testing.assert_array_almost_equal(origin, edg.data.origin.position) self.assertEqual(1, edg.data.flatness) def test_angle(self): angle = np.pi / 6 axis = [0.0, 0.0, 1.0] edge = factory.create(self.vertex_1, self.vertex_2, edges.Angle(angle, axis)) self.assertIsInstance(edge, AngleEdge) self.assertEqual(angle, edge.data.angle) np.testing.assert_almost_equal(axis, edge.data.axis.components) def test_spline(self): points = [[0.3, 0.25, 0], [0.6, 0.1, 0], [0.3, 0.25, 0]] edg = factory.create(self.vertex_1, self.vertex_2, edges.Spline(points)) self.assertIsInstance(edg, SplineEdge) np.testing.assert_almost_equal(points, edg.point_array) def test_polyline(self): points = [[0.3, 0.25, 0], [0.6, 0.1, 0], [0.3, 0.25, 0]] edg = factory.create(self.vertex_1, self.vertex_2, edges.PolyLine(points)) self.assertIsInstance(edg, SplineEdge) np.testing.assert_almost_equal(points, edg.point_array) def test_project_edge_single(self): label = "terrain" edg = factory.create(self.vertex_1, self.vertex_2, edges.Project(label)) self.assertIsInstance(edg, ProjectEdge) self.assertListEqual(edg.data.label, [label]) def test_project_edge_multi(self): label = ["terrain", "walls"] edg = factory.create(self.vertex_1, self.vertex_2, edges.Project(label)) self.assertIsInstance(edg, ProjectEdge) self.assertListEqual(edg.data.label, label) class EdgeValidityTests(unittest.TestCase): """Exclusive tests of Edge.is_valid property""" def get_edge(self, data: edges.EdgeData) -> Edge: """A shortcut to factory method""" return factory.create(Vertex([0, 0, 0], 0), Vertex([1, 0, 0], 1), data) def test_degenerate(self): """An edge between two vertices at the same point""" edge = factory.create(Vertex([0, 0, 0], 0), Vertex([0, 0, 0], 1), edges.Arc([1, 1, 1])) self.assertFalse(edge.is_valid) def test_line_edge(self): """line.is_valid""" # always false because lines need not be included in blockMeshDict, thus # they must be dropped self.assertFalse(self.get_edge(edges.Line()).is_valid) def test_valid_arc(self): """arc.is_valid""" self.assertTrue(self.get_edge(edges.Arc([0.5, 0.2, 0])).is_valid) def test_invalid_edge_creation_points(self): with self.assertRaises(EdgeCreationError): SplineEdge( Vertex([0, 0, 0], 0), Point([1, 1, 1]), # type: ignore # must be vertex, not point! edges.Spline( [ [0, 0, 0], [0, 0, 0], [0, 0, 0], ] ), ) def test_invalid_arc(self): """Arc from three collinear points""" self.assertFalse(self.get_edge(edges.Arc([0.5, 0, 0])).is_valid) def test_invalid_origin(self): """Catch exceptions raised when calculating arc point from the 'origin' alternative""" with self.assertRaises(ValueError): edge = self.get_edge(edges.Origin([0.5, 0, 0])) _ = edge.is_valid def test_valid_origin(self): """A normal arc edge""" self.assertTrue(self.get_edge(edges.Arc([0.5, 0.1, 0])).is_valid) def test_invalid_angle(self): """Catch exceptions raised whtn calculating arc point from the 'angle' alternative""" with self.assertRaises(ValueError): edge = self.get_edge(edges.Angle(0, [0, 0, 1])) _ = edge.is_valid def test_valid_angle(self): """A normal angle edge""" self.assertTrue(self.get_edge(edges.Angle(np.pi, [0, 0, 1])).is_valid) def test_valid_spline(self): """Spline edges are always valid""" self.assertTrue(self.get_edge(edges.Spline([[0, 0, 0], [0, 0, 0], [0, 0, 0]])).is_valid) def test_valid_polyline(self): """Same as spline, always valid""" self.assertTrue(self.get_edge(edges.PolyLine([[0, 0, 0], [0, 0, 0], [0, 0, 0]])).is_valid) def test_valid_project(self): """Projected edges cannot be checked for validity, therefore... they are assumed valid""" self.assertTrue(self.get_edge(edges.Project(["terrain", "walls"])).is_valid) class EdgeLengthTests(unittest.TestCase): """Various edge's lengths""" def get_edge(self, data: edges.EdgeData) -> Edge: """A shortcut to factory method""" return factory.create(Vertex([0, 0, 0], 0), Vertex([1, 0, 0], 1), data) def test_degenerate_arc(self): """Length of an Arc edge with three collinear points""" self.assertEqual(self.get_edge(edges.Arc([0.5, 0, 0])).length, 1) def test_arc_edge(self): """Length of a classical arc edge""" self.assertAlmostEqual(self.get_edge(edges.Arc([0.5, 0.5, 0])).length, 0.5 * np.pi) def test_origin_edge(self): """Length of the 'origin' edge""" self.assertAlmostEqual(self.get_edge(edges.Origin([0.5, -0.5, 0])).length, 2**0.5 * np.pi / 4) def test_spline_edge(self): """Length of the 'spline' edge - the segments, actually""" self.assertEqual(self.get_edge(edges.Spline([[1, 0, 0], [1, 1, 0]])).length, 3) def test_poly_edge(self): """Length of the 'polyLine' edge - accurately""" self.assertEqual(self.get_edge(edges.PolyLine([[1, 0, 0], [1, 1, 0]])).length, 3) def test_project_edge(self): """Length of the 'project' edge is equal to a line""" self.assertEqual(self.get_edge(edges.Project("terrain")).length, 1) def test_flattened_edge(self): """A 'flattened' origin edge is shorter""" self.assertLess( self.get_edge(edges.Origin([0.5, -0.5, 0], 2)).length, self.get_edge(edges.Origin([0.5, -0.5, 0], 1)).length ) class AlternativeArcTests(unittest.TestCase): """Origin and Axis arc specification""" unit_sq_corner = f.vector(2**0.5 / 2, 2**0.5 / 2, 0) def test_arc_mid(self): center = f.vector(0, 0, 0) edge_point_1 = f.vector(1, 0, 0) edge_point_2 = f.vector(0, 1, 0) np.testing.assert_array_almost_equal(f.arc_mid(center, edge_point_1, edge_point_2), self.unit_sq_corner) def test_arc_from_theta(self): edge_point_1 = f.vector(0, 1, 0) edge_point_2 = f.vector(1, 0, 0) angle = np.pi / 2 axis = f.vector(0, 0, -1) np.testing.assert_array_almost_equal( arc_from_theta(edge_point_1, edge_point_2, angle, axis), self.unit_sq_corner ) def test_arc_from_origin(self): edge_point_1 = f.vector(0, 1, 0) edge_point_2 = f.vector(1, 0, 0) center = f.vector(0, 0, 0) np.testing.assert_array_almost_equal(arc_from_origin(edge_point_1, edge_point_2, center), self.unit_sq_corner) def test_arc_from_origin_warn(self): edge_point_1 = f.vector(0, 1, 0) edge_point_2 = f.vector(1.1, 0, 0) center = f.vector(0, 0, 0) with self.assertWarns(Warning): adjusted_point = arc_from_origin(edge_point_1, edge_point_2, center) expected_point = f.vector(0.75743894, 0.72818283, 0) np.testing.assert_array_almost_equal(adjusted_point, expected_point) class EdgeDescriptionTests(unittest.TestCase): """Tests of edge outputs""" def get_edge(self, data: edges.EdgeData) -> Edge: """A shortcut to factory method""" return factory.create(Vertex([0, 0, 0], 0), Vertex([1, 0, 0], 1), data) def test_line_description(self): """Line has no description as it is not valid anyway""" self.assertFalse(self.get_edge(edges.Line()).description) def test_arc_description(self): """Classic arc description""" self.assertEqual( self.get_edge(edges.Arc([0.5, 0.1, 0])).description.strip(), "arc 0 1 (0.50000000 0.10000000 0.00000000)" ) def test_angle_description(self): self.assertIn( "arc 0 1 (0.50000000 -0.50000000 0.00000000)", self.get_edge(edges.Angle(np.pi, [0, 0, 1])).description ) def test_origin_description(self): self.assertIn( "arc 0 1 (0.50000000 0.20710678 0.00000000)", self.get_edge(edges.Origin([0.5, -0.5, 0])).description ) def test_spline_description(self): self.assertEqual( self.get_edge(edges.Spline([[0, 1, 0], [1, 1, 0]])).description, "\tspline 0 1 ((0.00000000 1.00000000 0.00000000) (1.00000000 1.00000000 0.00000000))", ) def test_project_description_single(self): """Projection to a single geometry""" self.assertEqual(self.get_edge(edges.Project("terrain")).description, "\tproject 0 1 (terrain)") def test_project_description_double(self): """Projection to two geometries""" self.assertEqual( self.get_edge(edges.Project(["terrain", "walls"])).description, "\tproject 0 1 (terrain walls)" ) class OnCurveEdgeTests(unittest.TestCase): def setUp(self): self.vertex_1 = Vertex([0, 0, 0], 0) self.vertex_2 = Vertex([1, 0, 0], 1) self.points = [ [0.05, 0.05, 0], # vertex_1 but not exact [0.25, 0.2, 0], [0.5, 0.3, 0], [0.75, 0.2, 0], [1, 0, 0], # vertex_2, exact ] self.curve = LinearInterpolatedCurve(self.points) @property def edge(self) -> OnCurveEdge: return OnCurveEdge(self.vertex_1, self.vertex_2, edges.OnCurve(self.curve)) def test_length(self): self.assertGreater(self.edge.length, 1) def test_param_start(self): self.assertAlmostEqual(self.edge.param_start, 0, places=5) def test_param_end(self): self.assertAlmostEqual(self.edge.param_end, 1, places=5) def test_point_array(self): self.assertEqual(len(self.edge.point_array), self.edge.data.n_points) def test_representation(self): self.assertEqual(self.edge.representation, "spline") ================================================ FILE: tests/test_items/test_patch.py ================================================ from classy_blocks.cbtyping import OrientType from classy_blocks.items.patch import Patch from classy_blocks.items.side import Side from tests.fixtures.block import BlockTestCase class PatchTests(BlockTestCase): def setUp(self): self.name = "test" @property def patch(self) -> Patch: """The test subject""" return Patch(self.name) def get_side(self, index: int, orient: OrientType) -> Side: """Creates a side on 'orient' from block at 'index'""" return Side(orient, self.make_vertices(index)) def test_add_side(self): """Add a single side to block""" patch = self.patch patch.add_side(self.get_side(0, "left")) self.assertEqual(len(patch.sides), 1) def test_add_equal_sides(self): """Add the same side twice""" patch = self.patch patch.add_side(self.get_side(0, "left")) with self.assertWarns(Warning): patch.add_side(self.get_side(0, "left")) self.assertEqual(len(patch.sides), 1) ================================================ FILE: tests/test_items/test_side.py ================================================ from classy_blocks.base.exceptions import SideCreationError from classy_blocks.items.side import Side from classy_blocks.items.vertex import Vertex from tests.fixtures.block import BlockTestCase class SideTests(BlockTestCase): def test_create_invalid_num_of_vertices(self): """Attempt to pass only side vertices instead of the whole set""" with self.assertRaises(SideCreationError): Side("left", [Vertex([0, 0, 0], 0)]) # missing 7 vertices def test_create_success(self): """Create a Side object and test its contents""" vertices = self.make_vertices(0) side = Side("bottom", vertices) self.assertListEqual(side.vertices, [vertices[i] for i in (0, 1, 2, 3)]) def test_equal(self): """Two coincident sides from different blocks are equal""" side_1 = Side("right", self.make_vertices(0)) side_2 = Side("left", self.make_vertices(1)) self.assertTrue(side_1 == side_2) ================================================ FILE: tests/test_items/test_vertex.py ================================================ import numpy as np from classy_blocks.base import transforms as tr from classy_blocks.base.exceptions import PointCreationError from classy_blocks.items.vertex import Vertex from classy_blocks.util import constants from classy_blocks.write.formats import format_vertex from tests.fixtures.data import DataTestCase class VertexTests(DataTestCase): """Vertex object""" def test_assert_3d(self): """Raise an exception if the point is not in 3D space""" with self.assertRaises(PointCreationError): Vertex([0, 0], 0) def test_translate_int(self): """Vertex translation with integer delta""" delta = [1.0, 0.0, 0.0] np.testing.assert_array_almost_equal(Vertex([0, 0, 0], 0).translate(delta).position, delta) def test_translate_float(self): """Vertex translation with float delta""" coords = [0.0, 0.0, 0.0] delta = [1.0, 0.0, 0.0] np.testing.assert_array_almost_equal(Vertex(coords, 0).translate(delta).position, delta) def test_rotate(self): """Rotate a point""" coords = [1.0, 0.0, 0.0] np.testing.assert_array_almost_equal( Vertex(coords, 0).rotate(np.pi / 2, [0, 0, 1], [0, 0, 0]).position, [0, 1, 0] ) def test_scale(self): """Scale a point""" coords = [1.0, 0.0, 0.0] np.testing.assert_array_almost_equal(Vertex(coords, 0).scale(2, [0, 0, 0]).position, [2, 0, 0]) def test_inequal(self): """The __eq__ method returns False""" point_1 = Vertex([0, 0, 0], 0) point_2 = Vertex([0, 0, 0 + 2 * constants.TOL], 1) self.assertFalse(point_1 == point_2) def test_description_plain(self): """A Rudimentary Vertex description""" v = Vertex([0, 0, 0], 0) self.assertEqual(format_vertex(v), "(0.00000000 0.00000000 0.00000000) // 0") def test_project_single(self): """Add a single geometry to project to""" v = Vertex([0.0, 0.0, 0.0], 0) v.project("terrain") expected = "project (0.00000000 0.00000000 0.00000000) (terrain) // 0" self.assertEqual(format_vertex(v), expected) def test_project_multiple(self): """Add a single geometry to project to""" v = Vertex([0.0, 0.0, 0.0], 0) v.project(["terrain", "walls", "border"]) expected = "project (0.00000000 0.00000000 0.00000000) (border terrain walls) // 0" self.assertEqual(format_vertex(v), expected) def test_multitransform(self): """Use the Transformation class for multiple transforms""" v = Vertex([0, 1, 1], 0) v.transform( [tr.Rotation([0, 0, 1], -np.pi / 2, [0, 0, 0]), tr.Scaling(2, [0, 0, 0]), tr.Translation([-2, 0, -2])] ) np.testing.assert_array_almost_equal(v.position, [0, 0, 0]) ================================================ FILE: tests/test_items/test_wire.py ================================================ import copy from classy_blocks.cbtyping import DirectionType from classy_blocks.items.vertex import Vertex from classy_blocks.items.wires.wire import Wire from tests.fixtures.data import DataTestCase class WireTests(DataTestCase): """Tests of Pair object""" def setUp(self) -> None: block = self.get_single_data(0) self.vertices = [Vertex(block.points[i], i) for i in range(8)] self.corner_1 = 1 self.corner_2 = 2 self.axis: DirectionType = 1 # make sure corners and axis are consistent @property def wire(self) -> Wire: """The test subject""" return Wire(self.vertices, self.axis, self.corner_1, self.corner_2) def test_coincident_aligned(self): """Coincident pair (__eq__()) with an aligned pair""" wire_1 = self.wire wire_2 = copy.copy(self.wire) self.assertTrue(wire_1.is_coincident(wire_2)) def test_coincident_inverted(self): """Coincident pair (__eq__()) with an inverted pair""" wire_1 = self.wire wire_2 = copy.copy(self.wire) # invert the other one wire_2.vertices.reverse() self.assertTrue(wire_1.is_coincident(wire_2)) def test_not_coincident(self): """Non-coincident pair""" wire_1 = self.wire self.corner_1 = 0 self.corner_2 = 1 self.axis = 0 wire_2 = self.wire self.assertFalse(wire_1 == wire_2) def test_is_aligned_exception(self): """Raise an exception if pairs are not equal""" wire_1 = self.wire self.corner_1 = 0 self.corner_2 = 1 self.axis = 0 wire_2 = self.wire self.assertFalse(wire_1 == wire_2) with self.assertRaises(RuntimeError): wire_1.is_aligned(wire_2) def test_is_aligned(self): """Alignment: the same""" wire_1 = self.wire wire_2 = copy.copy(self.wire) self.assertTrue(wire_1.is_aligned(wire_2)) def test_is_inverted(self): """Alignment: opposite""" wire_1 = self.wire wire_2 = copy.copy(self.wire) wire_2.vertices.reverse() self.assertFalse(wire_1.is_aligned(wire_2)) def test_add_inline_duplicate(self): wire = self.wire wire.add_inline(wire) self.assertEqual(len(wire.after), 0) self.assertEqual(len(wire.before), 0) ================================================ FILE: tests/test_lists/__init__.py ================================================ """Unit test package for classy_blocks.""" ================================================ FILE: tests/test_lists/test_edge_list.py ================================================ from classy_blocks.base.exceptions import EdgeNotFoundError from classy_blocks.construct.edges import Arc, PolyLine, Project, Spline from classy_blocks.construct.flat.face import Face from classy_blocks.construct.operations.revolve import Revolve from classy_blocks.construct.point import Point from classy_blocks.items.vertex import Vertex from classy_blocks.lists.edge_list import EdgeList from classy_blocks.lists.vertex_list import VertexList from tests.fixtures.data import DataTestCase class EdgeListTests(DataTestCase): def setUp(self): self.blocks = DataTestCase.get_all_data() self.vl = VertexList([]) self.el = EdgeList() def get_vertices(self, index: int) -> list[Vertex]: vertices = [] for point in self.get_single_data(index).points: vertices.append(self.vl.add_duplicated(Point(point), [])) return vertices def test_find_existing(self): """Find an existing edge""" vertices = self.get_vertices(0) edge = self.el.add(vertices[0], vertices[1], Arc([0.5, 0.5, 0])) self.assertEqual(self.el.find(vertices[0], vertices[1]), edge) def test_find_existing_invertex(self): """Find an existing edge with inverted vertices""" vertices = self.get_vertices(0) edge = self.el.add(vertices[0], vertices[1], Arc([0.5, 0.5, 0])) self.assertEqual(self.el.find(vertices[1], vertices[0]), edge) def test_find_nonexisting(self): """Raise an EdgeNotFoundError for a non-existing edge""" vertices = self.get_vertices(0) edge = self.el.add(vertices[0], vertices[1], Arc([0.5, 0.5, 0])) with self.assertRaises(EdgeNotFoundError): self.assertEqual(self.el.find(vertices[1], vertices[2]), edge) def test_add_new(self): """Add an edge when no such thing exists""" vertices = self.get_vertices(0) self.el.add(vertices[0], vertices[1], Arc([0.5, 0.5, 0])) self.assertEqual(len(self.el.edges), 1) def test_add_existing(self): """Add the same edges twice""" vertices = self.get_vertices(0) self.el.add(vertices[0], vertices[1], Arc([0.5, 0.5, 0])) self.el.add(vertices[0], vertices[1], Arc([0.5, 0.5, 0])) self.assertEqual(len(self.el.edges), 1) def test_add_invalid(self): """Add a circular edge that's actually a line""" vertices = self.get_vertices(0) self.el.add(vertices[0], vertices[1], Arc([0.5, 0, 0])) self.assertEqual(len(self.el.edges), 0) def test_vertex_order(self): """Assure vertices maintain consistent order""" vertices = self.get_vertices(0) # add some custom edges self.el.add(vertices[2], vertices[3], Spline([[0.7, 1.3, 0], [0.3, 1.3, 0]])) self.el.add(vertices[6], vertices[7], PolyLine([[0.7, 1.1, 1], [0.3, 1.1, 1]])) self.assertEqual(len(self.el.edges), 2) self.assertEqual(self.el.edges[(2, 3)].vertex_1.index, 2) self.assertEqual(self.el.edges[(2, 3)].vertex_2.index, 3) self.assertEqual(self.el.edges[(6, 7)].vertex_1.index, 6) self.assertEqual(self.el.edges[(6, 7)].vertex_2.index, 7) def test_add_from_operations(self): """Add edges from an operation""" face = Face([[0, 0, 0], [1, 0, 0], [1, 1, 0], [0, 1, 0]], [None, Project("terrain"), None, None]) revolve = Revolve(face, 1, [0, 0, 1], [-1, 0, 0]) for point in revolve.point_array: self.vl.add_duplicated(Point(point), []) self.el.add_from_operation(self.vl.vertices, revolve) self.assertEqual(len(self.el.edges), 6) # 4 arcs from a revolve and 2 projections from faces, # 6 'line' edges no_arc = 0 no_project = 0 for edge in self.el.edges.values(): if edge.kind == "angle": no_arc += 1 elif edge.kind == "project": no_project += 1 self.assertEqual(no_arc, 4) self.assertEqual(no_project, 2) ================================================ FILE: tests/test_lists/test_face_list.py ================================================ from parameterized import parameterized from classy_blocks.cbtyping import OrientType from classy_blocks.items.side import Side from classy_blocks.lists.face_list import FaceList, ProjectedFace from tests.fixtures.block import BlockTestCase class ProjectedFaceTests(BlockTestCase): @property def side_1(self) -> Side: return Side("right", self.make_vertices(0)) @property def side_2(self) -> Side: return Side("left", self.make_vertices(1)) def test_equal(self): pface_1 = ProjectedFace(self.side_1, "terrain") pface_2 = ProjectedFace(self.side_2, "geometry") self.assertEqual(pface_1, pface_2) def test_not_equal(self): pface_1 = ProjectedFace(self.side_1, "terrain") # change a vertex to a third-party side_2 = self.side_2 side_2.vertices[1] = self.make_vertices(2)[-1] pface_2 = ProjectedFace(side_2, "terrain") self.assertNotEqual(pface_1, pface_2) class FaceListTests(BlockTestCase): def setUp(self): self.flist = FaceList() self.index = 0 self.vertices = self.make_vertices(self.index) self.loft = self.make_loft(self.index) def get_side(self, block: int, orient: OrientType) -> Side: return Side(orient, self.make_vertices(block)) def test_find_existing_success(self): self.loft.project_side("left", "geometry") self.flist.add(self.vertices, self.loft) side = self.get_side(0, "left") self.assertTrue(self.flist.find_existing(side)) def test_find_existing_fail(self): self.flist.add(self.vertices, self.loft) side = self.get_side(0, "left") self.assertFalse(self.flist.find_existing(side)) @parameterized.expand( ( ("left",), ("right",), ("front",), ("back",), ("top",), ("bottom",), ) ) def test_capture_sides(self, orient): """Capture loft's projected side faces""" self.loft.project_side(orient, "terrain") self.flist.add(self.vertices, self.loft) self.assertEqual(self.flist.faces[0].label, "terrain") ================================================ FILE: tests/test_lists/test_patch_list.py ================================================ from classy_blocks.lists.patch_list import PatchList from tests.fixtures.block import BlockTestCase class PatchListTests(BlockTestCase): def setUp(self): self.plist = PatchList() self.vertices = self.make_vertices(0) self.loft = self.make_loft(0) self.name = "vessel" self.loft.set_patch(["left", "bottom", "front", "back", "right"], self.name) def test_get_new(self): """Add a new entry to patches dict when fetching a patch not entered before""" self.assertFalse("test" in self.plist.patches) self.plist.get("test") self.assertTrue("test" in self.plist.patches) def test_get_existing(self): """Fetch the same patch twice""" self.plist.get("test") self.assertEqual(self.plist.get("test"), self.plist.get("test")) def test_modify_type(self): """Modify patch type""" self.plist.modify("walls", "wall") self.assertEqual(self.plist.get("walls").kind, "wall") def test_add(self): """Add an Operation""" self.plist.add(self.vertices, self.loft) self.assertEqual(len(self.plist.patches[self.name].sides), 5) ================================================ FILE: tests/test_mesh.py ================================================ import numpy as np from classy_blocks.construct.operations.box import Box from classy_blocks.construct.shapes.cylinder import Cylinder from classy_blocks.construct.shapes.sphere import EighthSphere from classy_blocks.mesh import Mesh from tests.fixtures.block import BlockTestCase class MeshTests(BlockTestCase): def setUp(self): self.mesh = Mesh() def test_is_not_assembled(self): """A fresh mesh: not assembled""" self.assertFalse(self.mesh.is_assembled) def test_is_assembled(self): """An assembled mesh""" # If any processing has been done self.mesh.add(self.make_loft(0)) self.mesh.assemble() self.assertTrue(self.mesh.is_assembled) def test_assemble(self): """Add a couple of operations and run assemble""" for i in range(3): self.mesh.add(self.make_loft(i)) self.mesh.assemble() self.assertEqual(len(self.mesh.blocks), 3) def test_merged_multi(self): """Face merge multiple touching blocks""" # a 2x2 array of boxes center = [0.0, 0.0, 0.0] # |----|----| # | 01 | 00 | # |----C----| # | 11 | 10 | # |----|----| box_00 = Box(center, [1, 1, 1]) box_01 = Box(center, [-1, 1, 1]) box_11 = Box(center, [-1, -1, 1]) box_10 = Box(center, [1, -1, 1]) box_00.set_patch("left", "left_00") box_00.set_patch("front", "front_00") box_01.set_patch("right", "right_01") box_01.set_patch("front", "front_01") box_11.set_patch("right", "right_11") box_11.set_patch("back", "back_11") box_10.set_patch("left", "left_10") box_10.set_patch("back", "back_10") self.mesh.add(box_00) self.mesh.add(box_01) self.mesh.add(box_11) self.mesh.add(box_10) self.mesh.merge_patches("left_00", "right_01") self.mesh.merge_patches("front_00", "back_10") self.mesh.merge_patches("front_01", "back_11") self.mesh.merge_patches("left_10", "right_11") self.mesh.assemble() # all vertices must be duplicated self.assertEqual(len(self.mesh.vertices), 32) def test_cell_zone_operation(self): """Assign cell zone from an operation""" box = Box([0, 0, 0], [1, 1, 1]) box.set_cell_zone("mrf") self.mesh.add(box) self.mesh.assemble() for block in self.mesh.blocks: self.assertEqual(block.cell_zone, "mrf") def test_cell_zone_shape(self): """Assign cell zone from an operation""" cyl = Cylinder([0, 0, 0], [1, 0, 0], [0, 1, 0]) cyl.set_cell_zone("mrf") self.mesh.add(cyl) self.mesh.assemble() for block in self.mesh.blocks: self.assertEqual(block.cell_zone, "mrf") def test_chop_shape_axial(self): """Axial chop of a shape""" cyl = Cylinder([0, 0, 0], [1, 0, 0], [0, 1, 0]) cyl.chop_axial(count=10) cyl.chop_radial(count=1) cyl.chop_tangential(count=1) self.mesh.add(cyl) self.mesh.assemble() self.mesh.grade() for block in self.mesh.blocks: self.assertEqual(block.axes[2].count, 10) def test_chop_cylinder_tangential(self): """Cylinder chops differently from rings""" cyl = Cylinder([0, 0, 0], [1, 0, 0], [0, 1, 0]) cyl.chop_tangential(count=10) cyl.chop_radial(count=1) cyl.chop_axial(count=1) self.mesh.add(cyl) self.mesh.assemble() self.mesh.grade() for block in self.mesh.blocks: self.assertEqual(block.axes[1].count, 10) def test_set_default_patch(self): self.mesh.set_default_patch("terrain", "wall") self.assertDictEqual(self.mesh.settings.default_patch, {"name": "terrain", "kind": "wall"}) def test_modify_patch(self): self.mesh.modify_patch("terrain", "wall", ["transform none"]) self.assertEqual(self.mesh.settings.patch_settings["terrain"], ["wall", "transform none"]) def test_operations(self): """Add an op and a shape and check operation count""" # a single block box = Box([0, 0, 0], [1, 1, 1]) self.mesh.add(box) # 12 blocks cylinder = Cylinder([2, 0, 0], [3, 0, 0], [2, 1, 0]) self.mesh.add(cylinder) self.assertEqual(len(self.mesh.operations), 13) def test_with_geometry(self): esph = EighthSphere([0, 0, 0], [1, 0, 0], [0, 0, 1]) esph.chop_axial(count=10) esph.chop_radial(count=10) esph.chop_tangential(count=10) self.mesh.add(esph) self.mesh.assemble() self.assertEqual(len(self.mesh.settings.geometry), 1) def test_backport(self): box = Box([0, 0, 0], [1, 1, 1]) self.mesh.add(box) self.mesh.assemble() self.mesh.vertices[0].move_to([-1, -1, -1]) self.mesh.backport() np.testing.assert_array_equal(box.point_array[0], [-1, -1, -1]) def test_backport_empty(self): self.mesh.add(self.make_loft(0)) with self.assertRaises(RuntimeError): self.mesh.backport() def test_delete(self): boxes = [ Box([0, 0, 0], [1, 1, 1]), Box([1, 0, 0], [2, 1, 1]), Box([0, 1, 0], [1, 2, 1]), Box([1, 1, 0], [2, 2, 1]), ] for box in boxes: self.mesh.add(box) self.mesh.delete(boxes[0]) self.mesh.assemble() self.assertFalse(self.mesh.blocks[0].visible) def test_assemble_noncoincident(self): """Assemble a mesh with non-coincident vertices""" box = Box([0, 0, 0], [1, 1, 1]) box.set_patch("left", "left_patch") box.set_patch("front", "front_patch") self.mesh.add(box) box2 = Box([1.001, 1.001, 1.001], [2, 2, 2]) box2.set_patch("right", "right_patch") box2.set_patch("back", "back_patch") self.mesh.add(box2) self.mesh.assemble(merge_tol=0.002) self.assertEqual(len(self.mesh.vertices), 15) ================================================ FILE: tests/test_modify/__init__.py ================================================ ================================================ FILE: tests/test_modify/test_finder.py ================================================ from parameterized.parameterized import parameterized from classy_blocks.construct.shapes.cylinder import Cylinder from classy_blocks.mesh import Mesh from classy_blocks.modify.find.geometric import GeometricFinder from classy_blocks.modify.find.shape import RoundSolidFinder from tests.fixtures.block import BlockTestCase class GeometricFinderTests(BlockTestCase): def setUp(self): super().setUp() self.mesh = Mesh() loft = self.make_loft(0) self.mesh.add(loft) self.mesh.assemble() self.finder = GeometricFinder(self.mesh) def test_by_position_close(self): """Find one exact vertex""" found_vertices = self.finder.find_in_sphere([0, 0, 0]) self.assertSetEqual(found_vertices, {self.mesh.vertices[0]}) def test_by_position_close_count(self): """Find one exact vertex""" found_vertices = self.finder.find_in_sphere([0, 0, 0]) self.assertEqual(len(found_vertices), 1) def test_by_position_far(self): """Find vertices on cube""" found_vertices = self.finder.find_in_sphere([0, 0, 0], 1.0001) # the loft #0 is a cube self.assertEqual(len(found_vertices), 4) def test_by_position_all(self): """Find all vertices of this mesh""" found_vertices = self.finder.find_in_sphere([0, 0, 0], 2) self.assertEqual(len(found_vertices), 8) def test_on_plane_bottom(self): found_vertices = self.finder.find_on_plane([0, 0, 0], [0, 0, 1]) self.assertEqual(len(found_vertices), 4) def test_on_plane_top(self): found_vertices = self.finder.find_on_plane([0, 0, 1], [0, 0, 1]) self.assertEqual(len(found_vertices), 4) class RoundSolidShapeFinderTests(BlockTestCase): def setUp(self): super().setUp() self.mesh = Mesh() self.cylinder = Cylinder([0, 0, 0], [1, 0, 0], [0, 1, 0]) self.mesh.add(self.cylinder) self.mesh.assemble() self.finder = RoundSolidFinder(self.mesh, self.cylinder) @parameterized.expand(((True,), (False,))) def test_core_count(self, end_face): """Number of vertices on core""" vertices = self.finder.find_core(end_face) self.assertEqual(len(vertices), 9) @parameterized.expand(((True,), (False,))) def test_shell_count(self, end_face): """Number of vertices on shell""" vertices = self.finder.find_shell(end_face) self.assertEqual(len(vertices), 8) ================================================ FILE: tests/test_modify/test_reorient.py ================================================ import unittest import numpy as np from parameterized import parameterized from classy_blocks.construct.operations.loft import Loft from classy_blocks.modify.reorient.viewpoint import Quadrangle, Triangle, ViewpointReorienter from classy_blocks.util import functions as f from tests.fixtures.block import BlockTestCase class TriangleTests(unittest.TestCase): def setUp(self): self.triangle = Triangle([f.vector(0, 0, 0), f.vector(1, 0, 0), f.vector(0, 1, 0)]) def test_normal(self): np.testing.assert_array_equal(self.triangle.normal, [0, 0, 1]) def test_center(self): np.testing.assert_array_almost_equal(self.triangle.center, [1 / 3, 1 / 3, 0]) def test_flip(self): self.triangle.flip() np.testing.assert_array_equal(self.triangle.normal, [0, 0, -1]) def test_orient_no_change(self): self.triangle.orient(f.vector(-0.5, -0.5, -0.5)) np.testing.assert_array_almost_equal(self.triangle.normal, [0, 0, 1]) def test_orient_change(self): self.triangle.orient(f.vector(0.5, 0.5, 0.5)) np.testing.assert_array_almost_equal(self.triangle.normal, [0, 0, -1]) class QuadrangleTests(unittest.TestCase): def setUp(self): super().setUp() # lower-left part of quad self.tri_1 = Triangle([f.vector(0, 0, 0), f.vector(1, 0, 0), f.vector(1, 1, 0)]) # upper-right part self.tri_2 = Triangle([f.vector(0, 0, 0), f.vector(1, 1, 0), f.vector(0, 1, 0)]) def test_get_common_points(self): list_1 = self.tri_1.points list_2 = self.tri_2.points common_points = Quadrangle.get_common_points(list_1, list_2) np.testing.assert_array_equal(common_points, [[0, 0, 0], [1, 1, 0]]) def test_get_unique_points(self): list_1 = self.tri_1.points list_2 = self.tri_2.points unique_points = Quadrangle.get_unique_points(list_1, list_2) np.testing.assert_array_almost_equal(unique_points, [[1, 0, 0], [0, 1, 0]]) def test_point_count(self): quad = Quadrangle([self.tri_1, self.tri_2]) self.assertEqual(len(quad.points), 4) class ViewpointReorienterTests(BlockTestCase): def setUp(self): self.reorienter = ViewpointReorienter([0.5, -10, 0.5], [0.5, 0.5, 10]) @property def loft(self) -> Loft: return self.make_loft(0) @parameterized.expand( ( ( "x", np.pi / 2, ), ( "x", np.pi, ), ( "x", 3 * np.pi / 2, ), ( "y", np.pi / 2, ), ( "y", np.pi, ), ( "y", 3 * np.pi / 2, ), ( "z", np.pi / 2, ), ( "z", np.pi, ), ( "z", 3 * np.pi / 2, ), ) ) def test_sort_regular(self, axis, angle): axis = { "x": [1, 0, 0], "y": [0, 1, 0], "z": [0, 0, 1], }[axis] loft = self.loft loft.rotate(angle, axis, self.loft.center) self.reorienter.reorient(loft) np.testing.assert_array_almost_equal( loft.point_array, [[0, 0, 0], [1, 0, 0], [1, 1, 0], [0, 1, 0], [0, 0, 1], [1, 0, 1], [1, 1, 1], [0, 1, 1]], ) def test_translated_face(self): loft = self.loft original_face = loft.top_face.copy() loft.top_face.translate([1, 1, 0]) # make sure top face is still on top self.reorienter.reorient(loft) np.testing.assert_array_equal(loft.top_face.point_array, original_face.translate([1, 1, 0]).point_array) def test_rotated_face(self): loft = self.loft center = loft.center original_face = loft.top_face.copy() loft.top_face.rotate(0.9 * np.pi / 4, [0, 0, 1], center) # make sure top face is still on top self.reorienter.reorient(loft) np.testing.assert_array_equal( loft.top_face.point_array, original_face.rotate(0.9 * np.pi / 4, [0, 0, 1], center).point_array ) def test_moved_point(self): loft = self.loft loft.top_face.points[2].translate([2, 2, 2]) self.reorienter.reorient(loft) np.testing.assert_array_almost_equal(loft.top_face.point_array, [[0, 0, 1], [1, 0, 1], [3, 3, 3], [0, 1, 1]]) ================================================ FILE: tests/test_optimize/__init__.py ================================================ ================================================ FILE: tests/test_optimize/optimize_fixtures.py ================================================ import unittest from typing import get_args import numpy as np from classy_blocks.cbtyping import DirectionType from classy_blocks.construct.operations.box import Box from classy_blocks.mesh import Mesh from classy_blocks.modify.find.geometric import GeometricFinder from classy_blocks.optimize.grid import QuadGrid class SketchTestsBase(unittest.TestCase): @property def positions(self): return np.array( [ [0, 0, 0], [1, 0, 0], [2, 0, 0], [0, 1, 0], [1.2, 1.6, 0], # a moved vertex, should be [1, 1, 0] [2, 1, 0], [0, 2, 0], [1, 2, 0], [2, 2, 0], ] ) @property def quads(self): return [ [0, 1, 4, 3], [1, 2, 5, 4], [3, 4, 7, 6], [4, 5, 8, 7], ] @property def grid(self): return QuadGrid(self.positions, self.quads) class BoxTestsBase(unittest.TestCase): def setUp(self): self.mesh = Mesh() # generate a cube, consisting of 2x2x2 smaller cubes for x in (-1, 0): for y in (-1, 0): for z in (-1, 0): box = Box([x, y, z], [x + 1, y + 1, z + 1]) for axis in get_args(DirectionType): box.chop(axis, count=10) self.mesh.add(box) self.mesh.assemble() self.finder = GeometricFinder(self.mesh) def get_vertex(self, position): return next(iter(self.finder.find_in_sphere(position))) ================================================ FILE: tests/test_optimize/test_cell.py ================================================ import numpy as np from parameterized import parameterized from classy_blocks.base.exceptions import NoCommonSidesError from classy_blocks.optimize.cell import HexCell from tests.fixtures.mesh import MeshTestCase class CellTests(MeshTestCase): def setUp(self): super().setUp() @property def mesh_points(self): return np.array([vertex.position for vertex in self.mesh.vertices]) def get_cell(self, index: int) -> HexCell: return HexCell(0, self.mesh_points, self.mesh.blocks[index].indexes) @parameterized.expand( ( (0, 1, 4), (0, 2, 2), (1, 0, 4), (1, 2, 4), ) ) def test_common_vertices(self, index_1, index_2, count): cell_1 = self.get_cell(index_1) cell_2 = self.get_cell(index_2) self.assertEqual(len(cell_1.get_common_indexes(cell_2)), count) @parameterized.expand(((0, 0, 0), (0, 1, 1), (1, 1, 0), (1, 8, 1))) def test_get_corner(self, block, vertex, corner): cell = self.get_cell(block) self.assertEqual(cell.get_corner(vertex), corner) @parameterized.expand(((0, 1, "right"), (1, 0, "left"), (1, 2, "back"))) def test_get_common_side(self, index_1, index_2, orient): cell_1 = self.get_cell(index_1) cell_2 = self.get_cell(index_2) self.assertEqual(cell_1.get_common_side(cell_2), orient) def test_no_common_sides(self): with self.assertRaises(NoCommonSidesError): cell_1 = self.get_cell(0) cell_2 = self.get_cell(2) cell_1.get_common_side(cell_2) ================================================ FILE: tests/test_optimize/test_clamps.py ================================================ import unittest import numpy as np from classy_blocks.cbtyping import NPPointType from classy_blocks.construct.curves.analytic import AnalyticCurve from classy_blocks.optimize.clamps.curve import CurveClamp, LineClamp, RadialClamp from classy_blocks.optimize.clamps.free import FreeClamp from classy_blocks.optimize.clamps.surface import ParametricSurfaceClamp, PlaneClamp from classy_blocks.util import functions as f class ClampTestsBase(unittest.TestCase): def setUp(self): self.position = np.array([0, 0, 0]) class FreeClampTests(ClampTestsBase): def test_free_init(self): """Initialization of FreeClamp""" clamp = FreeClamp(self.position) np.testing.assert_array_equal(clamp.params, self.position) def test_free_update(self): """Update params of a free clamp""" clamp = FreeClamp(self.position) clamp.update_params([1, 0, 0]) np.testing.assert_array_equal(clamp.position, [1, 0, 0]) class CurveClampTests(ClampTestsBase): def setUp(self): super().setUp() def function(t: float) -> NPPointType: return f.vector(np.sin(t), np.cos(t), t) self.curve = AnalyticCurve(function, (0, 2 * np.pi)) def test_line_init(self): """Initialization of LineClamp""" clamp = LineClamp(self.position, [0, 0, 0], [1, 1, 1]) self.assertAlmostEqual(clamp.params[0], 0) def test_line_init_far(self): """Initialization that will yield t < 0""" clamp = LineClamp(self.position, [1, 1, 1], [2, 2, 2], (-100, 100)) self.assertAlmostEqual(clamp.params[0], -(3**0.5)) def test_line_value(self): clamp = LineClamp(self.position, [0, 0, 0], [1, 1, 1]) clamp.update_params([3**0.5 / 2]) np.testing.assert_array_almost_equal(clamp.position, [0.5, 0.5, 0.5]) def test_line_bounds_lower(self): position = [-1, -1, -1] clamp = LineClamp(position, [0, 0, 0], [1, 1, 1], (0, 1)) self.assertAlmostEqual(clamp.params[0], 0) def test_line_bounds_upper(self): position = [2, 2, 2] clamp = LineClamp(position, [0, 0, 0], [1, 1, 1], (0, 1)) self.assertAlmostEqual(clamp.params[0], 1) def test_analytic_init(self): clamp = CurveClamp(self.position, self.curve) self.assertAlmostEqual(clamp.params[0], 0, places=3) def test_analytic_init_noncoincident(self): position = [0, 0, 1] clamp = CurveClamp(position, self.curve) self.assertAlmostEqual(clamp.params[0], 1, places=3) def test_analytic_bounds_lower(self): position = [-1, -1, -1] clamp = CurveClamp(position, self.curve) self.assertAlmostEqual(clamp.params[0], 0, places=3) def test_analytic_bounds_upper(self): position = [0, 0, 2] self.curve.bounds = (0, 1) clamp = CurveClamp(position, self.curve) self.assertAlmostEqual(clamp.params[0], 1, places=3) def test_radial_init(self): position = [1, 0, 0] clamp = RadialClamp(position, [0, 0, -1], [0, 0, 1]) np.testing.assert_array_almost_equal(clamp.position, [1, 0, 0]) def test_radial_rotate(self): position = [1, 0, 0] clamp = RadialClamp(position, [0, 0, -1], [0, 0, 1]) clamp.update_params([np.pi / 2]) np.testing.assert_array_almost_equal(clamp.position, [0, 1, 0]) class SurfaceClampTests(ClampTestsBase): def setUp(self): super().setUp() def function(params) -> NPPointType: """A simple extruded sinusoidal surface""" u = params[0] v = params[1] return f.vector(u, v, np.sin(u)) self.function = function def test_plane_clamp(self): clamp = PlaneClamp(self.position, [0, 0, 0], [1, 1, 1]) np.testing.assert_array_almost_equal(clamp.params, [0, 0]) def test_plane_move_u(self): clamp = PlaneClamp(self.position, [0, 0, 0], [1, 0, 0]) clamp.update_params([1, 0]) self.assertAlmostEqual(self.position[0], 0) def test_plane_move_v(self): clamp = PlaneClamp(self.position, [0, 0, 0], [1, 0, 0]) clamp.update_params([0, 1]) self.assertAlmostEqual(self.position[0], 0) def test_plane_move_uv(self): clamp = PlaneClamp(self.position, [0, 0, 0], [1, 0, 0]) clamp.update_params([1, 1]) self.assertAlmostEqual(self.position[0], 0) def test_parametric_init(self): clamp = ParametricSurfaceClamp(self.position, self.function) np.testing.assert_array_almost_equal(clamp.params, [0, 0], decimal=3) def test_parametric_initial_unbounded(self): clamp = ParametricSurfaceClamp(self.position, self.function) np.testing.assert_array_almost_equal(clamp.initial_guess, [0, 0]) def test_parametric_initial_bounded(self): clamp = ParametricSurfaceClamp(self.position, self.function, [[0, 1], [0, 1]]) np.testing.assert_array_almost_equal(clamp.initial_guess, [0, 0]) def test_parametric_move(self): clamp = ParametricSurfaceClamp(self.position, self.function) clamp.update_params([np.pi / 2, 1]) np.testing.assert_array_almost_equal(clamp.position, [np.pi / 2, 1, 1]) def test_parametric_bounds_upper(self): position = [4, 4, 0] clamp = ParametricSurfaceClamp(position, self.function, [[0.0, np.pi], [0.0, np.pi]]) np.testing.assert_array_almost_equal(clamp.params, [np.pi, np.pi]) ================================================ FILE: tests/test_optimize/test_grid.py ================================================ import numpy as np from parameterized import parameterized from classy_blocks.construct.flat.sketches.disk import OneCoreDisk from classy_blocks.construct.flat.sketches.grid import Grid as GridSketch from classy_blocks.construct.stack import ExtrudedStack from classy_blocks.mesh import Mesh from classy_blocks.optimize.grid import HexGrid, QuadGrid from classy_blocks.util import functions as f from tests.fixtures.mesh import MeshTestCase from tests.test_optimize.optimize_fixtures import SketchTestsBase class HexGridTests(MeshTestCase): def get_grid(self, mesh: Mesh) -> HexGrid: points = np.array([vertex.position for vertex in mesh.vertices]) addresses = [block.indexes for block in mesh.blocks] return HexGrid(points, addresses) def setUp(self): super().setUp() self.grid = self.get_grid(self.mesh) def test_cells_quantity(self): self.assertEqual(len(self.grid.cells), len(self.mesh.blocks)) def test_junctions_quantity(self): self.assertEqual(len(self.grid.junctions), len(self.mesh.vertices)) def test_junction_boundary(self): # In this case, ALL junctions are on boundary for junction in self.grid.junctions: self.assertTrue(junction.is_boundary) def test_junction_internal(self): sketch = GridSketch([0, 0, 0], [1, 1, 0], 2, 2) stack = ExtrudedStack(sketch, 1, 2) mesh = Mesh() mesh.add(stack) mesh.assemble() grid = self.get_grid(mesh) for junction in grid.junctions: if f.norm(junction.point - f.vector(0.5, 0.5, 0.5)) < 0.01: self.assertFalse(junction.is_boundary) continue self.assertTrue(junction.is_boundary) @parameterized.expand(((0, 1), (1, 2), (2, 3), (3, 1), (4, 1), (5, 2), (6, 3), (7, 1))) def test_junction_cells(self, index, count): """Each junction contains cells that include that vertex""" self.assertEqual(len(self.grid.junctions[index].cells), count) @parameterized.expand(((0, "right", 1), (1, "left", 0), (1, "back", 2), (2, "front", 1))) def test_cell_neighbours(self, parent, orient, neighbour): self.assertEqual(self.grid.cells[parent].neighbours[orient], self.grid.cells[neighbour]) @parameterized.expand(((0, 3), (1, 4), (2, 5), (3, 3))) def test_neighbours(self, junction, count): self.assertEqual(len(self.grid.junctions[junction].neighbours), count) class QuadGridTests(SketchTestsBase): def test_from_sketch(self): sketch = OneCoreDisk([0, 0, 0], [1, 0, 0], [0, 0, 1]) grid = QuadGrid.from_sketch(sketch) self.assertEqual(len(grid.cells), 5) self.assertEqual(len(grid.junctions), 8) @parameterized.expand( ( (0, 4), (1, 3), (2, 3), (3, 3), (4, 3), ) ) def test_neighbour_cells(self, i_cell, n_neighbours): sketch = OneCoreDisk([0, 0, 0], [1, 0, 0], [0, 0, 1]) grid = QuadGrid.from_sketch(sketch) neighbour_count = 0 for n in grid.cells[i_cell].neighbours.values(): if n is not None: neighbour_count += 1 self.assertEqual(neighbour_count, n_neighbours) def test_positions(self): np.testing.assert_equal(self.grid.points, self.positions) @parameterized.expand( ( (0, {8, 1}), (1, {0, 2, 6}), (2, {1, 3, 7}), (3, {2, 4}), (4, {3, 5, 7}), (5, {4, 6}), (6, {8, 1, 5, 7}), (7, {2, 4, 6}), (8, {0, 6}), ) ) def test_find_neighbours(self, i_junction, expected_neighbours): # A random blocking (quadding) positions = np.zeros((9, 3)) indexes = [[1, 2, 7, 6], [2, 3, 4, 7], [7, 4, 5, 6], [0, 1, 6, 8]] grid = QuadGrid(positions, indexes) self.assertSetEqual(expected_neighbours, {junction.index for junction in grid.junctions[i_junction].neighbours}) def test_fixed_points(self): # Monocylinder, core is quads[0] positions = np.zeros((8, 3)) indexes = [ [0, 1, 2, 3], [0, 4, 5, 1], [1, 5, 6, 2], [2, 6, 7, 3], [3, 7, 4, 0], ] grid = QuadGrid(positions, indexes) fixed_points = set() for cell in grid.cells: fixed_points.update(cell.boundary) self.assertSetEqual( fixed_points, {4, 5, 6, 7}, ) ================================================ FILE: tests/test_optimize/test_links.py ================================================ import unittest import numpy as np from parameterized import parameterized from classy_blocks.optimize.links import RotationLink, SymmetryLink, TranslationLink from classy_blocks.util import functions as f class TranslationLinkTests(unittest.TestCase): def test_translate(self): link = TranslationLink([0, 0, 0], [1, 1, 1]) link.leader = np.array([3, 3, 3]) link.update() np.testing.assert_almost_equal(link.follower, [4, 4, 4]) class RotationLinkTests(unittest.TestCase): def setUp(self): self.leader = np.array([1, 0, 0]) self.follower = np.array([0, 1, 0]) @parameterized.expand( ( ([0, 0, 0],), ([0, 0, 1],), ([0, 1, 0],), ([1, 1, 1],), ) ) def test_radius(self, origin): leader = self.leader + np.array(origin) follower = self.follower + np.array(origin) link = RotationLink(leader, follower, [0, 0, 1], origin) np.testing.assert_almost_equal(link._get_radius(link.leader), [1, 0, 0]) def test_rotate(self): link = RotationLink(self.leader, self.follower, [0, 0, 1], [0, 0, 0]) link.leader = np.array([0, 1, 0]) link.update() np.testing.assert_almost_equal(link.follower, [-1, 0, 0]) def test_rotate_negative(self): """Rotate in negative direction""" link = RotationLink(self.leader, self.follower, [0, 0, 1], [0, 0, 0]) link.leader = np.array([0, -1, 0]) link.update() np.testing.assert_almost_equal(link.follower, [1, 0, 0]) @parameterized.expand( ( # z-axis, origin ([0, 0, 1], [0, 0, 0], np.pi / 3), ([0, 0, 1], [0, 0, 0], np.pi / 4), ([0, 0, 1], [0, 0, 0], np.pi / 6), ([0, 0, 1], [0, 0, 0], -np.pi / 3), ([0, 0, 1], [0, 0, 0], -np.pi / 4), ([0, 0, 1], [0, 0, 0], -np.pi / 6), # -z axis, origin ([0, 0, -1], [0, 0, 0], np.pi / 3), ([0, 0, -1], [0, 0, 0], np.pi / 4), ([0, 0, -1], [0, 0, 0], np.pi / 6), ([0, 0, -1], [0, 0, 0], -np.pi / 3), ([0, 0, -1], [0, 0, 0], -np.pi / 4), ([0, 0, -1], [0, 0, 0], -np.pi / 6), # skewed axis, origin ([0, 1, 1], [0, 0, 0], np.pi / 2), ([0, 1, 1], [0, 0, 0], np.pi / 3), ([0, 1, 1], [0, 0, 0], np.pi / 4), ([0, 1, 1], [0, 0, 0], np.pi / 6), # skewed axis, different origin ([0, 1, 1], [-1, -1, -1], np.pi / 2), ([0, 1, 1], [-1, -1, -1], np.pi / 3), ([0, 1, 1], [-1, -1, -1], np.pi / 4), ([0, 1, 1], [-1, -1, -1], np.pi / 6), # negative angles ([0, 1, 1], [-1, -1, -1], -np.pi / 2), ([0, 1, 1], [-1, -1, -1], -np.pi / 3), ([0, 1, 1], [-1, -1, -1], -np.pi / 4), ([0, 1, 1], [-1, -1, -1], -np.pi / 6), ) ) def test_rotate_arbitrary(self, axis, origin, angle): link = RotationLink(self.leader, self.follower, axis, origin) orig_follower_pos = np.copy(self.follower) link.leader = f.rotate(link.leader, angle, axis, origin) link.update() np.testing.assert_almost_equal(link.follower, f.rotate(orig_follower_pos, angle, axis, origin)) def test_coincident(self): with self.assertRaises(ValueError): _ = RotationLink(self.leader, self.follower, [0, 0, 1], [1, 0, 0]) class SymmetryLinkTests(unittest.TestCase): def test_move(self): link = SymmetryLink([0, 0, 1], [0, 0, -1], [0, 0, 1], [0, 0, 0]) link.leader = np.array([0, 0, 2]) link.update() np.testing.assert_almost_equal(link.follower, [0, 0, -2]) def test_move_displaced_origin(self): link = SymmetryLink([0, 0, 1], [0, 0, -1], [0, 0, 1], [2, 0, 0]) link.leader = np.array([0, 0, 2]) link.update() np.testing.assert_almost_equal(link.follower, [0, 0, -2]) ================================================ FILE: tests/test_optimize/test_optimizer.py ================================================ import unittest import numpy as np from parameterized import parameterized from classy_blocks.base.exceptions import ClampExistsError from classy_blocks.construct.flat.sketches.mapped import MappedSketch from classy_blocks.optimize.clamps.free import FreeClamp from classy_blocks.optimize.clamps.surface import PlaneClamp from classy_blocks.optimize.links import TranslationLink from classy_blocks.optimize.optimizer import MeshOptimizer, SketchOptimizer from classy_blocks.optimize.smoother import SketchSmoother from classy_blocks.util import functions as f from tests.test_optimize.optimize_fixtures import BoxTestsBase, SketchTestsBase class MeshOptimizerTests(BoxTestsBase): @parameterized.expand( ( (0.5, 0, 0.5), # TODO: add intermediate iterations (1.0, 0, 1), # relaxation disabled ) ) def test_relaxation(self, relaxation, iteration, result): optimizer = MeshOptimizer(self.mesh) optimizer.config.relaxation_start = relaxation self.assertAlmostEqual(optimizer.relaxation_factor(iteration), result) def test_add_junction_existing(self): optimizer = MeshOptimizer(self.mesh, report=False) optimizer.add_clamp(FreeClamp(self.mesh.vertices[0].position)) with self.assertRaises(ClampExistsError): optimizer.add_clamp(FreeClamp(self.mesh.vertices[0].position)) def test_optimize(self): # move a point, then optimize it back to # its initial-ish position vertex = self.get_vertex([0, 0, 0]) vertex.move_to([0.3, 0.3, 0.3]) optimizer = MeshOptimizer(self.mesh, report=False) clamp = FreeClamp(vertex.position) optimizer.add_clamp(clamp) optimizer.optimize(method="Powell", tolerance=0.1) np.testing.assert_almost_equal(vertex.position, [0, 0, 0], decimal=1) def test_optimize_linked(self): vertex = self.get_vertex([0, 0, 0]) vertex.move_to([0.3, 0.3, 0.3]) follower_vertex = next(iter(self.finder.find_in_sphere([0, 1, 0]))) follower_vertex.move_to([0.3, 1.3, 0.3]) link = TranslationLink(vertex.position, follower_vertex.position) clamp = FreeClamp(vertex.position) optimizer = MeshOptimizer(self.mesh, report=False) optimizer.add_clamp(clamp) optimizer.add_link(link) optimizer.optimize(method="Powell") self.assertGreater(f.norm(follower_vertex.position - f.vector(0, 1, 0)), 0) np.testing.assert_almost_equal(vertex.position, [0, 0, 0], decimal=1) class SketchOptimizerTests(SketchTestsBase): def test_optimize_manual(self): sketch = MappedSketch(self.positions, self.quads) clamp = PlaneClamp([1.2, 1.6, 0], [0, 0, 0], [0, 0, 1]) optimizer = SketchOptimizer(sketch, report=False) optimizer.add_clamp(clamp) optimizer.optimize(method="trust-constr") np.testing.assert_almost_equal(sketch.positions[4], [1, 1, 0], decimal=1) def test_optimize_auto(self): sketch = MappedSketch(self.positions, self.quads) optimizer = SketchOptimizer(sketch, report=False) optimizer.auto_optimize(method="trust-constr") np.testing.assert_almost_equal(sketch.positions[4], [1, 1, 0], decimal=1) def test_bad_algo(self): # make sure that if the user chooses a non-suitable algorithm # the optimization will at least not ruin the mesh sketch = MappedSketch(self.positions, self.quads) optimizer = SketchOptimizer(sketch, report=False) optimizer.auto_optimize(method="SLSQP") np.testing.assert_array_almost_equal(self.positions[4], [1.2, 1.6, 0]) def test_algo_options(self): # pass an additional option to optimizer sketch_1 = MappedSketch(self.positions, self.quads) optimizer_1 = SketchOptimizer(sketch_1, report=False) optimizer_1.config.method = "trust-constr" optimizer_1.config.options = {"xtol": 0.1} sketch_2 = MappedSketch(self.positions, self.quads) optimizer_2 = SketchOptimizer(sketch_2, report=False) optimizer_2.config.method = "trust-constr" optimizer_2.config.options = {"xtol": 1} optimizer_1.auto_optimize() optimizer_2.auto_optimize() self.assertGreater(optimizer_2.grid.quality, optimizer_1.grid.quality) class ComplexSketchTests(unittest.TestCase): """Tests on a real-life case""" # A degenerate starting configuration, # smoothed to just barely valid def setUp(self): positions = np.array( [ [0.01672874, 0.02687117, 0.02099406], [0.01672874, 0.03602998, 0.02814971], [0.01371287, 0.04465496, 0.03488828], [0.00370317, 0.04878153, 0.0381123], [-0.00689365, 0.04569677, 0.03570223], [-0.01109499, 0.03743333, 0.02924612], [-0.00613247, 0.02943629, 0.02299815], [0.00472874, 0.02687117, 0.02099406], [0.00872874, 0.02687117, 0.02099406], [0.01272874, 0.02687117, 0.02099406], [0.0110113, 0.03091146, 0.02415068], [0.0110113, 0.03549087, 0.0277285], [0.00950337, 0.03980335, 0.03109779], [0.00449852, 0.04186664, 0.0327098], [-0.00079989, 0.04032426, 0.03150476], [-0.00290056, 0.03619254, 0.02827671], [-0.0004193, 0.03219402, 0.02515273], [0.0050113, 0.03091146, 0.02415068], [0.0070113, 0.03091146, 0.02415068], [0.0090113, 0.03091146, 0.02415068], ], ) face_map = [ # outer blocks [8, 9, 1, 0], # 0 [9, 10, 2, 1], # 1 [10, 11, 3, 2], # 2 [11, 12, 4, 3], # 3 [12, 13, 5, 4], # 4 [13, 14, 6, 5], # 5 [14, 15, 7, 6], # 6 # inner blocks [15, 14, 9, 8], # 7 [14, 13, 10, 9], # 8 [13, 12, 11, 10], # 9 ] self.sketch = MappedSketch(positions, face_map) def test_optimize(self): smoother = SketchSmoother(self.sketch) smoother.smooth() optimizer = SketchOptimizer(self.sketch, report=False) initial_quality = optimizer.grid.quality optimizer.auto_optimize(tolerance=1e-6, method="Nelder-Mead") self.assertLess(optimizer.grid.quality, initial_quality) ================================================ FILE: tests/test_optimize/test_quality.py ================================================ import unittest import numpy as np from parameterized import parameterized from classy_blocks.optimize import quality class QualityTests(unittest.TestCase): @parameterized.expand( ( ([1.0, 1.0, 0.0], 90), ([0.5, 0.5, 0.0], 180), ([0.0, 0.0, 0.0], 270), ) ) def test_quad_inner_angle(self, point_2, angle): points = np.array([[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], point_2, [0.0, 1.0, 0.0]]) normal = np.array([0.0, 0.0, 1.0]) result = quality.get_quad_inner_angle(points, normal, 2) self.assertGreater(result, angle - 1) self.assertLess(result, angle + 1) def test_quad_quality(self): # compare a perfect quad with a little-less-than-perfect # and make sure calculated quality of the latter is worse grid_points = np.array([[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [1.0, 1.0, 0.0], [0.0, 1.0, 0.0], [1.1, 1.1, 0]]) quality_1 = quality.get_quad_quality(grid_points, np.array([0, 1, 2, 3])) quality_2 = quality.get_quad_quality(grid_points, np.array([0, 1, 4, 3])) self.assertGreater(quality_2, quality_1) # TODO: test with quad angles 179-180-181 degrees ================================================ FILE: tests/test_optimize/test_smoother.py ================================================ import numpy as np from classy_blocks.construct.flat.sketches.disk import OneCoreDisk from classy_blocks.construct.flat.sketches.mapped import MappedSketch from classy_blocks.optimize.smoother import MeshSmoother, SketchSmoother from classy_blocks.util import functions as f from tests.test_optimize.optimize_fixtures import BoxTestsBase, SketchTestsBase class HexSmootherTests(BoxTestsBase): def test_smooth_mesh(self): vertex = self.get_vertex([0, 0, 0]) vertex.move_to([0.3, 0.3, 0.3]) smoother = MeshSmoother(self.mesh) smoother.smooth() np.testing.assert_almost_equal(self.mesh.vertices[vertex.index].position, [0, 0, 0]) def test_fix_index(self): vertex = self.get_vertex([0, 0, 0]) vertex.move_to([0.3, 0.3, 0.3]) smoother = MeshSmoother(self.mesh) smoother.fix_indexes([vertex.index]) smoother.smooth() np.testing.assert_equal(vertex.position, [0.3, 0.3, 0.3]) def test_fix_point(self): vertex = self.get_vertex([0, 0, 0]) vertex.move_to([0.3, 0.3, 0.3]) smoother = MeshSmoother(self.mesh) smoother.fix_points([vertex.position]) smoother.smooth() np.testing.assert_equal(vertex.position, [0.3, 0.3, 0.3]) class QuadSmootherTests(SketchTestsBase): def test_smooth_disk(self): sketch = OneCoreDisk([0, 0, 0], [1, 0, 0], [0, 0, 1]) smoother = SketchSmoother(sketch) # laplacian smoothing produces inner square that is much smaller than it should be; # therefore it is handled manually radius_pre = f.norm(sketch.positions[0]) smoother.smooth() radius_post = f.norm(sketch.positions[0]) self.assertLess(radius_post, radius_pre) def test_smooth(self): # a grid of vertices 3x3 sketch = MappedSketch(self.positions, self.quads) smoother = SketchSmoother(sketch) smoother.smooth() np.testing.assert_almost_equal(sketch.positions[4], [1, 1, 0], decimal=5) ================================================ FILE: tests/test_propagation.py ================================================ import numpy as np from classy_blocks.mesh import Mesh from tests.fixtures.block import BlockTestCase class PropagationTests(BlockTestCase): def setUp(self): self.mesh = Mesh() def test_propagate_normal(self): """Propagate grading from one block to another""" op_0 = self.make_loft(0) op_0.chop(0, count=10) op_0.chop(1, count=20, total_expansion=5) op_0.chop(2, count=10) self.mesh.add(op_0) op_1 = self.make_loft(1) op_1.chop(0, count=10) self.mesh.add(op_1) self.mesh.assemble() self.mesh.grade() self.assertListEqual( self.mesh.blocks[0].axes[1].wires[2].grading.specification, self.mesh.blocks[1].axes[1].wires[2].grading.specification, ) def test_propagate_upsidedown(self): """Propagate grading from a block to an overturned block; invert grading""" op_0 = self.make_loft(0) op_0.chop(0, count=10) op_0.chop(1, count=20, total_expansion=5) op_0.chop(2, count=10) self.mesh.add(op_0) op_1 = self.make_loft(1).rotate(np.pi, [1, 0, 0]) op_1.chop(0, count=10) self.mesh.add(op_1) self.mesh.assemble() self.mesh.grade() spec_0 = self.mesh.blocks[0].axes[1].wires[1].grading.get_specification(False) spec_1 = self.mesh.blocks[1].axes[1].wires[1].grading.get_specification(False) self.assertListEqual(spec_0, spec_1) ================================================ FILE: tests/test_util/__init__.py ================================================ ================================================ FILE: tests/test_util/test_frame.py ================================================ import unittest from classy_blocks.util.frame import Frame class FrameTests(unittest.TestCase): def test_add_beam_invalid(self): frame = Frame() with self.assertRaises(ValueError): frame.add_beam(0, 0, 1) ================================================ FILE: tests/test_util/test_functions.py ================================================ import unittest import numpy as np from parameterized import parameterized from classy_blocks.util import functions as f class TestFunctions(unittest.TestCase): def assert_np_equal(self, a, b, msg=None): return np.testing.assert_almost_equal(a, b, err_msg=msg) def assert_np_almost_equal(self, a, b, msg=None): return np.testing.assert_array_almost_equal(a, b, err_msg=msg) def test_deg2rad(self): """degrees to radians""" deg = 30 self.assertEqual(deg * np.pi / 180, f.deg2rad(deg)) def test_rad2deg(self): """radians to degrees""" rad = np.pi / 3 self.assertEqual(rad * 180 / np.pi, f.rad2deg(rad)) def test_unit_vector(self): """scale vector to magnitude 1""" vector = f.vector(3, 5, 7) unit_vector = vector / np.linalg.norm(vector) self.assert_np_equal(unit_vector, f.unit_vector(vector)) def test_norm_vector(self): """Vector norm""" self.assertEqual(f.norm(f.vector(1, 0, 0)), 1) def test_angle_between(self): """angle between two vectors""" v1 = f.vector(0, 0, 1) v2 = f.vector(0, 2, 0) self.assertEqual(f.angle_between(v1, v2), np.pi / 2) self.assertEqual(f.angle_between(v1, v1), 0) v1 = f.vector(1, 0, 0) v2 = f.vector(1, 1, 0) self.assertAlmostEqual(f.angle_between(v1, v2), np.pi / 4) def test_arbitrary_rotation_point(self): """rotation of a point from another origin""" point = f.vector(0, 2, 0) origin = f.vector(0, 1, 0) axis = f.vector(0, 0, 1) self.assert_np_equal(f.rotate(point, -np.pi / 2, axis, origin), f.vector(1, 1, 0)) def test_arbitrary_rotation_axis(self): """rotation of a point around arbitrary axis""" point = f.vector(1, 0, 0) origin = f.vector(0, 0, 0) axis = f.vector(1, 1, 0) self.assert_np_almost_equal(f.rotate(point, np.pi, axis, origin), f.vector(0, 1, 0)) def test_to_polar_z_axis(self): """cartesian coordinate system to polar c.s., rotation around z-axis""" cartesian = f.vector(2, 2, 5) polar = f.vector(8**0.5, np.pi / 4, 5) self.assert_np_almost_equal(polar, f.to_polar(cartesian, axis="z")) def test_to_polar_x_axis(self): """cartesian coordinate system to polar c.s., rotation around x-axis""" cartesian = f.vector(5, 2, 2) polar = f.vector(8**0.5, np.pi / 4, 5) self.assert_np_almost_equal(polar, f.to_polar(cartesian, axis="x")) def test_to_polar_invalid_axis(self): with self.assertRaises(ValueError): _ = f.to_polar([0, 1, 1], "y") def test_lin_map(self): """map a value""" self.assertEqual(f.lin_map(10, 0, 100, 0, 1000), 100) def test_lin_map_limit(self): """map a value within given limits""" self.assertEqual(f.lin_map(200, 0, 10, 0, 100, limit=True), 100) self.assertEqual(f.lin_map(-5, 0, 10, 0, 100, limit=True), 0) def test_to_cartesian_point(self): """polar point to xyz""" # polar point p = np.array([1, np.pi / 2, 2]) # cartesian versions # axis: z self.assert_np_almost_equal(f.to_cartesian(p, axis="z"), f.vector(0, 1, 2)) self.assert_np_almost_equal(f.to_cartesian(p, axis="x"), f.vector(2, 0, 1)) self.assert_np_almost_equal(f.to_cartesian(p, axis="x", direction=-1), f.vector(2, 0, -1)) def test_polar2cartesian_x_axis(self): cp = np.array([32, 198, 95]) pp = f.to_polar(cp, axis="x") self.assert_np_almost_equal(cp, f.to_cartesian(pp, axis="x")) def test_polar2cartesian_z_axis(self): cp = np.array([32, 198, 95]) pp = f.to_polar(cp, axis="z") self.assert_np_almost_equal(cp, f.to_cartesian(pp, axis="z")) def test_to_cartesian_asserts(self): """garbage input to f.to_cartesian()""" p = np.array([1, 0, 1]) with self.assertRaises(ValueError): f.to_cartesian(p, direction=0) with self.assertRaises(ValueError): f.to_cartesian(p, axis="a") def test_arc_length_3point_half(self): a = f.vector(0, 0, 0) b = f.vector(1, 1, 0) c = f.vector(2, 0, 0) self.assertAlmostEqual(f.arc_length_3point(a, b, c), np.pi) def test_arc_length_3point_quarter(self): a = f.vector(0, 0, 0) s2 = 2**0.5 / 2 b = f.vector(1 - s2, s2, 0) c = f.vector(1, 1, 0) self.assertAlmostEqual(f.arc_length_3point(a, b, c), np.pi / 2) def test_arc_length_3point_3quarter(self): a = f.vector(0, 0, 0) s2 = 2**0.5 / 2 b = f.vector(1 + s2, s2, 0) c = f.vector(1, -1, 0) self.assertAlmostEqual(f.arc_length_3point(a, b, c), 3 * np.pi / 2) def test_arc_length_3point_full(self): a = f.vector(0, 0, 0) b = f.vector(2, 0, 0) c = f.vector(0, 0, 0) with self.assertRaises(ValueError): self.assertAlmostEqual(f.arc_length_3point(a, b, c), 2 * np.pi) def test_arc_length_3point_zero(self): a = f.vector(0, 0, 0) b = f.vector(0, 0, 0) c = f.vector(0, 0, 0) with self.assertRaises(ValueError): self.assertAlmostEqual(f.arc_length_3point(a, b, c), 0) def test_mirror_yz(self): point = [2, 0, 0] mirrored = f.mirror(point, [1, 0, 0], [0, 0, 0]) np.testing.assert_almost_equal(mirrored, [-2, 0, 0]) def test_mirror_yz_origin(self): point = [2, 0, 0] mirrored = f.mirror(point, [1, 0, 0], [1, 1, 1]) np.testing.assert_almost_equal(mirrored, [0, 0, 0]) def test_mirror_xz(self): point = [0, 2, 0] mirrored = f.mirror(point, [0, 1, 0], [0, 0, 0]) np.testing.assert_almost_equal(mirrored, [0, -2, 0]) def test_mirror_xz_origin(self): point = [0, 2, 0] mirrored = f.mirror(point, [0, 1, 0], [0, 1, 0]) np.testing.assert_almost_equal(mirrored, [0, 0, 0]) def test_mirror_xy(self): point = [0, 0, 2] mirrored = f.mirror(point, [0, 0, 1], [0, 0, 0]) np.testing.assert_almost_equal(mirrored, [0, 0, -2]) def test_mirror_xy_origin(self): point = [0, 0, 2] mirrored = f.mirror(point, [0, 0, 1], [0, 0, 1]) np.testing.assert_almost_equal(mirrored, [0, 0, 0]) def test_mirror_arbitrary(self): point = [2, 2, 2] mirrored = f.mirror(point, [1, 1, 1], [0, 0, 0]) np.testing.assert_almost_equal(mirrored, [-2, -2, -2]) def test_mirror_arbitrary_inverted(self): point = [2, 2, 2] mirrored = f.mirror(point, [-1, -1, -1], [0, 0, 0]) np.testing.assert_almost_equal(mirrored, [-2, -2, -2]) def test_mirror_arbitrary_origin(self): point = [2, 2, 2] mirrored = f.mirror(point, [1, 1, 1], [1, 1, 1]) np.testing.assert_almost_equal(mirrored, [0, 0, 0]) def test_mirror_array(self): points = [[2, 2, 2], [4, 4, 4]] mirrored = f.mirror(points, [1, 1, 1], [1, 1, 1]) np.testing.assert_almost_equal(mirrored, [[0, 0, 0], [-2, -2, -2]]) @parameterized.expand( ( ([1, 0, 0], [0, 0, 1], [2, 0, 0]), ([0, 0, 0], [0, 0, 1], [1, 0, 0]), ([0, 0, 0], [0, 0, 1], [0, 1, 0]), ([0, 0, 0], [0, 0, 1], [1, 1, 0]), ([0, 0, 0], [0, 0, 1], [0, 0, 0]), ) ) def test_is_point_on_plane_true(self, origin, normal, point): self.assertTrue(f.is_point_on_plane(origin, normal, point)) @parameterized.expand( ( ([1, 0, 0], [0, 0, 1], [2, 0, 1]), ([0, 0, 0], [0, 0, 1], [1, 0, 1]), ([0, 0, 0], [0, 0, 1], [0, 1, 1]), ([0, 0, 0], [0, 0, 1], [1, 1, 1]), ([0, 0, 0], [0, 0, 1], [0, 0, 1]), ) ) def test_is_point_on_plane_false(self, origin, normal, point): self.assertFalse(f.is_point_on_plane(origin, normal, point)) def test_point_to_line_distance(self): point = [1, 0, 0] direction = [1, 1, 0] origin = [1, 1, 0] self.assertAlmostEqual(f.point_to_line_distance(origin, direction, point), 2**0.5 / 2) def test_polyline_1dim(self): with self.assertRaises(ValueError): points = np.array([0, 1, 2, 3, 4, 5]) f.polyline_length(points) def test_polyline_2dim(self): with self.assertRaises(ValueError): points = np.array([[0, 0], [1, 1], [2, 1]]) f.polyline_length(points) def test_polyline_3dim(self): points = np.array( [ [0, 0, 0], [0, 1, 0], [1, 1, 0], [1, 0, 0], ] ) self.assertAlmostEqual(f.polyline_length(points), 3) def test_polyline_singlepoint(self): points = np.array( [ [0, 0, 0], ] ) with self.assertRaises(ValueError): _ = f.polyline_length(points) @parameterized.expand( ( ([1, 1, 0], [0, 1, 0], [0, 0, 0], 1), ([1, 1, 0], [-1, 1, 0], [0, 0, 0], 0), ) ) def test_point_to_plane_distance(self, point, normal, origin, distance): self.assertAlmostEqual(f.point_to_plane_distance(origin, normal, point), distance) ================================================ FILE: tests/test_util/test_imports.py ================================================ import unittest import classy_blocks as cb class ImportsTests(unittest.TestCase): """Import all objects relevant to the user directly from cb""" def test_import_transforms(self): _ = cb.Translation _ = cb.Rotation _ = cb.Scaling _ = cb.Mirror _ = cb.Shear def test_import_curves(self): _ = cb.CurveBase _ = cb.DiscreteCurve _ = cb.LinearInterpolatedCurve _ = cb.SplineInterpolatedCurve _ = cb.AnalyticCurve _ = cb.LineCurve _ = cb.CircleCurve def test_import_edges(self): _ = cb.Arc _ = cb.Angle _ = cb.Origin _ = cb.Spline _ = cb.PolyLine _ = cb.Project _ = cb.OnCurve def test_import_flat(self): _ = cb.Face def test_import_operations(self): _ = cb.Loft _ = cb.Box _ = cb.Extrude _ = cb.Revolve _ = cb.Wedge _ = cb.Connector def test_import_sketches(self): _ = cb.MappedSketch _ = cb.Grid _ = cb.OneCoreDisk _ = cb.FourCoreDisk _ = cb.HalfDisk _ = cb.WrappedDisk _ = cb.Oval def test_import_stacks(self): _ = cb.TransformedStack _ = cb.ExtrudedStack _ = cb.RevolvedStack def test_import_shapes(self): _ = cb.Shape _ = cb.ExtrudedShape _ = cb.LoftedShape _ = cb.RevolvedShape _ = cb.Elbow _ = cb.Frustum _ = cb.Cylinder _ = cb.SemiCylinder _ = cb.ExtrudedRing _ = cb.RevolvedRing _ = cb.Hemisphere _ = cb.Shell def test_import_mesh(self): _ = cb.Mesh def test_import_finders(self): _ = cb.GeometricFinder _ = cb.RoundSolidFinder def test_import_clamps(self): _ = cb.ClampBase _ = cb.FreeClamp _ = cb.LineClamp _ = cb.CurveClamp _ = cb.RadialClamp def test_import_links(self): _ = cb.LinkBase _ = cb.TranslationLink _ = cb.RotationLink _ = cb.SymmetryLink def test_import_optimizer(self): _ = cb.MeshOptimizer _ = cb.ShapeOptimizer _ = cb.SketchOptimizer _ = cb.MeshSmoother _ = cb.SketchSmoother def test_import_assemblies(self): _ = cb.Assembly _ = cb.NJoint _ = cb.TJoint _ = cb.LJoint ================================================ FILE: tests/test_util/test_tools.py ================================================ import unittest from parameterized import parameterized from classy_blocks.base.exceptions import CornerPairError from classy_blocks.util import tools class ToolsTests(unittest.TestCase): @parameterized.expand( ( (0, 2), (1, 3), (1, 6), (1, 7), ) ) def test_wrong_edge_location(self, corner_1, corner_2): """Raise an exception when an invalid corner pair is given""" edge_location = tools.EdgeLocation(corner_1, corner_2, "top") with self.assertRaises(CornerPairError): _ = edge_location.start_corner ================================================ FILE: tox.ini ================================================ [tox] envlist = py39, py310, py311, py312, py313, py314, analysis [testenv] deps = .[dev] commands = python -m pytest [testenv:analysis] deps = .[dev] commands = ruff check src tests mypy src tests