`_. The current layout is designed to keep the main meshing path visible at all times while still exposing the analysis and helper tools around it.
+
+Overview
+========
+
+The window is organized into two main areas:
+
+- a workflow sidebar on the left
+- a workspace on the right
+
+The workflow sidebar contains the active airfoil summary, the page navigation, and the currently selected tool page. The workspace contains the geometry view, the contour analysis tab, the message panel, and the viewer controls.
+
+.. figure:: images/ui_overview_main.png
+ :align: center
+ :width: 85%
+ :target: _images/ui_overview_main.png
+
+ PyAero main window with the workflow shell.
+
+Workflow Pages
+==============
+
+The left-hand workflow sidebar is split into dedicated pages:
+
+- :guilabel:`Airfoil Library`
+- :guilabel:`Geometry Prep`
+- :guilabel:`Mesh`
+- :guilabel:`CFD Inputs`
+- :guilabel:`Aerodynamics`
+- :guilabel:`Contour Analysis`
+
+The active airfoil summary at the top of the sidebar shows:
+
+- the loaded airfoil name
+- where it came from
+- whether prepared geometry is available
+- whether a mesh has already been generated
+
+.. figure:: images/active_airfoil_card.png
+ :align: center
+ :width: 45%
+ :target: _images/active_airfoil_card.png
+
+ Active airfoil summary card at the top of the workflow sidebar.
+
+.. figure:: images/workflow_navigation.png
+ :align: center
+ :width: 45%
+ :target: _images/workflow_navigation.png
+
+ Workflow navigation card for switching between the main tool pages.
+
+Menus and Toolbar
+=================
+
+Menus and toolbar buttons are registry-driven. Actions, tooltips, shortcuts, and icons are defined centrally, while the menu and toolbar layouts are assembled from bundled configuration data.
+
+This keeps the interface consistent:
+
+- menus and toolbar buttons trigger the same action definitions
+- the shortcut editor works against the same registry
+- adding a new action does not require duplicating logic in several places
+
+.. figure:: images/menu_structure_NEW.png
+ :align: center
+ :width: 80%
+ :target: _images/menu_structure_NEW.png
+
+ Menu structure overview.
+
+The :guilabel:`View` menu also contains a :guilabel:`Window Size` submenu for cycling or applying three configured screenshot-friendly window presets. For docs work, :guilabel:`Tools > Export UI` now groups direct PNG exports for the rounded sidebar cards, the utility panels, and the current canvas, and it also offers a one-shot complete export into a folder. Major dialogs also expose their own :guilabel:`Export PNG...` button so they can be captured cleanly without manual cropping. The canvas screenshot action ships with the default shortcut ``Ctrl+Alt+S``.
+
+.. figure:: images/toolbar_animated_NEW.gif
+ :align: center
+ :scale: 70%
+ :target: _images/toolbar_animated_NEW.gif
+
+ Toolbar overview.
+
+Tool Pages
+==========
+
+Most day-to-day work happens inside the workflow pages rather than through modal dialogs. In practice this means:
+
+- airfoil selection happens in the library page
+- contour preparation happens in the geometry page
+- mesh creation and export happen in the mesh page
+- CFD helper calculations happen in dedicated side pages
+
+.. figure:: images/toolbox_animated_NEW.gif
+ :align: center
+ :scale: 60%
+ :target: _images/toolbox_animated_NEW.gif
+
+ Overview of the page-based workflow controls.
+
+Workspace Tabs
+==============
+
+The right-hand workspace contains:
+
+- the main graphics viewer
+- the contour analysis view
+
+The graphics viewer is the main place for loading, inspecting, and reviewing the contour and generated mesh. The contour analysis tab shows the derived plots for gradient, curvature, and radius.
+
+.. figure:: images/tabbed_views_animated.gif
+ :align: center
+ :target: _images/tabbed_views_animated.gif
+
+ Switching between the graphics and analysis views.
+
+.. figure:: images/viewer_canvas.png
+ :align: center
+ :width: 85%
+ :target: _images/viewer_canvas.png
+
+ Main graphics viewer canvas used for contour and mesh inspection.
+
+Workspace Panels
+================
+
+Two utility panels sit close to the main canvas so the workflow stays visible while you work:
+
+- the viewer controls panel
+- the message panel
+
+The viewer controls panel groups fit actions, background switching, message visibility, and quick access to magnifier-related tools. The message panel shows load status, export summaries, warnings, and other workflow feedback without forcing a modal dialog.
+
+.. figure:: images/viewer_controls_panel.png
+ :align: center
+ :width: 75%
+ :target: _images/viewer_controls_panel.png
+
+ Viewer controls for fit commands, background toggle, message visibility, and magnifier access.
+
+.. figure:: images/message_panel.png
+ :align: center
+ :width: 85%
+ :target: _images/message_panel.png
+
+ Integrated message panel for status, warnings, and export feedback.
+
+Navigation
+==========
+
+Panning
+-------
+
+Press and hold :kbd:`CTRL` on Windows or Linux, or :kbd:`CMD` on macOS, then drag with the left mouse button to pan the scene.
+
+.. figure:: images/drag_view.gif
+ :align: center
+ :target: _images/drag_view.gif
+
+ Panning the scene.
+
+Zooming
+-------
+
+You can zoom in three ways:
+
+- draw a rubber-band rectangle with the left mouse button
+- use the mouse wheel
+- use the keyboard shortcuts for zooming
+
+.. figure:: images/zoom_view.gif
+ :align: center
+ :target: _images/zoom_view.gif
+
+ Rubber-band zoom in the graphics view.
+
+Magnifier
+---------
+
+The graphics viewer also includes a hover magnifier for close inspection without changing the main zoom level. You can enable it from :menuselection:`View --> Toggle magnifier`, from the graphics-view context menu, or with the default shortcut ``Ctrl+Alt+M``.
+
+While the magnifier is active:
+
+- moving the pointer repositions the lens
+- the mouse wheel changes the lens magnification
+- ``+`` and ``-`` adjust magnifier zoom from the keyboard
+- ``Ctrl++`` and ``Ctrl+-`` increase or decrease the lens size
+- the regular fit, zoom, background, and delete actions are temporarily disabled so the lens interaction stays focused
+
+The default lens size and magnification are configurable through :ref:`tutorial_settings`.
+
+Keyboard Shortcuts
+==================
+
+Keyboard shortcuts are managed centrally. Built-in defaults live in :file:`resources/Shortcuts/shortcuts.json`, and user overrides are stored in :file:`config/shortcuts_user.json`.
+
+The shortcut editor is available from the help menu and by default opens with:
+
+- :kbd:`Ctrl+K` on Windows and Linux
+- :kbd:`Cmd+K` on macOS when mapped through Qt's platform conventions
+
+The editor allows you to:
+
+- inspect the current shortcut map
+- compare built-in and overridden bindings
+- save one platform-specific override per action
+
+The actual text rendered by Qt may vary slightly across operating systems.
+
+Support Dialogs
+===============
+
+Several utility dialogs support the main workflow and the surrounding UI maintenance tasks:
+
+- :guilabel:`Settings` opens the editable runtime configuration
+- :guilabel:`Icon Preview` shows semantic icons and bundled app assets
+- :guilabel:`About PyAero` summarizes version, license, and environment details
+
+.. figure:: images/icon_preview_dialog.png
+ :align: center
+ :width: 80%
+ :target: _images/icon_preview_dialog.png
+
+ Icon preview dialog for checking semantic icons and bundled assets.
+
+.. figure:: images/about_dialog.png
+ :align: center
+ :width: 80%
+ :target: _images/about_dialog.png
+
+ About dialog with project, license, and environment information.
diff --git a/docs/ui_menu_view.inc b/docs/ui_menu_view.inc
index 05188c0..ab47701 100644
--- a/docs/ui_menu_view.inc
+++ b/docs/ui_menu_view.inc
@@ -3,8 +3,8 @@ Menu :guilabel:`View`
This menu is used to set dedicated zoom levels or to toggle parameters of the view.
-All submenus of the :menuselection:`View` menu can be activated by respective shortcuts or by
-using the right mouse button inside the graphics view.
+All submenus of the :menuselection:`View` menu can be activated by their default shortcuts or by
+using the right mouse button inside the graphics view.
Submenu :guilabel:`Fit airfoil in view`
"""""""""""""""""""""""""""""""""""""""
@@ -13,14 +13,14 @@ The :menuselection:`View --> Fit airfoil in view` does what it says.
It fits the complete airfoil into the graphics view.
This is exactly the same zoom and position which is used after an airfoil has been loaded.
-The keyboard shortcut for this submenu is :kbd:`CTRL-f`.
+The default keyboard shortcut for this submenu is :kbd:`CTRL-f`.
Submenu :guilabel:`Fit all in view`
"""""""""""""""""""""""""""""""""""""""
The :menuselection:`View --> Fit all in view` does also what it says.
It fits loaded airfoils only (if no mesh exist yet) or the complete mesh of the windtunnel into the graphics view.
-The respective keyboard shortcut is :kbd:`CTRL-SHIFT-f`.
+The default keyboard shortcut is :kbd:`CTRL-SHIFT-f`.
Submenu :guilabel:`Toggle background`
"""""""""""""""""""""""""""""""""""""
@@ -28,7 +28,7 @@ Submenu :guilabel:`Toggle background`
The :menuselection:`View --> Toggle background` can be used to toggle the background of the graphics view.
The default background is a solid whit background.
Toggling switches over to a gradient filled background (white to dark grey, from top to bottom).
-The respective keyboard shortcut used is :kbd:`CTRL-b`.
+The default keyboard shortcut is :kbd:`CTRL-b`.
Submenu :guilabel:`Toggle message window`
""""""""""""""""""""""""""""""""""""""""
@@ -36,7 +36,7 @@ Submenu :guilabel:`Toggle message window`
The :menuselection:`View --> Toggle message window` can be used to toggle the message window
which is located below the the graphics view (see also :ref:`figure_main_screen`).
Toggling hides the message window and after toggling once more it is brought back again.
-The respective keyboard shortcut used is :kbd:`CTRL-m`.
+The default keyboard shortcut is :kbd:`CTRL-m`.
The message window is a so called floating window which can also be detached from the GUI.
It can be positioned anywhere on the screen and it can be reattched to the GUI again at any position ( top, bottom, left, right).
diff --git a/requirements.txt b/requirements.txt
index 9e20b08..5936c39 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,7 +1,6 @@
-meshio==5.3.5
-numpy==2.1.3
-PySide6==6.8.0.2
-PySide6==6.8.0.2
-PySide6_Addons==6.8.0.2
-PySide6_Essentials==6.8.0.2
-scipy==1.14.1
\ No newline at end of file
+PySide6>=6.11.0
+PySide6_Addons>=6.11.0
+PySide6_Essentials>=6.11.0
+shiboken6>=6.11.0
+numpy>=2.3.2
+scipy>=1.16.1
\ No newline at end of file
diff --git a/data/Icons/16x16/About.png b/resources/Icons/16x16/About.png
similarity index 100%
rename from data/Icons/16x16/About.png
rename to resources/Icons/16x16/About.png
diff --git a/data/Icons/16x16/Add.png b/resources/Icons/16x16/Add.png
similarity index 100%
rename from data/Icons/16x16/Add.png
rename to resources/Icons/16x16/Add.png
diff --git a/data/Icons/16x16/Address book.png b/resources/Icons/16x16/Address book.png
similarity index 100%
rename from data/Icons/16x16/Address book.png
rename to resources/Icons/16x16/Address book.png
diff --git a/data/Icons/16x16/Alarm clock.png b/resources/Icons/16x16/Alarm clock.png
similarity index 100%
rename from data/Icons/16x16/Alarm clock.png
rename to resources/Icons/16x16/Alarm clock.png
diff --git a/data/Icons/16x16/Alarm.png b/resources/Icons/16x16/Alarm.png
similarity index 100%
rename from data/Icons/16x16/Alarm.png
rename to resources/Icons/16x16/Alarm.png
diff --git a/data/Icons/16x16/Anchor.png b/resources/Icons/16x16/Anchor.png
similarity index 100%
rename from data/Icons/16x16/Anchor.png
rename to resources/Icons/16x16/Anchor.png
diff --git a/data/Icons/16x16/Apply.png b/resources/Icons/16x16/Apply.png
similarity index 100%
rename from data/Icons/16x16/Apply.png
rename to resources/Icons/16x16/Apply.png
diff --git a/data/Icons/16x16/Attach.png b/resources/Icons/16x16/Attach.png
similarity index 100%
rename from data/Icons/16x16/Attach.png
rename to resources/Icons/16x16/Attach.png
diff --git a/data/Icons/16x16/Attribute.png b/resources/Icons/16x16/Attribute.png
similarity index 100%
rename from data/Icons/16x16/Attribute.png
rename to resources/Icons/16x16/Attribute.png
diff --git a/data/Icons/16x16/Balance.png b/resources/Icons/16x16/Balance.png
similarity index 100%
rename from data/Icons/16x16/Balance.png
rename to resources/Icons/16x16/Balance.png
diff --git a/data/Icons/16x16/Bar-code scanner.png b/resources/Icons/16x16/Bar-code scanner.png
similarity index 100%
rename from data/Icons/16x16/Bar-code scanner.png
rename to resources/Icons/16x16/Bar-code scanner.png
diff --git a/data/Icons/16x16/Basket.png b/resources/Icons/16x16/Basket.png
similarity index 100%
rename from data/Icons/16x16/Basket.png
rename to resources/Icons/16x16/Basket.png
diff --git a/data/Icons/16x16/Book.png b/resources/Icons/16x16/Book.png
similarity index 100%
rename from data/Icons/16x16/Book.png
rename to resources/Icons/16x16/Book.png
diff --git a/data/Icons/16x16/Brief case.png b/resources/Icons/16x16/Brief case.png
similarity index 100%
rename from data/Icons/16x16/Brief case.png
rename to resources/Icons/16x16/Brief case.png
diff --git a/data/Icons/16x16/Calculator.png b/resources/Icons/16x16/Calculator.png
similarity index 100%
rename from data/Icons/16x16/Calculator.png
rename to resources/Icons/16x16/Calculator.png
diff --git a/data/Icons/16x16/Calendar.png b/resources/Icons/16x16/Calendar.png
similarity index 100%
rename from data/Icons/16x16/Calendar.png
rename to resources/Icons/16x16/Calendar.png
diff --git a/data/Icons/16x16/Camera.png b/resources/Icons/16x16/Camera.png
similarity index 100%
rename from data/Icons/16x16/Camera.png
rename to resources/Icons/16x16/Camera.png
diff --git a/data/Icons/16x16/Cancel.png b/resources/Icons/16x16/Cancel.png
similarity index 100%
rename from data/Icons/16x16/Cancel.png
rename to resources/Icons/16x16/Cancel.png
diff --git a/data/Icons/16x16/Card file.png b/resources/Icons/16x16/Card file.png
similarity index 100%
rename from data/Icons/16x16/Card file.png
rename to resources/Icons/16x16/Card file.png
diff --git a/data/Icons/16x16/Cash register.png b/resources/Icons/16x16/Cash register.png
similarity index 100%
rename from data/Icons/16x16/Cash register.png
rename to resources/Icons/16x16/Cash register.png
diff --git a/data/Icons/16x16/Certificate.png b/resources/Icons/16x16/Certificate.png
similarity index 100%
rename from data/Icons/16x16/Certificate.png
rename to resources/Icons/16x16/Certificate.png
diff --git a/data/Icons/16x16/Chip.png b/resources/Icons/16x16/Chip.png
similarity index 100%
rename from data/Icons/16x16/Chip.png
rename to resources/Icons/16x16/Chip.png
diff --git a/data/Icons/16x16/Clock.png b/resources/Icons/16x16/Clock.png
similarity index 100%
rename from data/Icons/16x16/Clock.png
rename to resources/Icons/16x16/Clock.png
diff --git a/data/Icons/16x16/Coin.png b/resources/Icons/16x16/Coin.png
similarity index 100%
rename from data/Icons/16x16/Coin.png
rename to resources/Icons/16x16/Coin.png
diff --git a/data/Icons/16x16/Coins.png b/resources/Icons/16x16/Coins.png
similarity index 100%
rename from data/Icons/16x16/Coins.png
rename to resources/Icons/16x16/Coins.png
diff --git a/data/Icons/16x16/Compass.png b/resources/Icons/16x16/Compass.png
similarity index 100%
rename from data/Icons/16x16/Compass.png
rename to resources/Icons/16x16/Compass.png
diff --git a/data/Icons/16x16/Computer.png b/resources/Icons/16x16/Computer.png
similarity index 100%
rename from data/Icons/16x16/Computer.png
rename to resources/Icons/16x16/Computer.png
diff --git a/data/Icons/16x16/Configuration.png b/resources/Icons/16x16/Configuration.png
similarity index 100%
rename from data/Icons/16x16/Configuration.png
rename to resources/Icons/16x16/Configuration.png
diff --git a/data/Icons/16x16/Copy.png b/resources/Icons/16x16/Copy.png
similarity index 100%
rename from data/Icons/16x16/Copy.png
rename to resources/Icons/16x16/Copy.png
diff --git a/data/Icons/16x16/Counter.png b/resources/Icons/16x16/Counter.png
similarity index 100%
rename from data/Icons/16x16/Counter.png
rename to resources/Icons/16x16/Counter.png
diff --git a/data/Icons/16x16/Credit card.png b/resources/Icons/16x16/Credit card.png
similarity index 100%
rename from data/Icons/16x16/Credit card.png
rename to resources/Icons/16x16/Credit card.png
diff --git a/data/Icons/16x16/Credit cards.png b/resources/Icons/16x16/Credit cards.png
similarity index 100%
rename from data/Icons/16x16/Credit cards.png
rename to resources/Icons/16x16/Credit cards.png
diff --git a/data/Icons/16x16/Credit.png b/resources/Icons/16x16/Credit.png
similarity index 100%
rename from data/Icons/16x16/Credit.png
rename to resources/Icons/16x16/Credit.png
diff --git a/data/Icons/16x16/Cube.png b/resources/Icons/16x16/Cube.png
similarity index 100%
rename from data/Icons/16x16/Cube.png
rename to resources/Icons/16x16/Cube.png
diff --git a/data/Icons/16x16/Cut.png b/resources/Icons/16x16/Cut.png
similarity index 100%
rename from data/Icons/16x16/Cut.png
rename to resources/Icons/16x16/Cut.png
diff --git a/data/Icons/16x16/Database.png b/resources/Icons/16x16/Database.png
similarity index 100%
rename from data/Icons/16x16/Database.png
rename to resources/Icons/16x16/Database.png
diff --git a/data/Icons/16x16/Delete.png b/resources/Icons/16x16/Delete.png
similarity index 100%
rename from data/Icons/16x16/Delete.png
rename to resources/Icons/16x16/Delete.png
diff --git a/data/Icons/16x16/Delivery.png b/resources/Icons/16x16/Delivery.png
similarity index 100%
rename from data/Icons/16x16/Delivery.png
rename to resources/Icons/16x16/Delivery.png
diff --git a/data/Icons/16x16/Desktop.png b/resources/Icons/16x16/Desktop.png
similarity index 100%
rename from data/Icons/16x16/Desktop.png
rename to resources/Icons/16x16/Desktop.png
diff --git a/data/Icons/16x16/Documents.png b/resources/Icons/16x16/Documents.png
similarity index 100%
rename from data/Icons/16x16/Documents.png
rename to resources/Icons/16x16/Documents.png
diff --git a/data/Icons/16x16/Dollar.png b/resources/Icons/16x16/Dollar.png
similarity index 100%
rename from data/Icons/16x16/Dollar.png
rename to resources/Icons/16x16/Dollar.png
diff --git a/data/Icons/16x16/Down.png b/resources/Icons/16x16/Down.png
similarity index 100%
rename from data/Icons/16x16/Down.png
rename to resources/Icons/16x16/Down.png
diff --git a/data/Icons/16x16/E-mail.png b/resources/Icons/16x16/E-mail.png
similarity index 100%
rename from data/Icons/16x16/E-mail.png
rename to resources/Icons/16x16/E-mail.png
diff --git a/data/Icons/16x16/Edit.png b/resources/Icons/16x16/Edit.png
similarity index 100%
rename from data/Icons/16x16/Edit.png
rename to resources/Icons/16x16/Edit.png
diff --git a/data/Icons/16x16/Erase.png b/resources/Icons/16x16/Erase.png
similarity index 100%
rename from data/Icons/16x16/Erase.png
rename to resources/Icons/16x16/Erase.png
diff --git a/data/Icons/16x16/Error.png b/resources/Icons/16x16/Error.png
similarity index 100%
rename from data/Icons/16x16/Error.png
rename to resources/Icons/16x16/Error.png
diff --git a/data/Icons/16x16/Exit.png b/resources/Icons/16x16/Exit.png
similarity index 100%
rename from data/Icons/16x16/Exit.png
rename to resources/Icons/16x16/Exit.png
diff --git a/data/Icons/16x16/Fast delivery.png b/resources/Icons/16x16/Fast delivery.png
similarity index 100%
rename from data/Icons/16x16/Fast delivery.png
rename to resources/Icons/16x16/Fast delivery.png
diff --git a/data/Icons/16x16/Fax.png b/resources/Icons/16x16/Fax.png
similarity index 100%
rename from data/Icons/16x16/Fax.png
rename to resources/Icons/16x16/Fax.png
diff --git a/data/Icons/16x16/Filter.png b/resources/Icons/16x16/Filter.png
similarity index 100%
rename from data/Icons/16x16/Filter.png
rename to resources/Icons/16x16/Filter.png
diff --git a/data/Icons/16x16/Find.png b/resources/Icons/16x16/Find.png
similarity index 100%
rename from data/Icons/16x16/Find.png
rename to resources/Icons/16x16/Find.png
diff --git a/data/Icons/16x16/Floppy.png b/resources/Icons/16x16/Floppy.png
similarity index 100%
rename from data/Icons/16x16/Floppy.png
rename to resources/Icons/16x16/Floppy.png
diff --git a/data/Icons/16x16/Folder.png b/resources/Icons/16x16/Folder.png
similarity index 100%
rename from data/Icons/16x16/Folder.png
rename to resources/Icons/16x16/Folder.png
diff --git a/data/Icons/16x16/Full basket.png b/resources/Icons/16x16/Full basket.png
similarity index 100%
rename from data/Icons/16x16/Full basket.png
rename to resources/Icons/16x16/Full basket.png
diff --git a/data/Icons/16x16/Globe.png b/resources/Icons/16x16/Globe.png
similarity index 100%
rename from data/Icons/16x16/Globe.png
rename to resources/Icons/16x16/Globe.png
diff --git a/data/Icons/16x16/Go back.png b/resources/Icons/16x16/Go back.png
similarity index 100%
rename from data/Icons/16x16/Go back.png
rename to resources/Icons/16x16/Go back.png
diff --git a/data/Icons/16x16/Go forward.png b/resources/Icons/16x16/Go forward.png
similarity index 100%
rename from data/Icons/16x16/Go forward.png
rename to resources/Icons/16x16/Go forward.png
diff --git a/data/Icons/16x16/Hand cart.png b/resources/Icons/16x16/Hand cart.png
similarity index 100%
rename from data/Icons/16x16/Hand cart.png
rename to resources/Icons/16x16/Hand cart.png
diff --git a/data/Icons/16x16/Handshake.png b/resources/Icons/16x16/Handshake.png
similarity index 100%
rename from data/Icons/16x16/Handshake.png
rename to resources/Icons/16x16/Handshake.png
diff --git a/data/Icons/16x16/Help book.png b/resources/Icons/16x16/Help book.png
similarity index 100%
rename from data/Icons/16x16/Help book.png
rename to resources/Icons/16x16/Help book.png
diff --git a/data/Icons/16x16/Help.png b/resources/Icons/16x16/Help.png
similarity index 100%
rename from data/Icons/16x16/Help.png
rename to resources/Icons/16x16/Help.png
diff --git a/data/Icons/16x16/Hint.png b/resources/Icons/16x16/Hint.png
similarity index 100%
rename from data/Icons/16x16/Hint.png
rename to resources/Icons/16x16/Hint.png
diff --git a/data/Icons/16x16/History.png b/resources/Icons/16x16/History.png
similarity index 100%
rename from data/Icons/16x16/History.png
rename to resources/Icons/16x16/History.png
diff --git a/data/Icons/16x16/How to.png b/resources/Icons/16x16/How to.png
similarity index 100%
rename from data/Icons/16x16/How to.png
rename to resources/Icons/16x16/How to.png
diff --git a/data/Icons/16x16/Info.png b/resources/Icons/16x16/Info.png
similarity index 100%
rename from data/Icons/16x16/Info.png
rename to resources/Icons/16x16/Info.png
diff --git a/data/Icons/16x16/Insurance.png b/resources/Icons/16x16/Insurance.png
similarity index 100%
rename from data/Icons/16x16/Insurance.png
rename to resources/Icons/16x16/Insurance.png
diff --git a/data/Icons/16x16/Items.png b/resources/Icons/16x16/Items.png
similarity index 100%
rename from data/Icons/16x16/Items.png
rename to resources/Icons/16x16/Items.png
diff --git a/data/Icons/16x16/Key.png b/resources/Icons/16x16/Key.png
similarity index 100%
rename from data/Icons/16x16/Key.png
rename to resources/Icons/16x16/Key.png
diff --git a/data/Icons/16x16/Letter.png b/resources/Icons/16x16/Letter.png
similarity index 100%
rename from data/Icons/16x16/Letter.png
rename to resources/Icons/16x16/Letter.png
diff --git a/data/Icons/16x16/List.png b/resources/Icons/16x16/List.png
similarity index 100%
rename from data/Icons/16x16/List.png
rename to resources/Icons/16x16/List.png
diff --git a/data/Icons/16x16/Lists.png b/resources/Icons/16x16/Lists.png
similarity index 100%
rename from data/Icons/16x16/Lists.png
rename to resources/Icons/16x16/Lists.png
diff --git a/data/Icons/16x16/Lock.png b/resources/Icons/16x16/Lock.png
similarity index 100%
rename from data/Icons/16x16/Lock.png
rename to resources/Icons/16x16/Lock.png
diff --git a/data/Icons/16x16/Message.png b/resources/Icons/16x16/Message.png
similarity index 100%
rename from data/Icons/16x16/Message.png
rename to resources/Icons/16x16/Message.png
diff --git a/data/Icons/16x16/Mobile phone.png b/resources/Icons/16x16/Mobile phone.png
similarity index 100%
rename from data/Icons/16x16/Mobile phone.png
rename to resources/Icons/16x16/Mobile phone.png
diff --git a/data/Icons/16x16/Money bag.png b/resources/Icons/16x16/Money bag.png
similarity index 100%
rename from data/Icons/16x16/Money bag.png
rename to resources/Icons/16x16/Money bag.png
diff --git a/data/Icons/16x16/Money.png b/resources/Icons/16x16/Money.png
similarity index 100%
rename from data/Icons/16x16/Money.png
rename to resources/Icons/16x16/Money.png
diff --git a/data/Icons/16x16/Music note.png b/resources/Icons/16x16/Music note.png
similarity index 100%
rename from data/Icons/16x16/Music note.png
rename to resources/Icons/16x16/Music note.png
diff --git a/data/Icons/16x16/New.png b/resources/Icons/16x16/New.png
similarity index 100%
rename from data/Icons/16x16/New.png
rename to resources/Icons/16x16/New.png
diff --git a/data/Icons/16x16/No.png b/resources/Icons/16x16/No.png
similarity index 100%
rename from data/Icons/16x16/No.png
rename to resources/Icons/16x16/No.png
diff --git a/data/Icons/16x16/Notebook.png b/resources/Icons/16x16/Notebook.png
similarity index 100%
rename from data/Icons/16x16/Notebook.png
rename to resources/Icons/16x16/Notebook.png
diff --git a/data/Icons/16x16/Notes.png b/resources/Icons/16x16/Notes.png
similarity index 100%
rename from data/Icons/16x16/Notes.png
rename to resources/Icons/16x16/Notes.png
diff --git a/data/Icons/16x16/OK.png b/resources/Icons/16x16/OK.png
similarity index 100%
rename from data/Icons/16x16/OK.png
rename to resources/Icons/16x16/OK.png
diff --git a/data/Icons/16x16/Open door.png b/resources/Icons/16x16/Open door.png
similarity index 100%
rename from data/Icons/16x16/Open door.png
rename to resources/Icons/16x16/Open door.png
diff --git a/data/Icons/16x16/Open.png b/resources/Icons/16x16/Open.png
similarity index 100%
rename from data/Icons/16x16/Open.png
rename to resources/Icons/16x16/Open.png
diff --git a/data/Icons/16x16/Options.png b/resources/Icons/16x16/Options.png
similarity index 100%
rename from data/Icons/16x16/Options.png
rename to resources/Icons/16x16/Options.png
diff --git a/data/Icons/16x16/Order.png b/resources/Icons/16x16/Order.png
similarity index 100%
rename from data/Icons/16x16/Order.png
rename to resources/Icons/16x16/Order.png
diff --git a/data/Icons/16x16/Package.png b/resources/Icons/16x16/Package.png
similarity index 100%
rename from data/Icons/16x16/Package.png
rename to resources/Icons/16x16/Package.png
diff --git a/data/Icons/16x16/Pallet.png b/resources/Icons/16x16/Pallet.png
similarity index 100%
rename from data/Icons/16x16/Pallet.png
rename to resources/Icons/16x16/Pallet.png
diff --git a/data/Icons/16x16/Paste.png b/resources/Icons/16x16/Paste.png
similarity index 100%
rename from data/Icons/16x16/Paste.png
rename to resources/Icons/16x16/Paste.png
diff --git a/data/Icons/16x16/Payment.png b/resources/Icons/16x16/Payment.png
similarity index 100%
rename from data/Icons/16x16/Payment.png
rename to resources/Icons/16x16/Payment.png
diff --git a/data/Icons/16x16/Phone.png b/resources/Icons/16x16/Phone.png
similarity index 100%
rename from data/Icons/16x16/Phone.png
rename to resources/Icons/16x16/Phone.png
diff --git a/data/Icons/16x16/Pin.png b/resources/Icons/16x16/Pin.png
similarity index 100%
rename from data/Icons/16x16/Pin.png
rename to resources/Icons/16x16/Pin.png
diff --git a/data/Icons/16x16/Pixel editor.png b/resources/Icons/16x16/Pixel editor.png
similarity index 100%
rename from data/Icons/16x16/Pixel editor.png
rename to resources/Icons/16x16/Pixel editor.png
diff --git a/data/Icons/16x16/Pocket-book.png b/resources/Icons/16x16/Pocket-book.png
similarity index 100%
rename from data/Icons/16x16/Pocket-book.png
rename to resources/Icons/16x16/Pocket-book.png
diff --git a/data/Icons/16x16/Police officer.png b/resources/Icons/16x16/Police officer.png
similarity index 100%
rename from data/Icons/16x16/Police officer.png
rename to resources/Icons/16x16/Police officer.png
diff --git a/data/Icons/16x16/Preview.png b/resources/Icons/16x16/Preview.png
similarity index 100%
rename from data/Icons/16x16/Preview.png
rename to resources/Icons/16x16/Preview.png
diff --git a/data/Icons/16x16/Price list.png b/resources/Icons/16x16/Price list.png
similarity index 100%
rename from data/Icons/16x16/Price list.png
rename to resources/Icons/16x16/Price list.png
diff --git a/data/Icons/16x16/Print preview.png b/resources/Icons/16x16/Print preview.png
similarity index 100%
rename from data/Icons/16x16/Print preview.png
rename to resources/Icons/16x16/Print preview.png
diff --git a/data/Icons/16x16/Print.png b/resources/Icons/16x16/Print.png
similarity index 100%
rename from data/Icons/16x16/Print.png
rename to resources/Icons/16x16/Print.png
diff --git a/data/Icons/16x16/Printer.png b/resources/Icons/16x16/Printer.png
similarity index 100%
rename from data/Icons/16x16/Printer.png
rename to resources/Icons/16x16/Printer.png
diff --git a/data/Icons/16x16/Properties.png b/resources/Icons/16x16/Properties.png
similarity index 100%
rename from data/Icons/16x16/Properties.png
rename to resources/Icons/16x16/Properties.png
diff --git a/data/Icons/16x16/Qt.png b/resources/Icons/16x16/Qt.png
similarity index 100%
rename from data/Icons/16x16/Qt.png
rename to resources/Icons/16x16/Qt.png
diff --git a/data/Icons/16x16/Redo.png b/resources/Icons/16x16/Redo.png
similarity index 100%
rename from data/Icons/16x16/Redo.png
rename to resources/Icons/16x16/Redo.png
diff --git a/data/Icons/16x16/Refresh.png b/resources/Icons/16x16/Refresh.png
similarity index 100%
rename from data/Icons/16x16/Refresh.png
rename to resources/Icons/16x16/Refresh.png
diff --git a/data/Icons/16x16/Registry.png b/resources/Icons/16x16/Registry.png
similarity index 100%
rename from data/Icons/16x16/Registry.png
rename to resources/Icons/16x16/Registry.png
diff --git a/data/Icons/16x16/Relations.png b/resources/Icons/16x16/Relations.png
similarity index 100%
rename from data/Icons/16x16/Relations.png
rename to resources/Icons/16x16/Relations.png
diff --git a/data/Icons/16x16/Report.png b/resources/Icons/16x16/Report.png
similarity index 100%
rename from data/Icons/16x16/Report.png
rename to resources/Icons/16x16/Report.png
diff --git a/data/Icons/16x16/Retort.png b/resources/Icons/16x16/Retort.png
similarity index 100%
rename from data/Icons/16x16/Retort.png
rename to resources/Icons/16x16/Retort.png
diff --git a/data/Icons/16x16/Save all.png b/resources/Icons/16x16/Save all.png
similarity index 100%
rename from data/Icons/16x16/Save all.png
rename to resources/Icons/16x16/Save all.png
diff --git a/data/Icons/16x16/Save as.png b/resources/Icons/16x16/Save as.png
similarity index 100%
rename from data/Icons/16x16/Save as.png
rename to resources/Icons/16x16/Save as.png
diff --git a/data/Icons/16x16/Save.png b/resources/Icons/16x16/Save.png
similarity index 100%
rename from data/Icons/16x16/Save.png
rename to resources/Icons/16x16/Save.png
diff --git a/data/Icons/16x16/Scales.png b/resources/Icons/16x16/Scales.png
similarity index 100%
rename from data/Icons/16x16/Scales.png
rename to resources/Icons/16x16/Scales.png
diff --git a/data/Icons/16x16/Scenario.png b/resources/Icons/16x16/Scenario.png
similarity index 100%
rename from data/Icons/16x16/Scenario.png
rename to resources/Icons/16x16/Scenario.png
diff --git a/data/Icons/16x16/Schedule.png b/resources/Icons/16x16/Schedule.png
similarity index 100%
rename from data/Icons/16x16/Schedule.png
rename to resources/Icons/16x16/Schedule.png
diff --git a/data/Icons/16x16/Script.png b/resources/Icons/16x16/Script.png
similarity index 100%
rename from data/Icons/16x16/Script.png
rename to resources/Icons/16x16/Script.png
diff --git a/data/Icons/16x16/Search.png b/resources/Icons/16x16/Search.png
similarity index 100%
rename from data/Icons/16x16/Search.png
rename to resources/Icons/16x16/Search.png
diff --git a/data/Icons/16x16/Send letter.png b/resources/Icons/16x16/Send letter.png
similarity index 100%
rename from data/Icons/16x16/Send letter.png
rename to resources/Icons/16x16/Send letter.png
diff --git a/data/Icons/16x16/Server.png b/resources/Icons/16x16/Server.png
similarity index 100%
rename from data/Icons/16x16/Server.png
rename to resources/Icons/16x16/Server.png
diff --git a/data/Icons/16x16/Settings.png b/resources/Icons/16x16/Settings.png
similarity index 100%
rename from data/Icons/16x16/Settings.png
rename to resources/Icons/16x16/Settings.png
diff --git a/data/Icons/16x16/Shield.png b/resources/Icons/16x16/Shield.png
similarity index 100%
rename from data/Icons/16x16/Shield.png
rename to resources/Icons/16x16/Shield.png
diff --git a/data/Icons/16x16/Signature.png b/resources/Icons/16x16/Signature.png
similarity index 100%
rename from data/Icons/16x16/Signature.png
rename to resources/Icons/16x16/Signature.png
diff --git a/data/Icons/16x16/Synchronize.png b/resources/Icons/16x16/Synchronize.png
similarity index 100%
rename from data/Icons/16x16/Synchronize.png
rename to resources/Icons/16x16/Synchronize.png
diff --git a/data/Icons/16x16/Table.png b/resources/Icons/16x16/Table.png
similarity index 100%
rename from data/Icons/16x16/Table.png
rename to resources/Icons/16x16/Table.png
diff --git a/data/Icons/16x16/Tables.png b/resources/Icons/16x16/Tables.png
similarity index 100%
rename from data/Icons/16x16/Tables.png
rename to resources/Icons/16x16/Tables.png
diff --git a/data/Icons/16x16/Timer.png b/resources/Icons/16x16/Timer.png
similarity index 100%
rename from data/Icons/16x16/Timer.png
rename to resources/Icons/16x16/Timer.png
diff --git a/data/Icons/16x16/Tip of the day.png b/resources/Icons/16x16/Tip of the day.png
similarity index 100%
rename from data/Icons/16x16/Tip of the day.png
rename to resources/Icons/16x16/Tip of the day.png
diff --git a/data/Icons/16x16/Tools.png b/resources/Icons/16x16/Tools.png
similarity index 100%
rename from data/Icons/16x16/Tools.png
rename to resources/Icons/16x16/Tools.png
diff --git a/data/Icons/16x16/Undelete.png b/resources/Icons/16x16/Undelete.png
similarity index 100%
rename from data/Icons/16x16/Undelete.png
rename to resources/Icons/16x16/Undelete.png
diff --git a/data/Icons/16x16/Undo.png b/resources/Icons/16x16/Undo.png
similarity index 100%
rename from data/Icons/16x16/Undo.png
rename to resources/Icons/16x16/Undo.png
diff --git a/data/Icons/16x16/Up.png b/resources/Icons/16x16/Up.png
similarity index 100%
rename from data/Icons/16x16/Up.png
rename to resources/Icons/16x16/Up.png
diff --git a/data/Icons/16x16/Update.png b/resources/Icons/16x16/Update.png
similarity index 100%
rename from data/Icons/16x16/Update.png
rename to resources/Icons/16x16/Update.png
diff --git a/data/Icons/16x16/View.png b/resources/Icons/16x16/View.png
similarity index 100%
rename from data/Icons/16x16/View.png
rename to resources/Icons/16x16/View.png
diff --git a/data/Icons/16x16/Web.png b/resources/Icons/16x16/Web.png
similarity index 100%
rename from data/Icons/16x16/Web.png
rename to resources/Icons/16x16/Web.png
diff --git a/data/Icons/16x16/Yes.png b/resources/Icons/16x16/Yes.png
similarity index 100%
rename from data/Icons/16x16/Yes.png
rename to resources/Icons/16x16/Yes.png
diff --git a/data/Icons/16x16/fit-to-width-16.png b/resources/Icons/16x16/fit-to-width-16.png
similarity index 100%
rename from data/Icons/16x16/fit-to-width-16.png
rename to resources/Icons/16x16/fit-to-width-16.png
diff --git a/data/Icons/24x24/About.png b/resources/Icons/24x24/About.png
similarity index 100%
rename from data/Icons/24x24/About.png
rename to resources/Icons/24x24/About.png
diff --git a/data/Icons/24x24/Add.png b/resources/Icons/24x24/Add.png
similarity index 100%
rename from data/Icons/24x24/Add.png
rename to resources/Icons/24x24/Add.png
diff --git a/data/Icons/24x24/Address book.png b/resources/Icons/24x24/Address book.png
similarity index 100%
rename from data/Icons/24x24/Address book.png
rename to resources/Icons/24x24/Address book.png
diff --git a/data/Icons/24x24/Alarm clock.png b/resources/Icons/24x24/Alarm clock.png
similarity index 100%
rename from data/Icons/24x24/Alarm clock.png
rename to resources/Icons/24x24/Alarm clock.png
diff --git a/data/Icons/24x24/Alarm.png b/resources/Icons/24x24/Alarm.png
similarity index 100%
rename from data/Icons/24x24/Alarm.png
rename to resources/Icons/24x24/Alarm.png
diff --git a/data/Icons/24x24/Anchor.png b/resources/Icons/24x24/Anchor.png
similarity index 100%
rename from data/Icons/24x24/Anchor.png
rename to resources/Icons/24x24/Anchor.png
diff --git a/data/Icons/24x24/Apply.png b/resources/Icons/24x24/Apply.png
similarity index 100%
rename from data/Icons/24x24/Apply.png
rename to resources/Icons/24x24/Apply.png
diff --git a/data/Icons/24x24/Attach.png b/resources/Icons/24x24/Attach.png
similarity index 100%
rename from data/Icons/24x24/Attach.png
rename to resources/Icons/24x24/Attach.png
diff --git a/data/Icons/24x24/Attribute.png b/resources/Icons/24x24/Attribute.png
similarity index 100%
rename from data/Icons/24x24/Attribute.png
rename to resources/Icons/24x24/Attribute.png
diff --git a/data/Icons/24x24/Balance.png b/resources/Icons/24x24/Balance.png
similarity index 100%
rename from data/Icons/24x24/Balance.png
rename to resources/Icons/24x24/Balance.png
diff --git a/data/Icons/24x24/Bar-code scanner.png b/resources/Icons/24x24/Bar-code scanner.png
similarity index 100%
rename from data/Icons/24x24/Bar-code scanner.png
rename to resources/Icons/24x24/Bar-code scanner.png
diff --git a/data/Icons/24x24/Basket.png b/resources/Icons/24x24/Basket.png
similarity index 100%
rename from data/Icons/24x24/Basket.png
rename to resources/Icons/24x24/Basket.png
diff --git a/data/Icons/24x24/Book.png b/resources/Icons/24x24/Book.png
similarity index 100%
rename from data/Icons/24x24/Book.png
rename to resources/Icons/24x24/Book.png
diff --git a/data/Icons/24x24/Brief case.png b/resources/Icons/24x24/Brief case.png
similarity index 100%
rename from data/Icons/24x24/Brief case.png
rename to resources/Icons/24x24/Brief case.png
diff --git a/data/Icons/24x24/Calculator.png b/resources/Icons/24x24/Calculator.png
similarity index 100%
rename from data/Icons/24x24/Calculator.png
rename to resources/Icons/24x24/Calculator.png
diff --git a/data/Icons/24x24/Calendar.png b/resources/Icons/24x24/Calendar.png
similarity index 100%
rename from data/Icons/24x24/Calendar.png
rename to resources/Icons/24x24/Calendar.png
diff --git a/data/Icons/24x24/Camera.png b/resources/Icons/24x24/Camera.png
similarity index 100%
rename from data/Icons/24x24/Camera.png
rename to resources/Icons/24x24/Camera.png
diff --git a/data/Icons/24x24/Cancel.png b/resources/Icons/24x24/Cancel.png
similarity index 100%
rename from data/Icons/24x24/Cancel.png
rename to resources/Icons/24x24/Cancel.png
diff --git a/data/Icons/24x24/Card file.png b/resources/Icons/24x24/Card file.png
similarity index 100%
rename from data/Icons/24x24/Card file.png
rename to resources/Icons/24x24/Card file.png
diff --git a/data/Icons/24x24/Cash register.png b/resources/Icons/24x24/Cash register.png
similarity index 100%
rename from data/Icons/24x24/Cash register.png
rename to resources/Icons/24x24/Cash register.png
diff --git a/data/Icons/24x24/Certificate.png b/resources/Icons/24x24/Certificate.png
similarity index 100%
rename from data/Icons/24x24/Certificate.png
rename to resources/Icons/24x24/Certificate.png
diff --git a/data/Icons/24x24/Chip.png b/resources/Icons/24x24/Chip.png
similarity index 100%
rename from data/Icons/24x24/Chip.png
rename to resources/Icons/24x24/Chip.png
diff --git a/data/Icons/24x24/Clock.png b/resources/Icons/24x24/Clock.png
similarity index 100%
rename from data/Icons/24x24/Clock.png
rename to resources/Icons/24x24/Clock.png
diff --git a/data/Icons/24x24/Coin.png b/resources/Icons/24x24/Coin.png
similarity index 100%
rename from data/Icons/24x24/Coin.png
rename to resources/Icons/24x24/Coin.png
diff --git a/data/Icons/24x24/Coins.png b/resources/Icons/24x24/Coins.png
similarity index 100%
rename from data/Icons/24x24/Coins.png
rename to resources/Icons/24x24/Coins.png
diff --git a/data/Icons/24x24/Compass.png b/resources/Icons/24x24/Compass.png
similarity index 100%
rename from data/Icons/24x24/Compass.png
rename to resources/Icons/24x24/Compass.png
diff --git a/data/Icons/24x24/Computer.png b/resources/Icons/24x24/Computer.png
similarity index 100%
rename from data/Icons/24x24/Computer.png
rename to resources/Icons/24x24/Computer.png
diff --git a/data/Icons/24x24/Configuration.png b/resources/Icons/24x24/Configuration.png
similarity index 100%
rename from data/Icons/24x24/Configuration.png
rename to resources/Icons/24x24/Configuration.png
diff --git a/data/Icons/24x24/Copy.png b/resources/Icons/24x24/Copy.png
similarity index 100%
rename from data/Icons/24x24/Copy.png
rename to resources/Icons/24x24/Copy.png
diff --git a/data/Icons/24x24/Counter.png b/resources/Icons/24x24/Counter.png
similarity index 100%
rename from data/Icons/24x24/Counter.png
rename to resources/Icons/24x24/Counter.png
diff --git a/data/Icons/24x24/Credit card.png b/resources/Icons/24x24/Credit card.png
similarity index 100%
rename from data/Icons/24x24/Credit card.png
rename to resources/Icons/24x24/Credit card.png
diff --git a/data/Icons/24x24/Credit cards.png b/resources/Icons/24x24/Credit cards.png
similarity index 100%
rename from data/Icons/24x24/Credit cards.png
rename to resources/Icons/24x24/Credit cards.png
diff --git a/data/Icons/24x24/Credit.png b/resources/Icons/24x24/Credit.png
similarity index 100%
rename from data/Icons/24x24/Credit.png
rename to resources/Icons/24x24/Credit.png
diff --git a/data/Icons/24x24/Cube.png b/resources/Icons/24x24/Cube.png
similarity index 100%
rename from data/Icons/24x24/Cube.png
rename to resources/Icons/24x24/Cube.png
diff --git a/data/Icons/24x24/Cut.png b/resources/Icons/24x24/Cut.png
similarity index 100%
rename from data/Icons/24x24/Cut.png
rename to resources/Icons/24x24/Cut.png
diff --git a/data/Icons/24x24/Database.png b/resources/Icons/24x24/Database.png
similarity index 100%
rename from data/Icons/24x24/Database.png
rename to resources/Icons/24x24/Database.png
diff --git a/data/Icons/24x24/Delete.png b/resources/Icons/24x24/Delete.png
similarity index 100%
rename from data/Icons/24x24/Delete.png
rename to resources/Icons/24x24/Delete.png
diff --git a/data/Icons/24x24/Delivery.png b/resources/Icons/24x24/Delivery.png
similarity index 100%
rename from data/Icons/24x24/Delivery.png
rename to resources/Icons/24x24/Delivery.png
diff --git a/data/Icons/24x24/Desktop.png b/resources/Icons/24x24/Desktop.png
similarity index 100%
rename from data/Icons/24x24/Desktop.png
rename to resources/Icons/24x24/Desktop.png
diff --git a/data/Icons/24x24/Documents.png b/resources/Icons/24x24/Documents.png
similarity index 100%
rename from data/Icons/24x24/Documents.png
rename to resources/Icons/24x24/Documents.png
diff --git a/data/Icons/24x24/Dollar.png b/resources/Icons/24x24/Dollar.png
similarity index 100%
rename from data/Icons/24x24/Dollar.png
rename to resources/Icons/24x24/Dollar.png
diff --git a/data/Icons/24x24/Down.png b/resources/Icons/24x24/Down.png
similarity index 100%
rename from data/Icons/24x24/Down.png
rename to resources/Icons/24x24/Down.png
diff --git a/data/Icons/24x24/E-mail.png b/resources/Icons/24x24/E-mail.png
similarity index 100%
rename from data/Icons/24x24/E-mail.png
rename to resources/Icons/24x24/E-mail.png
diff --git a/data/Icons/24x24/Edit.png b/resources/Icons/24x24/Edit.png
similarity index 100%
rename from data/Icons/24x24/Edit.png
rename to resources/Icons/24x24/Edit.png
diff --git a/data/Icons/24x24/Erase.png b/resources/Icons/24x24/Erase.png
similarity index 100%
rename from data/Icons/24x24/Erase.png
rename to resources/Icons/24x24/Erase.png
diff --git a/data/Icons/24x24/Error.png b/resources/Icons/24x24/Error.png
similarity index 100%
rename from data/Icons/24x24/Error.png
rename to resources/Icons/24x24/Error.png
diff --git a/data/Icons/24x24/Exit.png b/resources/Icons/24x24/Exit.png
similarity index 100%
rename from data/Icons/24x24/Exit.png
rename to resources/Icons/24x24/Exit.png
diff --git a/data/Icons/24x24/Fast delivery.png b/resources/Icons/24x24/Fast delivery.png
similarity index 100%
rename from data/Icons/24x24/Fast delivery.png
rename to resources/Icons/24x24/Fast delivery.png
diff --git a/data/Icons/24x24/Fax.png b/resources/Icons/24x24/Fax.png
similarity index 100%
rename from data/Icons/24x24/Fax.png
rename to resources/Icons/24x24/Fax.png
diff --git a/data/Icons/24x24/Filter.png b/resources/Icons/24x24/Filter.png
similarity index 100%
rename from data/Icons/24x24/Filter.png
rename to resources/Icons/24x24/Filter.png
diff --git a/data/Icons/24x24/Find.png b/resources/Icons/24x24/Find.png
similarity index 100%
rename from data/Icons/24x24/Find.png
rename to resources/Icons/24x24/Find.png
diff --git a/data/Icons/24x24/Fit airfoil.png b/resources/Icons/24x24/Fit airfoil.png
similarity index 100%
rename from data/Icons/24x24/Fit airfoil.png
rename to resources/Icons/24x24/Fit airfoil.png
diff --git a/data/Icons/24x24/Fit in view.png b/resources/Icons/24x24/Fit in view.png
similarity index 100%
rename from data/Icons/24x24/Fit in view.png
rename to resources/Icons/24x24/Fit in view.png
diff --git a/data/Icons/24x24/Fit view.png b/resources/Icons/24x24/Fit view.png
similarity index 100%
rename from data/Icons/24x24/Fit view.png
rename to resources/Icons/24x24/Fit view.png
diff --git a/data/Icons/24x24/Floppy.png b/resources/Icons/24x24/Floppy.png
similarity index 100%
rename from data/Icons/24x24/Floppy.png
rename to resources/Icons/24x24/Floppy.png
diff --git a/data/Icons/24x24/Folder.png b/resources/Icons/24x24/Folder.png
similarity index 100%
rename from data/Icons/24x24/Folder.png
rename to resources/Icons/24x24/Folder.png
diff --git a/data/Icons/24x24/Full basket.png b/resources/Icons/24x24/Full basket.png
similarity index 100%
rename from data/Icons/24x24/Full basket.png
rename to resources/Icons/24x24/Full basket.png
diff --git a/data/Icons/24x24/Globe.png b/resources/Icons/24x24/Globe.png
similarity index 100%
rename from data/Icons/24x24/Globe.png
rename to resources/Icons/24x24/Globe.png
diff --git a/data/Icons/24x24/Go back.png b/resources/Icons/24x24/Go back.png
similarity index 100%
rename from data/Icons/24x24/Go back.png
rename to resources/Icons/24x24/Go back.png
diff --git a/data/Icons/24x24/Go forward.png b/resources/Icons/24x24/Go forward.png
similarity index 100%
rename from data/Icons/24x24/Go forward.png
rename to resources/Icons/24x24/Go forward.png
diff --git a/data/Icons/24x24/Hand cart.png b/resources/Icons/24x24/Hand cart.png
similarity index 100%
rename from data/Icons/24x24/Hand cart.png
rename to resources/Icons/24x24/Hand cart.png
diff --git a/data/Icons/24x24/Handshake.png b/resources/Icons/24x24/Handshake.png
similarity index 100%
rename from data/Icons/24x24/Handshake.png
rename to resources/Icons/24x24/Handshake.png
diff --git a/data/Icons/24x24/Help book.png b/resources/Icons/24x24/Help book.png
similarity index 100%
rename from data/Icons/24x24/Help book.png
rename to resources/Icons/24x24/Help book.png
diff --git a/data/Icons/24x24/Help.png b/resources/Icons/24x24/Help.png
similarity index 100%
rename from data/Icons/24x24/Help.png
rename to resources/Icons/24x24/Help.png
diff --git a/data/Icons/24x24/Hint.png b/resources/Icons/24x24/Hint.png
similarity index 100%
rename from data/Icons/24x24/Hint.png
rename to resources/Icons/24x24/Hint.png
diff --git a/data/Icons/24x24/History.png b/resources/Icons/24x24/History.png
similarity index 100%
rename from data/Icons/24x24/History.png
rename to resources/Icons/24x24/History.png
diff --git a/data/Icons/24x24/How to.png b/resources/Icons/24x24/How to.png
similarity index 100%
rename from data/Icons/24x24/How to.png
rename to resources/Icons/24x24/How to.png
diff --git a/data/Icons/24x24/Info.png b/resources/Icons/24x24/Info.png
similarity index 100%
rename from data/Icons/24x24/Info.png
rename to resources/Icons/24x24/Info.png
diff --git a/data/Icons/24x24/Insurance.png b/resources/Icons/24x24/Insurance.png
similarity index 100%
rename from data/Icons/24x24/Insurance.png
rename to resources/Icons/24x24/Insurance.png
diff --git a/data/Icons/24x24/Items.png b/resources/Icons/24x24/Items.png
similarity index 100%
rename from data/Icons/24x24/Items.png
rename to resources/Icons/24x24/Items.png
diff --git a/data/Icons/24x24/Key.png b/resources/Icons/24x24/Key.png
similarity index 100%
rename from data/Icons/24x24/Key.png
rename to resources/Icons/24x24/Key.png
diff --git a/data/Icons/24x24/Letter.png b/resources/Icons/24x24/Letter.png
similarity index 100%
rename from data/Icons/24x24/Letter.png
rename to resources/Icons/24x24/Letter.png
diff --git a/data/Icons/24x24/List.png b/resources/Icons/24x24/List.png
similarity index 100%
rename from data/Icons/24x24/List.png
rename to resources/Icons/24x24/List.png
diff --git a/data/Icons/24x24/Lists.png b/resources/Icons/24x24/Lists.png
similarity index 100%
rename from data/Icons/24x24/Lists.png
rename to resources/Icons/24x24/Lists.png
diff --git a/data/Icons/24x24/Lock.png b/resources/Icons/24x24/Lock.png
similarity index 100%
rename from data/Icons/24x24/Lock.png
rename to resources/Icons/24x24/Lock.png
diff --git a/data/Icons/24x24/Message.png b/resources/Icons/24x24/Message.png
similarity index 100%
rename from data/Icons/24x24/Message.png
rename to resources/Icons/24x24/Message.png
diff --git a/data/Icons/24x24/Mobile phone.png b/resources/Icons/24x24/Mobile phone.png
similarity index 100%
rename from data/Icons/24x24/Mobile phone.png
rename to resources/Icons/24x24/Mobile phone.png
diff --git a/data/Icons/24x24/Money bag.png b/resources/Icons/24x24/Money bag.png
similarity index 100%
rename from data/Icons/24x24/Money bag.png
rename to resources/Icons/24x24/Money bag.png
diff --git a/data/Icons/24x24/Money.png b/resources/Icons/24x24/Money.png
similarity index 100%
rename from data/Icons/24x24/Money.png
rename to resources/Icons/24x24/Money.png
diff --git a/data/Icons/24x24/Music note.png b/resources/Icons/24x24/Music note.png
similarity index 100%
rename from data/Icons/24x24/Music note.png
rename to resources/Icons/24x24/Music note.png
diff --git a/data/Icons/24x24/New.png b/resources/Icons/24x24/New.png
similarity index 100%
rename from data/Icons/24x24/New.png
rename to resources/Icons/24x24/New.png
diff --git a/data/Icons/24x24/No.png b/resources/Icons/24x24/No.png
similarity index 100%
rename from data/Icons/24x24/No.png
rename to resources/Icons/24x24/No.png
diff --git a/data/Icons/24x24/Notebook.png b/resources/Icons/24x24/Notebook.png
similarity index 100%
rename from data/Icons/24x24/Notebook.png
rename to resources/Icons/24x24/Notebook.png
diff --git a/data/Icons/24x24/Notes.png b/resources/Icons/24x24/Notes.png
similarity index 100%
rename from data/Icons/24x24/Notes.png
rename to resources/Icons/24x24/Notes.png
diff --git a/data/Icons/24x24/OK.png b/resources/Icons/24x24/OK.png
similarity index 100%
rename from data/Icons/24x24/OK.png
rename to resources/Icons/24x24/OK.png
diff --git a/data/Icons/24x24/Open door.png b/resources/Icons/24x24/Open door.png
similarity index 100%
rename from data/Icons/24x24/Open door.png
rename to resources/Icons/24x24/Open door.png
diff --git a/data/Icons/24x24/Open.png b/resources/Icons/24x24/Open.png
similarity index 100%
rename from data/Icons/24x24/Open.png
rename to resources/Icons/24x24/Open.png
diff --git a/data/Icons/24x24/Options.png b/resources/Icons/24x24/Options.png
similarity index 100%
rename from data/Icons/24x24/Options.png
rename to resources/Icons/24x24/Options.png
diff --git a/data/Icons/24x24/Order.png b/resources/Icons/24x24/Order.png
similarity index 100%
rename from data/Icons/24x24/Order.png
rename to resources/Icons/24x24/Order.png
diff --git a/data/Icons/24x24/Package.png b/resources/Icons/24x24/Package.png
similarity index 100%
rename from data/Icons/24x24/Package.png
rename to resources/Icons/24x24/Package.png
diff --git a/data/Icons/24x24/Pallet.png b/resources/Icons/24x24/Pallet.png
similarity index 100%
rename from data/Icons/24x24/Pallet.png
rename to resources/Icons/24x24/Pallet.png
diff --git a/data/Icons/24x24/Paste.png b/resources/Icons/24x24/Paste.png
similarity index 100%
rename from data/Icons/24x24/Paste.png
rename to resources/Icons/24x24/Paste.png
diff --git a/data/Icons/24x24/Payment.png b/resources/Icons/24x24/Payment.png
similarity index 100%
rename from data/Icons/24x24/Payment.png
rename to resources/Icons/24x24/Payment.png
diff --git a/data/Icons/24x24/Phone.png b/resources/Icons/24x24/Phone.png
similarity index 100%
rename from data/Icons/24x24/Phone.png
rename to resources/Icons/24x24/Phone.png
diff --git a/data/Icons/24x24/Pin.png b/resources/Icons/24x24/Pin.png
similarity index 100%
rename from data/Icons/24x24/Pin.png
rename to resources/Icons/24x24/Pin.png
diff --git a/data/Icons/24x24/Pixel editor.png b/resources/Icons/24x24/Pixel editor.png
similarity index 100%
rename from data/Icons/24x24/Pixel editor.png
rename to resources/Icons/24x24/Pixel editor.png
diff --git a/data/Icons/24x24/Pocket-book.png b/resources/Icons/24x24/Pocket-book.png
similarity index 100%
rename from data/Icons/24x24/Pocket-book.png
rename to resources/Icons/24x24/Pocket-book.png
diff --git a/data/Icons/24x24/Police officer.png b/resources/Icons/24x24/Police officer.png
similarity index 100%
rename from data/Icons/24x24/Police officer.png
rename to resources/Icons/24x24/Police officer.png
diff --git a/data/Icons/24x24/Preview.png b/resources/Icons/24x24/Preview.png
similarity index 100%
rename from data/Icons/24x24/Preview.png
rename to resources/Icons/24x24/Preview.png
diff --git a/data/Icons/24x24/Price list.png b/resources/Icons/24x24/Price list.png
similarity index 100%
rename from data/Icons/24x24/Price list.png
rename to resources/Icons/24x24/Price list.png
diff --git a/data/Icons/24x24/Print preview.png b/resources/Icons/24x24/Print preview.png
similarity index 100%
rename from data/Icons/24x24/Print preview.png
rename to resources/Icons/24x24/Print preview.png
diff --git a/data/Icons/24x24/Print.png b/resources/Icons/24x24/Print.png
similarity index 100%
rename from data/Icons/24x24/Print.png
rename to resources/Icons/24x24/Print.png
diff --git a/data/Icons/24x24/Printer.png b/resources/Icons/24x24/Printer.png
similarity index 100%
rename from data/Icons/24x24/Printer.png
rename to resources/Icons/24x24/Printer.png
diff --git a/data/Icons/24x24/Properties.png b/resources/Icons/24x24/Properties.png
similarity index 100%
rename from data/Icons/24x24/Properties.png
rename to resources/Icons/24x24/Properties.png
diff --git a/data/Icons/24x24/Qt.png b/resources/Icons/24x24/Qt.png
similarity index 100%
rename from data/Icons/24x24/Qt.png
rename to resources/Icons/24x24/Qt.png
diff --git a/data/Icons/24x24/Redo.png b/resources/Icons/24x24/Redo.png
similarity index 100%
rename from data/Icons/24x24/Redo.png
rename to resources/Icons/24x24/Redo.png
diff --git a/data/Icons/24x24/Refresh.png b/resources/Icons/24x24/Refresh.png
similarity index 100%
rename from data/Icons/24x24/Refresh.png
rename to resources/Icons/24x24/Refresh.png
diff --git a/data/Icons/24x24/Registry.png b/resources/Icons/24x24/Registry.png
similarity index 100%
rename from data/Icons/24x24/Registry.png
rename to resources/Icons/24x24/Registry.png
diff --git a/data/Icons/24x24/Relations.png b/resources/Icons/24x24/Relations.png
similarity index 100%
rename from data/Icons/24x24/Relations.png
rename to resources/Icons/24x24/Relations.png
diff --git a/data/Icons/24x24/Report.png b/resources/Icons/24x24/Report.png
similarity index 100%
rename from data/Icons/24x24/Report.png
rename to resources/Icons/24x24/Report.png
diff --git a/data/Icons/24x24/Retort.png b/resources/Icons/24x24/Retort.png
similarity index 100%
rename from data/Icons/24x24/Retort.png
rename to resources/Icons/24x24/Retort.png
diff --git a/data/Icons/24x24/Save all.png b/resources/Icons/24x24/Save all.png
similarity index 100%
rename from data/Icons/24x24/Save all.png
rename to resources/Icons/24x24/Save all.png
diff --git a/data/Icons/24x24/Save as.png b/resources/Icons/24x24/Save as.png
similarity index 100%
rename from data/Icons/24x24/Save as.png
rename to resources/Icons/24x24/Save as.png
diff --git a/data/Icons/24x24/Save.png b/resources/Icons/24x24/Save.png
similarity index 100%
rename from data/Icons/24x24/Save.png
rename to resources/Icons/24x24/Save.png
diff --git a/data/Icons/24x24/Scales.png b/resources/Icons/24x24/Scales.png
similarity index 100%
rename from data/Icons/24x24/Scales.png
rename to resources/Icons/24x24/Scales.png
diff --git a/data/Icons/24x24/Scenario.png b/resources/Icons/24x24/Scenario.png
similarity index 100%
rename from data/Icons/24x24/Scenario.png
rename to resources/Icons/24x24/Scenario.png
diff --git a/data/Icons/24x24/Schedule.png b/resources/Icons/24x24/Schedule.png
similarity index 100%
rename from data/Icons/24x24/Schedule.png
rename to resources/Icons/24x24/Schedule.png
diff --git a/data/Icons/24x24/Script.png b/resources/Icons/24x24/Script.png
similarity index 100%
rename from data/Icons/24x24/Script.png
rename to resources/Icons/24x24/Script.png
diff --git a/data/Icons/24x24/Search.png b/resources/Icons/24x24/Search.png
similarity index 100%
rename from data/Icons/24x24/Search.png
rename to resources/Icons/24x24/Search.png
diff --git a/data/Icons/24x24/Send letter.png b/resources/Icons/24x24/Send letter.png
similarity index 100%
rename from data/Icons/24x24/Send letter.png
rename to resources/Icons/24x24/Send letter.png
diff --git a/data/Icons/24x24/Server.png b/resources/Icons/24x24/Server.png
similarity index 100%
rename from data/Icons/24x24/Server.png
rename to resources/Icons/24x24/Server.png
diff --git a/data/Icons/24x24/Settings.png b/resources/Icons/24x24/Settings.png
similarity index 100%
rename from data/Icons/24x24/Settings.png
rename to resources/Icons/24x24/Settings.png
diff --git a/data/Icons/24x24/Shield.png b/resources/Icons/24x24/Shield.png
similarity index 100%
rename from data/Icons/24x24/Shield.png
rename to resources/Icons/24x24/Shield.png
diff --git a/data/Icons/24x24/Signature.png b/resources/Icons/24x24/Signature.png
similarity index 100%
rename from data/Icons/24x24/Signature.png
rename to resources/Icons/24x24/Signature.png
diff --git a/data/Icons/24x24/Synchronize.png b/resources/Icons/24x24/Synchronize.png
similarity index 100%
rename from data/Icons/24x24/Synchronize.png
rename to resources/Icons/24x24/Synchronize.png
diff --git a/data/Icons/24x24/Table.png b/resources/Icons/24x24/Table.png
similarity index 100%
rename from data/Icons/24x24/Table.png
rename to resources/Icons/24x24/Table.png
diff --git a/data/Icons/24x24/Tables.png b/resources/Icons/24x24/Tables.png
similarity index 100%
rename from data/Icons/24x24/Tables.png
rename to resources/Icons/24x24/Tables.png
diff --git a/data/Icons/24x24/Timer.png b/resources/Icons/24x24/Timer.png
similarity index 100%
rename from data/Icons/24x24/Timer.png
rename to resources/Icons/24x24/Timer.png
diff --git a/data/Icons/24x24/Tip of the day.png b/resources/Icons/24x24/Tip of the day.png
similarity index 100%
rename from data/Icons/24x24/Tip of the day.png
rename to resources/Icons/24x24/Tip of the day.png
diff --git a/data/Icons/24x24/Tools.png b/resources/Icons/24x24/Tools.png
similarity index 100%
rename from data/Icons/24x24/Tools.png
rename to resources/Icons/24x24/Tools.png
diff --git a/data/Icons/24x24/Undelete.png b/resources/Icons/24x24/Undelete.png
similarity index 100%
rename from data/Icons/24x24/Undelete.png
rename to resources/Icons/24x24/Undelete.png
diff --git a/data/Icons/24x24/Undo.png b/resources/Icons/24x24/Undo.png
similarity index 100%
rename from data/Icons/24x24/Undo.png
rename to resources/Icons/24x24/Undo.png
diff --git a/data/Icons/24x24/Up.png b/resources/Icons/24x24/Up.png
similarity index 100%
rename from data/Icons/24x24/Up.png
rename to resources/Icons/24x24/Up.png
diff --git a/data/Icons/24x24/Update.png b/resources/Icons/24x24/Update.png
similarity index 100%
rename from data/Icons/24x24/Update.png
rename to resources/Icons/24x24/Update.png
diff --git a/data/Icons/24x24/View.png b/resources/Icons/24x24/View.png
similarity index 100%
rename from data/Icons/24x24/View.png
rename to resources/Icons/24x24/View.png
diff --git a/data/Icons/24x24/Web.png b/resources/Icons/24x24/Web.png
similarity index 100%
rename from data/Icons/24x24/Web.png
rename to resources/Icons/24x24/Web.png
diff --git a/data/Icons/24x24/Yes.png b/resources/Icons/24x24/Yes.png
similarity index 100%
rename from data/Icons/24x24/Yes.png
rename to resources/Icons/24x24/Yes.png
diff --git a/data/Icons/24x24/airfoil.png b/resources/Icons/24x24/airfoil.png
similarity index 100%
rename from data/Icons/24x24/airfoil.png
rename to resources/Icons/24x24/airfoil.png
diff --git a/data/Icons/24x24/fit-to-width-24.png b/resources/Icons/24x24/fit-to-width-24.png
similarity index 100%
rename from data/Icons/24x24/fit-to-width-24.png
rename to resources/Icons/24x24/fit-to-width-24.png
diff --git a/data/Icons/24x24/mesh.png b/resources/Icons/24x24/mesh.png
similarity index 100%
rename from data/Icons/24x24/mesh.png
rename to resources/Icons/24x24/mesh.png
diff --git a/data/Icons/24x24/profil.png b/resources/Icons/24x24/profil.png
similarity index 100%
rename from data/Icons/24x24/profil.png
rename to resources/Icons/24x24/profil.png
diff --git a/data/Icons/app_image.ico b/resources/Icons/app_image.ico
similarity index 100%
rename from data/Icons/app_image.ico
rename to resources/Icons/app_image.ico
diff --git a/data/Icons/app_image.png b/resources/Icons/app_image.png
similarity index 100%
rename from data/Icons/app_image.png
rename to resources/Icons/app_image.png
diff --git a/resources/Icons/app_image.svg b/resources/Icons/app_image.svg
new file mode 100644
index 0000000..95521ee
--- /dev/null
+++ b/resources/Icons/app_image.svg
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/data/Icons/app_image_24x24.png b/resources/Icons/app_image_24x24.png
similarity index 100%
rename from data/Icons/app_image_24x24.png
rename to resources/Icons/app_image_24x24.png
diff --git a/data/Icons/app_image_256x256.png b/resources/Icons/app_image_256x256.png
similarity index 100%
rename from data/Icons/app_image_256x256.png
rename to resources/Icons/app_image_256x256.png
diff --git a/resources/Icons/custom/airfoil-library.svg b/resources/Icons/custom/airfoil-library.svg
new file mode 100644
index 0000000..e806866
--- /dev/null
+++ b/resources/Icons/custom/airfoil-library.svg
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/resources/Icons/custom/airfoil.svg b/resources/Icons/custom/airfoil.svg
new file mode 100644
index 0000000..14dcc18
--- /dev/null
+++ b/resources/Icons/custom/airfoil.svg
@@ -0,0 +1,13 @@
+
+
+
diff --git a/resources/Icons/custom/mesh.svg b/resources/Icons/custom/mesh.svg
new file mode 100644
index 0000000..cf7118f
--- /dev/null
+++ b/resources/Icons/custom/mesh.svg
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+
+
diff --git a/resources/Icons/lucide/LICENSE b/resources/Icons/lucide/LICENSE
new file mode 100644
index 0000000..718bb3f
--- /dev/null
+++ b/resources/Icons/lucide/LICENSE
@@ -0,0 +1,43 @@
+ISC License
+
+Copyright (c) 2026 Lucide Icons and Contributors
+
+Permission to use, copy, modify, and/or distribute this software for any
+purpose with or without fee is hereby granted, provided that the above
+copyright notice and this permission notice appear in all copies.
+
+THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+
+---
+
+The following Lucide icons are derived from the Feather project:
+
+airplay, alert-circle, alert-octagon, alert-triangle, aperture, arrow-down-circle, arrow-down-left, arrow-down-right, arrow-down, arrow-left-circle, arrow-left, arrow-right-circle, arrow-right, arrow-up-circle, arrow-up-left, arrow-up-right, arrow-up, at-sign, calendar, cast, check, chevron-down, chevron-left, chevron-right, chevron-up, chevrons-down, chevrons-left, chevrons-right, chevrons-up, circle, clipboard, clock, code, columns, command, compass, corner-down-left, corner-down-right, corner-left-down, corner-left-up, corner-right-down, corner-right-up, corner-up-left, corner-up-right, crosshair, database, divide-circle, divide-square, dollar-sign, download, external-link, feather, frown, hash, headphones, help-circle, info, italic, key, layout, life-buoy, link-2, link, loader, lock, log-in, log-out, maximize, meh, minimize, minimize-2, minus-circle, minus-square, minus, monitor, moon, more-horizontal, more-vertical, move, music, navigation-2, navigation, octagon, pause-circle, percent, plus-circle, plus-square, plus, power, radio, rss, search, server, share, shopping-bag, sidebar, smartphone, smile, square, table-2, tablet, target, terminal, trash-2, trash, triangle, tv, type, upload, x-circle, x-octagon, x-square, x, zoom-in, zoom-out
+
+The MIT License (MIT) (for the icons listed above)
+
+Copyright (c) 2013-present Cole Bemis
+
+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.
diff --git a/resources/Icons/lucide/book-open-text.svg b/resources/Icons/lucide/book-open-text.svg
new file mode 100644
index 0000000..ac82a89
--- /dev/null
+++ b/resources/Icons/lucide/book-open-text.svg
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+
+
diff --git a/resources/Icons/lucide/calculator.svg b/resources/Icons/lucide/calculator.svg
new file mode 100644
index 0000000..2089728
--- /dev/null
+++ b/resources/Icons/lucide/calculator.svg
@@ -0,0 +1,22 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/resources/Icons/lucide/chart-line.svg b/resources/Icons/lucide/chart-line.svg
new file mode 100644
index 0000000..e5fc87c
--- /dev/null
+++ b/resources/Icons/lucide/chart-line.svg
@@ -0,0 +1,14 @@
+
+
+
+
diff --git a/resources/Icons/lucide/file-pen-line.svg b/resources/Icons/lucide/file-pen-line.svg
new file mode 100644
index 0000000..7e79f35
--- /dev/null
+++ b/resources/Icons/lucide/file-pen-line.svg
@@ -0,0 +1,16 @@
+
+
+
+
+
+
diff --git a/resources/Icons/lucide/file-search.svg b/resources/Icons/lucide/file-search.svg
new file mode 100644
index 0000000..0a840b8
--- /dev/null
+++ b/resources/Icons/lucide/file-search.svg
@@ -0,0 +1,16 @@
+
+
+
+
+
+
diff --git a/resources/Icons/lucide/folder-open.svg b/resources/Icons/lucide/folder-open.svg
new file mode 100644
index 0000000..b01f60f
--- /dev/null
+++ b/resources/Icons/lucide/folder-open.svg
@@ -0,0 +1,13 @@
+
+
+
diff --git a/resources/Icons/lucide/folder.svg b/resources/Icons/lucide/folder.svg
new file mode 100644
index 0000000..ef69b34
--- /dev/null
+++ b/resources/Icons/lucide/folder.svg
@@ -0,0 +1,13 @@
+
+
+
diff --git a/resources/Icons/lucide/gauge.svg b/resources/Icons/lucide/gauge.svg
new file mode 100644
index 0000000..340b932
--- /dev/null
+++ b/resources/Icons/lucide/gauge.svg
@@ -0,0 +1,14 @@
+
+
+
+
diff --git a/resources/Icons/lucide/info.svg b/resources/Icons/lucide/info.svg
new file mode 100644
index 0000000..2a46eac
--- /dev/null
+++ b/resources/Icons/lucide/info.svg
@@ -0,0 +1,15 @@
+
+
+
+
+
diff --git a/resources/Icons/lucide/keyboard.svg b/resources/Icons/lucide/keyboard.svg
new file mode 100644
index 0000000..2545828
--- /dev/null
+++ b/resources/Icons/lucide/keyboard.svg
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/resources/Icons/lucide/log-out.svg b/resources/Icons/lucide/log-out.svg
new file mode 100644
index 0000000..fe772e6
--- /dev/null
+++ b/resources/Icons/lucide/log-out.svg
@@ -0,0 +1,15 @@
+
+
+
+
+
diff --git a/resources/Icons/lucide/maximize.svg b/resources/Icons/lucide/maximize.svg
new file mode 100644
index 0000000..f78ca32
--- /dev/null
+++ b/resources/Icons/lucide/maximize.svg
@@ -0,0 +1,16 @@
+
+
+
+
+
+
diff --git a/resources/Icons/lucide/pen-tool.svg b/resources/Icons/lucide/pen-tool.svg
new file mode 100644
index 0000000..738c6fe
--- /dev/null
+++ b/resources/Icons/lucide/pen-tool.svg
@@ -0,0 +1,16 @@
+
+
+
+
+
+
diff --git a/resources/Icons/lucide/printer.svg b/resources/Icons/lucide/printer.svg
new file mode 100644
index 0000000..dfa0a60
--- /dev/null
+++ b/resources/Icons/lucide/printer.svg
@@ -0,0 +1,15 @@
+
+
+
+
+
diff --git a/resources/Icons/lucide/save.svg b/resources/Icons/lucide/save.svg
new file mode 100644
index 0000000..07af070
--- /dev/null
+++ b/resources/Icons/lucide/save.svg
@@ -0,0 +1,15 @@
+
+
+
+
+
diff --git a/resources/Icons/lucide/scan-search.svg b/resources/Icons/lucide/scan-search.svg
new file mode 100644
index 0000000..bb2f67f
--- /dev/null
+++ b/resources/Icons/lucide/scan-search.svg
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+
+
diff --git a/resources/Icons/lucide/settings.svg b/resources/Icons/lucide/settings.svg
new file mode 100644
index 0000000..4c6263a
--- /dev/null
+++ b/resources/Icons/lucide/settings.svg
@@ -0,0 +1,14 @@
+
+
+
+
diff --git a/resources/Icons/lucide/trash-2.svg b/resources/Icons/lucide/trash-2.svg
new file mode 100644
index 0000000..e8cb0ed
--- /dev/null
+++ b/resources/Icons/lucide/trash-2.svg
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+
diff --git a/resources/Icons/lucide/wind.svg b/resources/Icons/lucide/wind.svg
new file mode 100644
index 0000000..a649875
--- /dev/null
+++ b/resources/Icons/lucide/wind.svg
@@ -0,0 +1,15 @@
+
+
+
+
+
diff --git a/resources/Icons/lucide/workflow.svg b/resources/Icons/lucide/workflow.svg
new file mode 100644
index 0000000..20784a2
--- /dev/null
+++ b/resources/Icons/lucide/workflow.svg
@@ -0,0 +1,15 @@
+
+
+
+
+
diff --git a/data/Icons/mesh.png b/resources/Icons/mesh.png
similarity index 100%
rename from data/Icons/mesh.png
rename to resources/Icons/mesh.png
diff --git a/resources/Menus/menu_layout.json b/resources/Menus/menu_layout.json
new file mode 100644
index 0000000..7ec5b6c
--- /dev/null
+++ b/resources/Menus/menu_layout.json
@@ -0,0 +1,71 @@
+{
+ "menus": [
+ {
+ "name": "File",
+ "items": [
+ "file.open",
+ "file.save",
+ "file.save_as",
+ "separator",
+ "tools.settings",
+ "separator",
+ "file.print",
+ "file.print_preview",
+ "separator",
+ "app.exit"
+ ]
+ },
+ {
+ "name": "View",
+ "items": [
+ "view.fit_airfoil",
+ "view.fit_all",
+ {
+ "name": "Window Size",
+ "items": [
+ "view.window_size_cycle",
+ "view.window_size_preset_1",
+ "view.window_size_preset_2",
+ "view.window_size_preset_3"
+ ]
+ },
+ "separator",
+ "view.toggle_background",
+ "view.toggle_magnifier",
+ "view.toggle_messages"
+ ]
+ },
+ {
+ "name": "Tools",
+ "items": [
+ "tools.calculator",
+ "tools.icon_preview",
+ {
+ "name": "Export UI",
+ "items": [
+ "tools.export_ui_capture_set",
+ "separator",
+ "tools.export_ui_current_workflow_panel",
+ "tools.export_ui_active_airfoil_card",
+ "tools.export_ui_workflow_navigation",
+ "tools.export_ui_message_panel",
+ "tools.export_ui_viewer_controls_panel",
+ "tools.export_ui_canvas_screenshot"
+ ]
+ }
+ ]
+ },
+ {
+ "name": "Help",
+ "items": [
+ "help.manual_online",
+ "help.manual_pdf",
+ "separator",
+ "help.shortcuts",
+ "separator",
+ "help.about_qt",
+ "help.about_pyaero"
+ ]
+ }
+ ]
+}
diff --git a/resources/Menus/toolbar_layout.json b/resources/Menus/toolbar_layout.json
new file mode 100644
index 0000000..c2a56dd
--- /dev/null
+++ b/resources/Menus/toolbar_layout.json
@@ -0,0 +1,27 @@
+{
+ "toolbars": [
+ {
+ "name": "Toolbar",
+ "items": [
+ "file.open",
+ "file.save",
+ "separator",
+ "file.print",
+ "file.print_preview",
+ "separator",
+ "file.open_predefined",
+ "separator",
+ "airfoil.delete_active",
+ "separator",
+ "view.fit_airfoil",
+ "view.fit_all",
+ "separator",
+ "debug.autorun",
+ "separator",
+ "help.about_pyaero",
+ "separator",
+ "app.exit"
+ ]
+ }
+ ]
+}
diff --git a/resources/Shortcuts/shortcuts.json b/resources/Shortcuts/shortcuts.json
new file mode 100644
index 0000000..ff7d4cf
--- /dev/null
+++ b/resources/Shortcuts/shortcuts.json
@@ -0,0 +1,110 @@
+{
+ "version": 1,
+ "all": {
+ "file.open": [
+ {
+ "standard_key": "Open"
+ }
+ ],
+ "file.save": [
+ {
+ "standard_key": "Save"
+ }
+ ],
+ "file.save_as": [
+ {
+ "standard_key": "SaveAs"
+ }
+ ],
+ "file.print": [
+ {
+ "standard_key": "Print"
+ }
+ ],
+ "file.print_preview": [
+ "Ctrl+Shift+P"
+ ],
+ "file.open_predefined": [],
+ "app.exit": [
+ {
+ "standard_key": "Quit"
+ }
+ ],
+ "view.fit_airfoil": [
+ "F"
+ ],
+ "view.fit_all": [
+ "Home",
+ "Shift+F"
+ ],
+ "view.toggle_background": [
+ "B"
+ ],
+ "view.toggle_magnifier": [
+ "Ctrl+Alt+M"
+ ],
+ "view.toggle_messages": [
+ "Ctrl+Shift+M"
+ ],
+ "view.window_size_cycle": [
+ "Ctrl+Alt+W"
+ ],
+ "view.window_size_preset_1": [],
+ "view.window_size_preset_2": [],
+ "view.window_size_preset_3": [],
+ "view.zoom_in": [
+ "PageDown"
+ ],
+ "view.zoom_out": [
+ "PageUp"
+ ],
+ "view.magnifier_zoom_in": [
+ "+"
+ ],
+ "view.magnifier_zoom_out": [
+ "-"
+ ],
+ "view.magnifier_size_up": [
+ "Ctrl++"
+ ],
+ "view.magnifier_size_down": [
+ "Ctrl+-"
+ ],
+ "airfoil.delete_active": [
+ {
+ "standard_key": "Delete"
+ }
+ ],
+ "tools.calculator": [],
+ "tools.settings": [
+ {
+ "standard_key": "Preferences"
+ }
+ ],
+ "tools.icon_preview": [],
+ "tools.export_ui_capture_set": [],
+ "tools.export_ui_current_workflow_panel": [],
+ "tools.export_ui_active_airfoil_card": [],
+ "tools.export_ui_workflow_navigation": [],
+ "tools.export_ui_message_panel": [],
+ "tools.export_ui_viewer_controls_panel": [],
+ "tools.export_ui_canvas_screenshot": [
+ "Ctrl+Alt+S"
+ ],
+ "help.manual_online": [
+ {
+ "standard_key": "HelpContents"
+ }
+ ],
+ "help.manual_pdf": [],
+ "help.shortcuts": [
+ "Ctrl+K"
+ ],
+ "help.about_qt": [],
+ "help.about_pyaero": [],
+ "debug.autorun": []
+ },
+ "windows": {},
+ "macos": {},
+ "linux": {}
+}
diff --git a/src/ActionRegistry.py b/src/ActionRegistry.py
new file mode 100644
index 0000000..24971ee
--- /dev/null
+++ b/src/ActionRegistry.py
@@ -0,0 +1,975 @@
+from __future__ import annotations
+
+import copy
+import json
+import platform
+from collections.abc import Mapping, Sequence
+from dataclasses import dataclass
+from pathlib import Path
+from typing import Callable
+
+from PySide6 import QtCore, QtGui, QtWidgets
+
+import Icons
+
+import logging
+logger = logging.getLogger(__name__)
+
+
+ROOT = Path(__file__).resolve().parent.parent
+MENU_LAYOUT_FILE = ROOT / 'resources' / 'Menus' / 'menu_layout.json'
+TOOLBAR_LAYOUT_FILE = ROOT / 'resources' / 'Menus' / 'toolbar_layout.json'
+DEFAULT_SHORTCUT_FILE = ROOT / 'resources' / 'Shortcuts' / 'shortcuts.json'
+USER_SHORTCUT_FILE = ROOT / 'config' / 'shortcuts_user.json'
+LEGACY_SHORTCUT_FILE = ROOT / 'config' / 'shortcuts.json'
+SEPARATOR_TOKEN = 'separator'
+SHORTCUT_CONFIG_VERSION = 1
+PLATFORM_KEYS = ('all', 'windows', 'macos', 'linux')
+PLATFORM_LABELS = {
+ 'all': 'All platforms',
+ 'windows': 'Windows',
+ 'macos': 'macOS',
+ 'linux': 'Linux',
+}
+
+
+ActionHandlerFactory = Callable[[object], Callable[[], None]]
+ShortcutProvider = Callable[[object], Sequence[object]]
+
+
+@dataclass(frozen=True)
+class ActionDefinition:
+ action_id: str
+ text: str
+ tooltip: str
+ handler_factory: ActionHandlerFactory
+ icon_name: str = ''
+ shortcut_context: QtCore.Qt.ShortcutContext = QtCore.Qt.WindowShortcut
+ shortcut_targets: tuple[str, ...] = ('mainwindow',)
+ help_category: str = 'General'
+ user_editable: bool = True
+ shortcut_provider: ShortcutProvider | None = None
+
+
+@dataclass(frozen=True)
+class ShortcutHelpEntry:
+ category: str
+ text: str
+ description: str
+ shortcuts: tuple[str, ...]
+
+
+@dataclass(frozen=True)
+class ShortcutEditorEntry:
+ action_id: str
+ category: str
+ text: str
+ description: str
+ current_shortcuts: tuple[str, ...]
+ default_shortcuts: tuple[str, ...]
+ scope: str
+ source: str
+ editable: bool
+
+
+def _slot(slot_name):
+ return lambda mw: getattr(mw.slots, slot_name)
+
+
+def _view_method(method_name):
+ return lambda mw: getattr(mw.view, method_name)
+
+
+def _quit_application(_mw):
+ return lambda: QtCore.QCoreApplication.quit()
+
+
+def _escape_shortcuts(mw):
+ if getattr(mw, 'EXIT_ON_ESCAPE', False):
+ return ('Esc',)
+ return ()
+
+
+ACTION_DEFINITIONS = (
+ ActionDefinition(
+ action_id='file.open',
+ text='Open',
+ tooltip='Open airfoil contour file',
+ icon_name='open',
+ help_category='File',
+ handler_factory=_slot('onOpen'),
+ ),
+ ActionDefinition(
+ action_id='file.save',
+ text='Save',
+ tooltip='Save airfoil contour file',
+ icon_name='save',
+ help_category='File',
+ handler_factory=_slot('onSave'),
+ ),
+ ActionDefinition(
+ action_id='file.save_as',
+ text='Save as',
+ tooltip='Save airfoil contour file with different name',
+ icon_name='save-as',
+ help_category='File',
+ handler_factory=_slot('onSaveAs'),
+ ),
+ ActionDefinition(
+ action_id='file.print',
+ text='Print',
+ tooltip='Print current view',
+ icon_name='print',
+ help_category='File',
+ handler_factory=_slot('onPrint'),
+ ),
+ ActionDefinition(
+ action_id='file.print_preview',
+ text='Print preview',
+ tooltip='Print preview',
+ icon_name='print-preview',
+ help_category='File',
+ handler_factory=_slot('onPreview'),
+ ),
+ ActionDefinition(
+ action_id='file.open_predefined',
+ text='Open predefined airfoil contour',
+ tooltip='Open predefined airfoil contour',
+ icon_name='airfoil-library',
+ help_category='File',
+ handler_factory=_slot('onOpenPredefined'),
+ ),
+ ActionDefinition(
+ action_id='app.exit',
+ text='Exit',
+ tooltip='Shut down PyAero',
+ icon_name='exit',
+ help_category='Application',
+ handler_factory=_slot('onExit'),
+ ),
+ ActionDefinition(
+ action_id='app.exit_on_escape',
+ text='Exit on Escape',
+ tooltip='Close PyAero when Exit on Escape is enabled',
+ shortcut_context=QtCore.Qt.ApplicationShortcut,
+ shortcut_targets=('mainwindow',),
+ help_category='Application',
+ user_editable=False,
+ shortcut_provider=_escape_shortcuts,
+ handler_factory=_quit_application,
+ ),
+ ActionDefinition(
+ action_id='view.fit_airfoil',
+ text='Fit airfoil in view',
+ tooltip='Set view to selected airfoil',
+ icon_name='fit-airfoil',
+ shortcut_context=QtCore.Qt.WidgetWithChildrenShortcut,
+ shortcut_targets=('view',),
+ help_category='View',
+ handler_factory=_slot('fitAirfoilInView'),
+ ),
+ ActionDefinition(
+ action_id='view.fit_all',
+ text='Fit all in view',
+ tooltip='Set view to all items visible',
+ icon_name='fit-all',
+ shortcut_context=QtCore.Qt.WidgetWithChildrenShortcut,
+ shortcut_targets=('view',),
+ help_category='View',
+ handler_factory=_slot('onViewAll'),
+ ),
+ ActionDefinition(
+ action_id='view.toggle_background',
+ text='Toggle background',
+ tooltip='Toggle background color',
+ shortcut_context=QtCore.Qt.WidgetWithChildrenShortcut,
+ shortcut_targets=('view',),
+ help_category='View',
+ handler_factory=_slot('onBackground'),
+ ),
+ ActionDefinition(
+ action_id='view.toggle_magnifier',
+ text='Toggle magnifier',
+ tooltip='Turn the magnifier lens on or off',
+ shortcut_context=QtCore.Qt.WidgetWithChildrenShortcut,
+ shortcut_targets=('view',),
+ help_category='Magnifier',
+ handler_factory=_view_method('toggleMagnifier'),
+ ),
+ ActionDefinition(
+ action_id='view.toggle_messages',
+ text='Toggle message window',
+ tooltip='Toggle the message window to have more space for the viewer',
+ help_category='View',
+ handler_factory=_slot('toggleLogDock'),
+ ),
+ ActionDefinition(
+ action_id='view.window_size_cycle',
+ text='Cycle window sizes',
+ tooltip='Cycle through the configured window-size presets',
+ help_category='View',
+ handler_factory=_slot('onCycleWindowSize'),
+ ),
+ ActionDefinition(
+ action_id='view.window_size_preset_1',
+ text='Apply window preset 1',
+ tooltip='Resize the main window to the configured preset 1 geometry',
+ help_category='View',
+ handler_factory=_slot('onWindowPreset1'),
+ ),
+ ActionDefinition(
+ action_id='view.window_size_preset_2',
+ text='Apply window preset 2',
+ tooltip='Resize the main window to the configured preset 2 geometry',
+ help_category='View',
+ handler_factory=_slot('onWindowPreset2'),
+ ),
+ ActionDefinition(
+ action_id='view.window_size_preset_3',
+ text='Apply window preset 3',
+ tooltip='Resize the main window to the configured preset 3 geometry',
+ help_category='View',
+ handler_factory=_slot('onWindowPreset3'),
+ ),
+ ActionDefinition(
+ action_id='view.zoom_in',
+ text='Zoom in',
+ tooltip='Zoom in the current graphics view',
+ shortcut_context=QtCore.Qt.WidgetWithChildrenShortcut,
+ shortcut_targets=('view',),
+ help_category='Viewer',
+ handler_factory=_view_method('zoomIn'),
+ ),
+ ActionDefinition(
+ action_id='view.zoom_out',
+ text='Zoom out',
+ tooltip='Zoom out the current graphics view',
+ shortcut_context=QtCore.Qt.WidgetWithChildrenShortcut,
+ shortcut_targets=('view',),
+ help_category='Viewer',
+ handler_factory=_view_method('zoomOut'),
+ ),
+ ActionDefinition(
+ action_id='view.magnifier_zoom_in',
+ text='Magnifier zoom in',
+ tooltip='Increase the magnifier zoom level',
+ shortcut_context=QtCore.Qt.WidgetWithChildrenShortcut,
+ shortcut_targets=('view',),
+ help_category='Magnifier',
+ handler_factory=_view_method('magnifierZoomIn'),
+ ),
+ ActionDefinition(
+ action_id='view.magnifier_zoom_out',
+ text='Magnifier zoom out',
+ tooltip='Decrease the magnifier zoom level',
+ shortcut_context=QtCore.Qt.WidgetWithChildrenShortcut,
+ shortcut_targets=('view',),
+ help_category='Magnifier',
+ handler_factory=_view_method('magnifierZoomOut'),
+ ),
+ ActionDefinition(
+ action_id='view.magnifier_size_up',
+ text='Magnifier size up',
+ tooltip='Increase the magnifier lens size',
+ shortcut_context=QtCore.Qt.WidgetWithChildrenShortcut,
+ shortcut_targets=('view',),
+ help_category='Magnifier',
+ handler_factory=_view_method('magnifierIncreaseSize'),
+ ),
+ ActionDefinition(
+ action_id='view.magnifier_size_down',
+ text='Magnifier size down',
+ tooltip='Decrease the magnifier lens size',
+ shortcut_context=QtCore.Qt.WidgetWithChildrenShortcut,
+ shortcut_targets=('view',),
+ help_category='Magnifier',
+ handler_factory=_view_method('magnifierDecreaseSize'),
+ ),
+ ActionDefinition(
+ action_id='airfoil.delete_active',
+ text='Delete airfoil',
+ tooltip='Delete the active airfoil',
+ icon_name='delete',
+ shortcut_context=QtCore.Qt.WidgetWithChildrenShortcut,
+ shortcut_targets=('view',),
+ help_category='Viewer',
+ handler_factory=_slot('removeAirfoil'),
+ ),
+ ActionDefinition(
+ action_id='tools.calculator',
+ text='Calculator',
+ tooltip='Start the internal calculator',
+ icon_name='calculator',
+ help_category='Tools',
+ handler_factory=_slot('onCalculator'),
+ ),
+ ActionDefinition(
+ action_id='tools.settings',
+ text='Settings',
+ tooltip='Edit PyAero application settings',
+ icon_name='settings',
+ help_category='Tools',
+ handler_factory=_slot('onSettings'),
+ ),
+ ActionDefinition(
+ action_id='tools.icon_preview',
+ text='Icon Preview',
+ tooltip='Preview semantic icons and app assets',
+ help_category='Tools',
+ handler_factory=_slot('onIconPreview'),
+ ),
+ ActionDefinition(
+ action_id='tools.export_ui_capture_set',
+ text='Complete Export...',
+ tooltip='Export the current UI capture set into one folder',
+ help_category='Tools',
+ handler_factory=_slot('onExportUiCaptureSet'),
+ ),
+ ActionDefinition(
+ action_id='tools.export_ui_current_workflow_panel',
+ text='Current Workflow Panel...',
+ tooltip='Save the current rounded workflow panel as a PNG',
+ help_category='Tools',
+ handler_factory=_slot('onExportCurrentWorkflowPanel'),
+ ),
+ ActionDefinition(
+ action_id='tools.export_ui_active_airfoil_card',
+ text='Active Airfoil Card...',
+ tooltip='Save the active-airfoil summary card as a PNG',
+ help_category='Tools',
+ handler_factory=_slot('onExportActiveAirfoilCard'),
+ ),
+ ActionDefinition(
+ action_id='tools.export_ui_workflow_navigation',
+ text='Workflow Navigation...',
+ tooltip='Save the workflow navigation card as a PNG',
+ help_category='Tools',
+ handler_factory=_slot('onExportWorkflowNavigation'),
+ ),
+ ActionDefinition(
+ action_id='tools.export_ui_message_panel',
+ text='Message Panel...',
+ tooltip='Save the message panel as a PNG',
+ help_category='Tools',
+ handler_factory=_slot('onExportMessagePanel'),
+ ),
+ ActionDefinition(
+ action_id='tools.export_ui_viewer_controls_panel',
+ text='Viewer Controls Panel...',
+ tooltip='Save the viewer controls panel as a PNG',
+ help_category='Tools',
+ handler_factory=_slot('onExportViewerControlsPanel'),
+ ),
+ ActionDefinition(
+ action_id='tools.export_ui_canvas_screenshot',
+ text='Canvas Screenshot...',
+ tooltip='Save the current viewer or analysis canvas as a PNG',
+ help_category='Tools',
+ handler_factory=_slot('onExportCanvasScreenshot'),
+ ),
+ ActionDefinition(
+ action_id='help.manual_online',
+ text='Manual (online)',
+ tooltip='Open PyAero online documentation',
+ icon_name='manual',
+ help_category='Help',
+ handler_factory=_slot('onHelpOnline'),
+ ),
+ ActionDefinition(
+ action_id='help.manual_pdf',
+ text='Manual (PDF)',
+ tooltip='Open PyAero manual',
+ icon_name='manual',
+ help_category='Help',
+ handler_factory=_slot('onHelpPDF'),
+ ),
+ ActionDefinition(
+ action_id='help.shortcuts',
+ text='Keyboard shortcuts',
+ tooltip='Edit keyboard shortcuts',
+ icon_name='keyboard-shortcuts',
+ help_category='Help',
+ handler_factory=_slot('onKeyBd'),
+ ),
+ ActionDefinition(
+ action_id='help.about_qt',
+ text='About Qt',
+ tooltip='Show the Qt libraries about box',
+ help_category='Help',
+ handler_factory=_slot('onAboutQt'),
+ ),
+ ActionDefinition(
+ action_id='help.about_pyaero',
+ text='About PyAero',
+ tooltip='Information about the PyAero and its licensing',
+ icon_name='about',
+ help_category='Help',
+ handler_factory=_slot('onAbout'),
+ ),
+ ActionDefinition(
+ action_id='debug.autorun',
+ text='Autorun (for testing)',
+ tooltip='Autorun (for testing)',
+ icon_name='autorun',
+ help_category='Tools',
+ handler_factory=_slot('runCommands'),
+ ),
+)
+
+
+class ActionRegistry:
+ def __init__(self, mainwindow):
+ self.mw = mainwindow
+ self._actions = {}
+ self._definitions = {
+ definition.action_id: definition for definition in ACTION_DEFINITIONS
+ }
+ self._definition_order = tuple(
+ definition.action_id for definition in ACTION_DEFINITIONS
+ )
+ self._menu_layout = None
+ self._toolbar_layout = None
+ self._default_shortcut_config = self._load_shortcut_config(
+ DEFAULT_SHORTCUT_FILE,
+ require_platform_sections=True,
+ )
+ self._user_shortcut_config = self._load_user_shortcut_config()
+
+ def install(self):
+ for action_id in self._definition_order:
+ self._actions[action_id] = self._create_action(
+ self._definitions[action_id]
+ )
+
+ def action(self, action_id):
+ return self._actions.get(action_id)
+
+ def action_ids(self):
+ return self._definition_order
+
+ def definition(self, action_id):
+ return self._definitions.get(action_id)
+
+ def menu_layout(self):
+ if self._menu_layout is None:
+ self._menu_layout = self._load_layout(MENU_LAYOUT_FILE, 'menus')
+ return self._menu_layout
+
+ def toolbar_layout(self):
+ if self._toolbar_layout is None:
+ self._toolbar_layout = self._load_layout(
+ TOOLBAR_LAYOUT_FILE,
+ 'toolbars',
+ )
+ return self._toolbar_layout
+
+ def platform_key(self):
+ platform_name = getattr(self.mw, 'platform', '') or platform.system()
+ platform_name = str(platform_name).lower()
+ if platform_name.startswith('win'):
+ return 'windows'
+ if platform_name.startswith('darwin') or platform_name.startswith('mac'):
+ return 'macos'
+ return 'linux'
+
+ def platform_label(self):
+ return PLATFORM_LABELS.get(self.platform_key(), self.platform_key())
+
+ def shortcut_help(self):
+ entries = []
+ for action_id in self._definition_order:
+ definition = self.definition(action_id)
+ shortcut_text = self.active_shortcut_text(action_id)
+ if not shortcut_text:
+ continue
+
+ entries.append(
+ ShortcutHelpEntry(
+ category=definition.help_category,
+ text=definition.text,
+ description=definition.tooltip or definition.text,
+ shortcuts=shortcut_text,
+ )
+ )
+ return tuple(entries)
+
+ def shortcut_editor_entries(self, platform_overrides=None):
+ entries = []
+ user_config = self._user_config_with_platform_overrides(platform_overrides)
+ for action_id in self._definition_order:
+ definition = self.definition(action_id)
+ entries.append(
+ ShortcutEditorEntry(
+ action_id=action_id,
+ category=definition.help_category,
+ text=definition.text,
+ description=definition.tooltip or definition.text,
+ current_shortcuts=self.active_shortcut_text(
+ action_id,
+ user_config=user_config,
+ ),
+ default_shortcuts=self.default_shortcut_text(action_id),
+ scope=self._scope_label(definition),
+ source=self._shortcut_source_label(
+ action_id,
+ user_config=user_config,
+ ),
+ editable=definition.user_editable,
+ )
+ )
+ return tuple(entries)
+
+ def platform_override_specs(self):
+ section = self._section_mapping(
+ self._user_shortcut_config,
+ self.platform_key(),
+ )
+ overrides = {}
+ for action_id, value in section.items():
+ overrides[action_id] = tuple(self._normalize_shortcut_specs(value))
+ return overrides
+
+ def active_shortcut_text(self, action_id, user_config=None):
+ return tuple(
+ self._format_shortcut_text(
+ self.active_shortcuts(action_id, user_config=user_config)
+ )
+ )
+
+ def default_shortcut_text(self, action_id):
+ return tuple(
+ self._format_shortcut_text(self.default_shortcuts(action_id))
+ )
+
+ def active_shortcuts(self, action_id, user_config=None):
+ return tuple(
+ self._resolve_shortcuts(action_id, user_config=user_config)
+ )
+
+ def default_shortcuts(self, action_id):
+ return tuple(
+ self._specs_to_sequences(
+ self._default_shortcut_specs(action_id),
+ )
+ )
+
+ def active_portable_shortcuts(self, action_id, user_config=None):
+ return tuple(
+ self._portable_texts(
+ self.active_shortcuts(action_id, user_config=user_config)
+ )
+ )
+
+ def active_portable_shortcuts_for_overrides(self, action_id, platform_overrides=None):
+ user_config = self._user_config_with_platform_overrides(platform_overrides)
+ return self.active_portable_shortcuts(action_id, user_config=user_config)
+
+ def detect_shortcut_conflicts(self, platform_overrides=None):
+ user_config = self._user_config_with_platform_overrides(platform_overrides)
+ usage = {}
+ for action_id in self._definition_order:
+ seen = set()
+ for portable_text in self.active_portable_shortcuts(
+ action_id,
+ user_config=user_config,
+ ):
+ if not portable_text or portable_text in seen:
+ continue
+ usage.setdefault(portable_text, []).append(action_id)
+ seen.add(portable_text)
+
+ return {
+ shortcut: tuple(action_ids)
+ for shortcut, action_ids in usage.items()
+ if len(action_ids) > 1
+ }
+
+ def save_platform_overrides(self, platform_overrides):
+ config = copy.deepcopy(self._user_shortcut_config)
+ platform_key = self.platform_key()
+ normalized_platform_section = {}
+
+ inherited_user_config = copy.deepcopy(config)
+ inherited_user_config[platform_key] = {}
+
+ for action_id, value in platform_overrides.items():
+ definition = self.definition(action_id)
+ if definition is None or not definition.user_editable:
+ continue
+
+ specs = tuple(self._normalize_shortcut_specs(value))
+ sequences = self._specs_to_sequences(specs)
+ inherited_sequences = self._resolve_shortcuts(
+ action_id,
+ user_config=inherited_user_config,
+ )
+
+ if self._portable_texts(sequences) == self._portable_texts(inherited_sequences):
+ continue
+
+ normalized_platform_section[action_id] = list(specs)
+
+ config[platform_key] = normalized_platform_section
+ self._write_shortcut_config(USER_SHORTCUT_FILE, config)
+ self._user_shortcut_config = config
+ self.apply_shortcuts()
+
+ def apply_shortcuts(self):
+ for action_id, action in self._actions.items():
+ if action is None:
+ continue
+ action.setShortcuts(self.active_shortcuts(action_id))
+
+ def _load_user_shortcut_config(self):
+ user_config = self._load_shortcut_config(
+ USER_SHORTCUT_FILE,
+ require_platform_sections=False,
+ )
+ legacy_section = self._load_legacy_shortcut_section()
+ if not legacy_section:
+ return user_config
+
+ platform_key = self.platform_key()
+ platform_section = self._section_mapping(user_config, platform_key)
+ if platform_section:
+ return user_config
+
+ user_config[platform_key] = legacy_section
+ self._write_shortcut_config(USER_SHORTCUT_FILE, user_config)
+ logger.info(
+ 'Migrated legacy shortcuts from %s to %s',
+ LEGACY_SHORTCUT_FILE,
+ USER_SHORTCUT_FILE,
+ )
+ return user_config
+
+ def _load_legacy_shortcut_section(self):
+ if not LEGACY_SHORTCUT_FILE.exists():
+ return {}
+
+ try:
+ with LEGACY_SHORTCUT_FILE.open('r', encoding='utf-8') as handle:
+ data = json.load(handle)
+ except (OSError, ValueError) as error:
+ logger.warning(
+ 'Failed to read legacy shortcuts from %s: %s',
+ LEGACY_SHORTCUT_FILE,
+ error,
+ )
+ return {}
+
+ if not isinstance(data, Mapping):
+ return {}
+
+ if any(key in PLATFORM_KEYS for key in data.keys()):
+ return {}
+
+ section = {}
+ for action_id, value in data.items():
+ section[action_id] = list(self._normalize_shortcut_specs(value))
+ return section
+
+ def _load_shortcut_config(self, filename, require_platform_sections):
+ base = self._empty_shortcut_config()
+ if not filename.exists():
+ return base
+
+ try:
+ with filename.open('r', encoding='utf-8') as handle:
+ data = json.load(handle)
+ except (OSError, ValueError) as error:
+ logger.warning(
+ 'Failed to read shortcuts from %s: %s',
+ filename,
+ error,
+ )
+ return base
+
+ if not isinstance(data, Mapping):
+ logger.warning(
+ 'Ignoring shortcuts in %s because the content is not a mapping.',
+ filename,
+ )
+ return base
+
+ config = self._empty_shortcut_config()
+ if isinstance(data.get('version'), int):
+ config['version'] = data['version']
+
+ for key in PLATFORM_KEYS:
+ section = data.get(key, {})
+ if not isinstance(section, Mapping):
+ if require_platform_sections and key in data:
+ logger.warning(
+ 'Ignoring invalid shortcut section %s in %s',
+ key,
+ filename,
+ )
+ continue
+
+ normalized_section = {}
+ for action_id, value in section.items():
+ normalized_section[action_id] = list(
+ self._normalize_shortcut_specs(value)
+ )
+ config[key] = normalized_section
+
+ return config
+
+ def _load_layout(self, filename, top_level_key):
+ with filename.open('r', encoding='utf-8') as handle:
+ data = json.load(handle)
+
+ layout = data.get(top_level_key, [])
+ if not isinstance(layout, list):
+ raise ValueError(f'Invalid layout structure in {filename}')
+
+ return tuple(layout)
+
+ def _create_action(self, definition):
+ icon = Icons.icon(definition.icon_name) if definition.icon_name else QtGui.QIcon()
+ action = QtGui.QAction(icon, definition.text, self.mw)
+ action.setObjectName(definition.action_id)
+ action.setStatusTip(definition.tooltip or definition.text)
+ action.setToolTip(definition.tooltip or definition.text)
+ action.setShortcutContext(definition.shortcut_context)
+ action.setShortcuts(self.active_shortcuts(definition.action_id))
+
+ handler = definition.handler_factory(self.mw)
+ action.triggered.connect(
+ lambda _checked=False, callback=handler: callback()
+ )
+
+ for target_name in definition.shortcut_targets:
+ target = self._resolve_target(target_name)
+ if target is None:
+ logger.warning(
+ 'Shortcut target %s for action %s is not available.',
+ target_name,
+ definition.action_id,
+ )
+ continue
+ target.addAction(action)
+
+ return action
+
+ def _resolve_shortcuts(self, action_id, user_config=None):
+ definition = self.definition(action_id)
+ if definition is None:
+ return []
+
+ if definition.shortcut_provider is not None:
+ return self._specs_to_sequences(definition.shortcut_provider(self.mw))
+
+ specs = self._effective_shortcut_specs(
+ action_id,
+ user_config=user_config,
+ )
+ return self._specs_to_sequences(specs)
+
+ def _effective_shortcut_specs(self, action_id, user_config=None):
+ config = user_config or self._user_shortcut_config
+ platform_key = self.platform_key()
+ platform_user = self._section_mapping(config, platform_key)
+ all_user = self._section_mapping(config, 'all')
+ platform_default = self._section_mapping(
+ self._default_shortcut_config,
+ platform_key,
+ )
+ all_default = self._section_mapping(self._default_shortcut_config, 'all')
+
+ if action_id in platform_user:
+ return platform_user[action_id]
+ if action_id in all_user:
+ return all_user[action_id]
+ if action_id in platform_default:
+ return platform_default[action_id]
+ if action_id in all_default:
+ return all_default[action_id]
+ return ()
+
+ def _default_shortcut_specs(self, action_id):
+ platform_key = self.platform_key()
+ platform_default = self._section_mapping(
+ self._default_shortcut_config,
+ platform_key,
+ )
+ all_default = self._section_mapping(self._default_shortcut_config, 'all')
+ if action_id in platform_default:
+ return platform_default[action_id]
+ if action_id in all_default:
+ return all_default[action_id]
+ return ()
+
+ def _shortcut_source_label(self, action_id, user_config=None):
+ definition = self.definition(action_id)
+ if definition.shortcut_provider is not None:
+ active = self.active_shortcut_text(action_id, user_config=user_config)
+ if active:
+ return 'Managed by application setting'
+ return 'Disabled by application setting'
+
+ config = user_config or self._user_shortcut_config
+ platform_key = self.platform_key()
+ platform_user = self._section_mapping(config, platform_key)
+ all_user = self._section_mapping(config, 'all')
+ platform_default = self._section_mapping(
+ self._default_shortcut_config,
+ platform_key,
+ )
+ all_default = self._section_mapping(self._default_shortcut_config, 'all')
+
+ if action_id in platform_user:
+ return f'User override ({self.platform_label()})'
+ if action_id in all_user:
+ return 'User override (all platforms)'
+ if action_id in platform_default:
+ return f'Built-in default ({self.platform_label()})'
+ if action_id in all_default:
+ return 'Built-in default'
+ return 'No shortcut'
+
+ def _user_config_with_platform_overrides(self, platform_overrides):
+ if platform_overrides is None:
+ return self._user_shortcut_config
+
+ config = copy.deepcopy(self._user_shortcut_config)
+ platform_key = self.platform_key()
+ config[platform_key] = {}
+ for action_id, value in platform_overrides.items():
+ config[platform_key][action_id] = list(
+ self._normalize_shortcut_specs(value)
+ )
+ return config
+
+ def _normalize_shortcut_specs(self, shortcut_value):
+ if shortcut_value is None:
+ return ()
+
+ if isinstance(shortcut_value, str):
+ values = [shortcut_value]
+ elif isinstance(shortcut_value, Mapping):
+ values = [shortcut_value]
+ elif isinstance(shortcut_value, Sequence):
+ values = list(shortcut_value)
+ else:
+ values = []
+
+ normalized = []
+ for value in values:
+ if isinstance(value, str):
+ text = value.strip()
+ if text:
+ normalized.append(text)
+ continue
+
+ if not isinstance(value, Mapping):
+ continue
+
+ standard_key = value.get('standard_key')
+ if isinstance(standard_key, str) and standard_key.strip():
+ normalized.append({'standard_key': standard_key.strip()})
+ continue
+
+ sequence_text = value.get('sequence')
+ if isinstance(sequence_text, str) and sequence_text.strip():
+ normalized.append(sequence_text.strip())
+
+ return tuple(normalized)
+
+ def _specs_to_sequences(self, specs):
+ sequences = []
+ seen = set()
+ for spec in self._normalize_shortcut_specs(specs):
+ if isinstance(spec, Mapping):
+ standard_key = self._resolve_standard_key(spec.get('standard_key'))
+ if standard_key is None:
+ continue
+ candidates = QtGui.QKeySequence.keyBindings(standard_key)
+ else:
+ sequence = QtGui.QKeySequence.fromString(
+ str(spec),
+ QtGui.QKeySequence.PortableText,
+ )
+ candidates = [sequence] if not sequence.isEmpty() else []
+
+ for candidate in candidates:
+ portable = candidate.toString(QtGui.QKeySequence.PortableText)
+ if not portable or portable in seen:
+ continue
+ sequences.append(candidate)
+ seen.add(portable)
+ return sequences
+
+ def _resolve_standard_key(self, name):
+ if not isinstance(name, str) or not name:
+ return None
+
+ enum_type = getattr(QtGui.QKeySequence, 'StandardKey', None)
+ if enum_type is not None and hasattr(enum_type, name):
+ return getattr(enum_type, name)
+
+ if hasattr(QtGui.QKeySequence, name):
+ return getattr(QtGui.QKeySequence, name)
+
+ logger.warning('Unknown standard shortcut key %s', name)
+ return None
+
+ def _portable_texts(self, shortcuts):
+ texts = []
+ for shortcut in shortcuts:
+ text = shortcut.toString(QtGui.QKeySequence.PortableText)
+ if not text:
+ text = shortcut.toString()
+ if text and text not in texts:
+ texts.append(text)
+ return tuple(texts)
+
+ def _resolve_target(self, target_name):
+ if target_name == 'mainwindow':
+ return self.mw
+ if target_name == 'view':
+ return getattr(self.mw, 'view', None)
+ if target_name == 'toolbox':
+ return getattr(getattr(self.mw, 'mainArea', None), 'toolbox', None)
+ return None
+
+ def _scope_label(self, definition):
+ if definition.shortcut_context == QtCore.Qt.ApplicationShortcut:
+ return 'Application'
+ if definition.shortcut_targets == ('view',):
+ return 'Viewer'
+ if definition.shortcut_targets == ('toolbox',):
+ return 'Toolbox'
+ return 'Window'
+
+ def _section_mapping(self, config, key):
+ section = config.get(key, {})
+ if isinstance(section, Mapping):
+ return section
+ return {}
+
+ def _empty_shortcut_config(self):
+ return {
+ 'version': SHORTCUT_CONFIG_VERSION,
+ 'all': {},
+ 'windows': {},
+ 'macos': {},
+ 'linux': {},
+ }
+
+ def _write_shortcut_config(self, filename, config):
+ with filename.open('w', encoding='utf-8') as handle:
+ json.dump(config, handle, indent=2)
+ handle.write('\n')
+
+ @staticmethod
+ def _format_shortcut_text(shortcuts):
+ texts = []
+ for shortcut in shortcuts:
+ text = shortcut.toString(QtGui.QKeySequence.NativeText)
+ if not text:
+ text = shortcut.toString()
+ if text and text not in texts:
+ texts.append(text)
+ return texts
diff --git a/src/Airfoil.py b/src/Airfoil.py
index 9197bbc..6eaea9a 100644
--- a/src/Airfoil.py
+++ b/src/Airfoil.py
@@ -1,16 +1,18 @@
import numpy as np
-from PySide6 import QtGui, QtCore
-
-import GraphicsItemsCollection as gic
-import GraphicsItem
+from PySide6 import QtGui, QtCore
+
+import GraphicsItemsCollection as gic
+import GraphicsItem
+from ContourData import CamberData
+from Shape import Polygon, Polyline
import logging
logger = logging.getLogger(__name__)
-class Airfoil:
+class Airfoil:
"""Class to read airfoil data from file (or use predefined airfoil)
The Airfoil object carries several graphics items:
@@ -26,24 +28,147 @@ class Airfoil:
raw_coordinates (numpy array): list of contour points as tuples
"""
- def __init__(self, name):
-
- # get MainWindow instance (overcomes handling parents)
- self.mainwindow = QtCore.QCoreApplication.instance().mainwindow
-
- self.name = name
- self.chord = None
- self.has_TE = False
- self.contourPolygon = None
- # self.contourSpline = None
- self.spline_data = None
- self.raw_coordinates = None
- self.pencolor = QtGui.QColor(0, 20, 255, 255)
- self.penwidth = 4.0
- self.brushcolor = QtGui.QColor()
- self.brushcolor.setNamedColor('#7c8696')
-
- def readContour(self, filename, comment):
+ def __init__(self, name, mainwindow=None):
+
+ # MainWindow instance
+ self.mw = mainwindow or QtCore.QCoreApplication.instance().mainwindow
+
+ self.name = name
+ self.chord = None
+ self.has_TE = False
+ self.contourPolygon = None
+ self.contourSpline = None
+ self.polygonMarkersGroup = None
+ self.splineMarkersGroup = None
+ self.camber_data = None
+ self.camberline = None
+ self.camber_circles = None
+ self.camberCircleMarkers = []
+ self.max_thickness_marker = None
+ self.maxThicknessMarkers = []
+ self.max_thickness_marker_coordinates = None
+ self.max_camber_marker = None
+ self.maxCamberMarkers = []
+ self.max_camber_marker_coordinates = None
+ self.le_circle = None
+ self.mesh = None
+ self.mesh_blocks = None
+ self.mesh_quality = None
+ self.mesh_model = None
+ self.domain_model = None
+ self.curvature_data = None
+ self.spline_data = None
+ self.spline_fill_enabled = False
+ self.raw_coordinates = None
+ self.source_path = None
+ self.polygonMarkers = []
+ self.splineMarkers = []
+ self.pencolor = QtGui.QColor('#6f7f90')
+ self.penwidth = 2.4
+ self.brushcolor = QtGui.QColor(219, 229, 238, 36)
+
+ def _display_palette(self):
+ return {
+ 'raw_pen': QtGui.QColor('#6f7f90'),
+ 'raw_fill': QtGui.QColor(219, 229, 238, 36),
+ 'raw_marker_pen': QtGui.QColor('#4e6277'),
+ 'raw_marker_fill': QtGui.QColor('#e07a94'),
+ 'spline_pen': QtGui.QColor('#3a7ea1'),
+ 'spline_fill': QtGui.QColor(58, 126, 161, 34),
+ 'spline_marker_pen': QtGui.QColor('#35556f'),
+ 'spline_marker_fill': QtGui.QColor('#93c83e'),
+ 'chord_pen': QtGui.QColor('#94a4b5'),
+ 'camber_pen': QtGui.QColor('#d07c4d'),
+ 'camber_circle_pen': QtGui.QColor('#d07c4d'),
+ 'camber_circle_fill': QtGui.QColor(208, 124, 77, 22),
+ 'max_thickness_pen': QtGui.QColor('#3d8f8b'),
+ 'max_thickness_fill': QtGui.QColor('#73d0c7'),
+ 'max_camber_pen': QtGui.QColor('#9e5c7f'),
+ 'max_camber_fill': QtGui.QColor('#db8eb4'),
+ 'le_circle_pen': QtGui.QColor('#5f7ea4'),
+ 'le_circle_fill': QtGui.QColor(95, 126, 164, 24),
+ 'le_center_pen': QtGui.QColor('#3f5b82'),
+ 'le_center_fill': QtGui.QColor('#3f5b82'),
+ 'le_tangent_pen': QtGui.QColor('#7f9ec4'),
+ 'le_tangent_fill': QtGui.QColor('#7f9ec4'),
+ }
+
+ def _apply_spline_fill_style(self):
+ if self.contourSpline is None:
+ return
+
+ palette = self._display_palette()
+ self.contourSpline.brush.setColor(palette['spline_fill'])
+ if self.spline_fill_enabled:
+ self.contourSpline.brush.setStyle(QtCore.Qt.SolidPattern)
+ else:
+ self.contourSpline.brush.setStyle(QtCore.Qt.NoBrush)
+ self.contourSpline.update()
+ if hasattr(self.mw, 'scene') and self.mw.scene is not None:
+ self.mw.scene.update()
+ for view in self.mw.scene.views():
+ view.viewport().update()
+
+ def setSplineFillEnabled(self, enabled):
+ self.spline_fill_enabled = bool(enabled)
+ self._apply_spline_fill_style()
+
+ @classmethod
+ def from_file(cls, filename, comment='#', mainwindow=None):
+ fileinfo = QtCore.QFileInfo(filename)
+ airfoil = cls(fileinfo.fileName(), mainwindow=mainwindow)
+ airfoil.source_path = fileinfo.absoluteFilePath()
+ if airfoil.readContour(filename, comment):
+ return airfoil
+ return None
+
+ @property
+ def has_spline(self):
+ return self.spline_data is not None
+
+ def current_contour(self, prefer_spline=True):
+ if prefer_spline and self.spline_data is not None:
+ return self.spline_data.coordinates
+ return self.raw_coordinates
+
+ def markerCollections(self):
+ collections = []
+ if self.raw_coordinates is not None and self.polygonMarkers:
+ collections.append((self.raw_coordinates, self.polygonMarkers, 1.0))
+ if self.has_spline and self.splineMarkers:
+ collections.append((self.spline_data.coordinates, self.splineMarkers, 1.0))
+ if self.camber_data is not None and self.camberCircleMarkers:
+ collections.append((
+ self.camber_data.display_coordinates(),
+ self.camberCircleMarkers,
+ 0.6,
+ ))
+ if self.max_thickness_marker_coordinates and self.maxThicknessMarkers:
+ collections.append((
+ self.max_thickness_marker_coordinates,
+ self.maxThicknessMarkers,
+ 1.3,
+ ))
+ if self.max_camber_marker_coordinates and self.maxCamberMarkers:
+ collections.append((
+ self.max_camber_marker_coordinates,
+ self.maxCamberMarkers,
+ 1.3,
+ ))
+ return collections
+
+ def to_shape(self, prefer_spline=True, closed=True):
+ contour = self.current_contour(prefer_spline=prefer_spline)
+ if contour is None:
+ return None
+
+ points = list(zip(*contour))
+ label = f'{self.name} contour'
+ if closed:
+ return Polygon(points, name=label)
+ return Polyline(points, closed=False, name=label)
+
+ def readContour(self, filename, comment):
try:
with open(filename, mode='r') as f:
@@ -100,60 +225,71 @@ def readContour(self, filename, comment):
return True
- def makeAirfoil(self):
- # make polygon graphicsitem from coordinates
- self.makeContourPolygon()
- self.makeChord()
- self.makePolygonMarkers()
+ def makeAirfoil(self):
+ if hasattr(self.mw, 'mainArea') and self.mw.mainArea is not None:
+ self.mw.mainArea.resetAirfoilViewControls()
+
+ # make polygon graphicsitem from coordinates
+ self.makeContourPolygon()
+ self.makeChord()
+ self.makePolygonMarkers()
# Activate checkboxes for contour points, polygon, and chord in viewing options.
checkboxes = [
- self.mainwindow.centralwidget.airfoil_points_checkbox,
- self.mainwindow.centralwidget.airfoil_raw_contour_checkbox,
- self.mainwindow.centralwidget.airfoil_chord_checkbox,
+ self.mw.mainArea.airfoil_points_checkbox,
+ self.mw.mainArea.airfoil_raw_contour_checkbox,
+ self.mw.mainArea.airfoil_chord_checkbox,
]
for checkbox in checkboxes:
checkbox.setChecked(True)
checkbox.setEnabled(True)
- def addToScene(self, scene):
- """add all items to the scene"""
- scene.addItem(self.contourPolygon)
- scene.addItem(self.chord)
- self.polygonMarkersGroup = scene. \
- createItemGroup(self.polygonMarkers)
-
- def makeContourPolygon(self):
- """Add airfoil points as GraphicsItem to the scene"""
-
- # instantiate a graphics item
- contour = gic.GraphicsCollection()
+ def addToScene(self, scene):
+ """add all items to the scene"""
+ scene.addItem(self.contourPolygon)
+ scene.addItem(self.chord)
+ self.polygonMarkersGroup = scene. \
+ createItemGroup(self.polygonMarkers)
+ self.contourPolygon.setZValue(20)
+ self.chord.setZValue(30)
+ self.polygonMarkersGroup.setZValue(120)
+
+ def makeContourPolygon(self):
+ """Add airfoil points as GraphicsItem to the scene"""
+ palette = self._display_palette()
+
+ # instantiate a graphics item
+ contour = gic.GraphicsCollection()
# make it polygon type and populate its points
- points = [QtCore.QPointF(x, y) for x, y in zip(*self.raw_coordinates)]
- contour.Polygon(QtGui.QPolygonF(points), self.name)
- # set its properties
- contour.pen.setColor(self.pencolor)
- contour.pen.setWidthF(self.penwidth)
- # no pen thickness change when zoomed
- contour.pen.setCosmetic(True)
- contour.brush.setColor(self.brushcolor)
-
- self.contourPolygon = GraphicsItem.GraphicsItem(contour)
-
- def makePolygonMarkers(self):
- """Create marker for polygon contour"""
-
- self.polygonMarkers = list()
-
- for x, y in zip(*self.raw_coordinates):
-
- marker = gic.GraphicsCollection()
- marker.pen.setColor(QtGui.QColor(60, 60, 80, 255))
- marker.pen.setWidthF(1.6)
- # no pen thickness change when zoomed
- marker.pen.setCosmetic(True)
- marker.brush.setColor(QtGui.QColor(217, 63, 122, 255))
- # circle size doesn't do anything here
+ points = [QtCore.QPointF(x, y) for x, y in zip(*self.raw_coordinates)]
+ contour.Polygon(QtGui.QPolygonF(points), self.name)
+ # set its properties
+ contour.pen.setColor(palette['raw_pen'])
+ contour.pen.setWidthF(self.penwidth)
+ # no pen thickness change when zoomed
+ contour.pen.setCosmetic(True)
+ contour.brush.setColor(palette['raw_fill'])
+ contour.brush.setStyle(QtCore.Qt.NoBrush)
+
+ self.contourPolygon = GraphicsItem.GraphicsItem(contour)
+ self.contourPolygon.setAcceptHoverEvents(False)
+
+ def makePolygonMarkers(self):
+ """Create marker for polygon contour"""
+ palette = self._display_palette()
+
+ self.polygonMarkers = list()
+
+ for x, y in zip(*self.raw_coordinates):
+
+ marker = gic.GraphicsCollection()
+ marker.pen.setColor(palette['raw_marker_pen'])
+ marker.pen.setWidthF(1.35)
+ # no pen thickness change when zoomed
+ marker.pen.setCosmetic(True)
+ marker.brush.setColor(palette['raw_marker_fill'])
+ marker.brush.setStyle(QtCore.Qt.SolidPattern)
+ # circle size doesn't do anything here
# this is indirectly deactivated because we don't want to change
# marker size during zoom
# the sizing is thus handled in graphicsview adjustMarkerSize
@@ -166,21 +302,23 @@ def makePolygonMarkers(self):
self.polygonMarkers.append(markerItem)
- def makeChord(self):
- line = gic.GraphicsCollection()
- color = QtGui.QColor(52, 235, 122, 255)
- line.pen.setColor(color)
- line.pen.setWidthF(2.5)
- # no pen thickness change when zoomed
- line.pen.setCosmetic(True)
- # setting CustomDashLine not needed as it will be set
- # implicitely by Qt when CustomDashLine is applied
- # put it just for completeness
- line.pen.setStyle(QtCore.Qt.CustomDashLine)
- stroke = 10
- dot = 1
- space = 5
- line.pen.setDashPattern([stroke, space, dot, space])
+ def makeChord(self):
+ palette = self._display_palette()
+ line = gic.GraphicsCollection()
+ color = palette['chord_pen']
+ line.pen.setColor(color)
+ line.pen.setWidthF(2.1)
+ # no pen thickness change when zoomed
+ line.pen.setCosmetic(True)
+ line.pen.setCapStyle(QtCore.Qt.RoundCap)
+ # setting CustomDashLine not needed as it will be set
+ # implicitely by Qt when CustomDashLine is applied
+ # put it just for completeness
+ line.pen.setStyle(QtCore.Qt.CustomDashLine)
+ stroke = 14
+ dot = 3
+ space = 7
+ line.pen.setDashPattern([stroke, space, dot, space])
index_min = np.argmin(self.raw_coordinates[0])
index_max = np.argmax(self.raw_coordinates[0])
x1 = self.raw_coordinates[0][index_min]
@@ -189,119 +327,301 @@ def makeChord(self):
y2 = self.raw_coordinates[1][index_max]
line.Line(x1, y1, x2, y2)
- self.chord = GraphicsItem.GraphicsItem(line)
- self.chord.setZValue(99)
+ self.chord = GraphicsItem.GraphicsItem(line)
+ self.chord.setZValue(30)
self.chord.setAcceptHoverEvents(False)
- def makeContourSpline(self):
- """Add splined and refined airfoil points as GraphicsItem to
- the scene
- """
- self.pencolor = QtGui.QColor(80, 80, 220, 255)
- self.penwidth = 4.0
-
- # instantiate a graphics item
- splinecontour = gic.GraphicsCollection()
+ def makeContourSpline(self):
+ """Add splined and refined airfoil points as GraphicsItem to
+ the scene
+ """
+ palette = self._display_palette()
+ self.pencolor = palette['spline_pen']
+ self.penwidth = 2.7
+
+ # instantiate a graphics item
+ splinecontour = gic.GraphicsCollection()
# make it polygon type and populate its points
- points = [QtCore.QPointF(x, y) for x, y in zip(*self.spline_data[0])]
+ points = [QtCore.QPointF(x, y) for x, y in zip(*self.spline_data.coordinates)]
splinecontour.Polygon(QtGui.QPolygonF(points), self.name)
# set its properties
splinecontour.pen.setColor(self.pencolor)
- splinecontour.pen.setWidthF(self.penwidth)
- # no pen thickness change when zoomed
- splinecontour.pen.setCosmetic(True)
-
- # remove items from iterated uses of spline/refine and trailing edge
- if hasattr(self, 'contourSpline') and \
- self.contourSpline in self.mainwindow.scene.items():
- self.mainwindow.scene.removeItem(self.contourSpline)
- self.contourSpline = GraphicsItem.GraphicsItem(splinecontour)
- self.mainwindow.scene.addItem(self.contourSpline)
+ splinecontour.pen.setWidthF(self.penwidth)
+ # no pen thickness change when zoomed
+ splinecontour.pen.setCosmetic(True)
+ splinecontour.brush.setColor(palette['spline_fill'])
+
+ # remove items from iterated uses of spline/refine and trailing edge
+ if hasattr(self, 'contourSpline') and \
+ self.contourSpline in self.mw.scene.items():
+ self.mw.scene.removeItem(self.contourSpline)
+ self.contourSpline = GraphicsItem.GraphicsItem(splinecontour)
+ self.contourSpline.setAcceptHoverEvents(False)
+ self.contourSpline.setZValue(40)
+ self._apply_spline_fill_style()
+ self.mw.scene.addItem(self.contourSpline)
# remove items from iterated uses of spline/refine and trailing edge
if hasattr(self, 'splineMarkersGroup') and \
- self.splineMarkersGroup in self.mainwindow.scene.items():
- self.mainwindow.scene.removeItem(self.splineMarkersGroup)
+ self.splineMarkersGroup in self.mw.scene.items():
+ self.mw.scene.removeItem(self.splineMarkersGroup)
self.makeSplineMarkers()
- self.splineMarkersGroup = self.mainwindow.scene. \
- createItemGroup(self.splineMarkers)
-
- self.mainwindow.airfoil.contourSpline.brush. \
- setStyle(QtCore.Qt.SolidPattern)
- color = QtGui.QColor()
- color.setNamedColor('#7c8696')
- self.contourSpline.brush.setColor(color)
- self.polygonMarkersGroup.setZValue(100)
+ self.splineMarkersGroup = self.mw.scene. \
+ createItemGroup(self.splineMarkers)
+ self.splineMarkersGroup.setZValue(140)
+
+ self.polygonMarkersGroup.setZValue(120)
# switch off raw contour and toogle corresponding checkbox
if self.polygonMarkersGroup.isVisible():
- self.mainwindow.centralwidget.airfoil_points_checkbox.click()
+ self.mw.mainArea.airfoil_points_checkbox.click()
if self.contourPolygon.isVisible():
- self.mainwindow.centralwidget.airfoil_raw_contour_checkbox.click()
+ self.mw.mainArea.airfoil_raw_contour_checkbox.click()
# Activate checkboxes for contour points and chord in viewing options.
checkboxes = [
- self.mainwindow.centralwidget.airfoil_spline_points_checkbox,
- self.mainwindow.centralwidget.airfoil_spline_contour_checkbox,
+ self.mw.mainArea.airfoil_spline_points_checkbox,
+ self.mw.mainArea.airfoil_spline_contour_checkbox,
]
for checkbox in checkboxes:
checkbox.setChecked(True)
checkbox.setEnabled(True)
- self.mainwindow.view.adjustMarkerSize()
-
- def makeSplineMarkers(self):
- """Create marker for polygon contour"""
-
- self.splineMarkers = list()
+ self.mw.view.adjustMarkerSize()
- for x, y in zip(*self.spline_data[0]):
+ def makeSplineMarkers(self):
+ """Create marker for polygon contour"""
+ palette = self._display_palette()
+
+ self.splineMarkers = list()
- # put airfoil contour points as graphicsitem
- splinemarker = gic.GraphicsCollection()
- splinemarker.pen.setColor(QtGui.QColor(60, 60, 80, 255))
- splinemarker.pen.setWidthF(1.6)
- # no pen thickness change when zoomed
- splinemarker.pen.setCosmetic(True)
- splinemarker.brush.setColor(QtGui.QColor(203, 250, 72, 255))
+ for x, y in zip(*self.spline_data.coordinates):
- splinemarker.Circle(x, y, 0.004)
+ # put airfoil contour points as graphicsitem
+ splinemarker = gic.GraphicsCollection()
+ splinemarker.pen.setColor(palette['spline_marker_pen'])
+ splinemarker.pen.setWidthF(1.35)
+ # no pen thickness change when zoomed
+ splinemarker.pen.setCosmetic(True)
+ splinemarker.brush.setColor(palette['spline_marker_fill'])
+ splinemarker.brush.setStyle(QtCore.Qt.SolidPattern)
+
+ splinemarker.Circle(x, y, 0.004)
splineMarkerItem = GraphicsItem.GraphicsItem(splinemarker)
self.splineMarkers.append(splineMarkerItem)
- def drawCamber(self, camber):
-
- self.pencolor = QtGui.QColor(220, 80, 80, 255)
- self.penwidth = 3.5
-
- # instantiate a graphics item
- camberline = gic.GraphicsCollection()
- # make it polygon type and populate its points
- points = [QtCore.QPointF(x, y) for x, y in zip(*camber)]
- camberline.Polyline(QtGui.QPolygonF(points))
+ def _removeSceneItem(self, item):
+ if item is not None and item.scene() is not None:
+ item.scene().removeItem(item)
+
+ def drawCamber(self, camber):
+ if isinstance(camber, CamberData):
+ self.camber_data = camber
+ coordinates = camber.polyline_coordinates(start_at_le_tangency=True)
+ else:
+ coordinates = camber
+
+ palette = self._display_palette()
+
+ self.pencolor = palette['camber_pen']
+ self.penwidth = 2.3
+
+ # instantiate a graphics item
+ camberline = gic.GraphicsCollection()
+ # make it polygon type and populate its points
+ points = [QtCore.QPointF(x, y) for x, y in zip(*coordinates)]
+ camberline.Polyline(QtGui.QPolygonF(points))
# set its properties
- camberline.pen.setColor(self.pencolor)
- camberline.pen.setWidthF(self.penwidth)
- # camberline.pen.setStyle(QtCore.Qt.DashLine)
- camberline.pen.setStyle(QtCore.Qt.DotLine)
+ camberline.pen.setColor(self.pencolor)
+ camberline.pen.setWidthF(self.penwidth)
+ camberline.pen.setStyle(QtCore.Qt.CustomDashLine)
+ camberline.pen.setCapStyle(QtCore.Qt.RoundCap)
+ camberline.pen.setDashPattern([1.2, 6.8])
# no pen thickness change when zoomed
camberline.pen.setCosmetic(True)
camberline.brush.setColor(self.brushcolor)
# add the spline polygon without filling
- camberline.brush.setStyle(QtCore.Qt.NoBrush)
-
- # remove items from iterated uses of spline/refine and trailing edge
- if hasattr(self, 'camberline') and \
- self.camberline in self.mainwindow.scene.items():
- self.mainwindow.scene.removeItem(self.camberline)
- self.camberline = GraphicsItem.GraphicsItem(camberline)
- self.camberline.setAcceptHoverEvents(False)
- self.camberline.setZValue(99)
- self.mainwindow.scene.addItem(self.camberline)
- self.mainwindow.centralwidget.airfoil_camber_line_checkbox.setChecked(True)
- self.mainwindow.centralwidget.airfoil_camber_line_checkbox.setEnabled(True)
+ camberline.brush.setStyle(QtCore.Qt.NoBrush)
+
+ # remove items from iterated uses of spline/refine and trailing edge
+ self._removeSceneItem(self.camberline)
+ self.camberline = GraphicsItem.GraphicsItem(camberline)
+ self.camberline.setAcceptHoverEvents(False)
+ self.camberline.setZValue(35)
+ self.mw.scene.addItem(self.camberline)
+ self.mw.mainArea.airfoil_camber_line_checkbox.setChecked(True)
+ self.mw.mainArea.airfoil_camber_line_checkbox.setEnabled(True)
+
+ def drawCamberCircles(self, camber_data):
+ if not isinstance(camber_data, CamberData):
+ return
+
+ palette = self._display_palette()
+ self.camber_data = camber_data
+
+ self._removeSceneItem(self.camber_circles)
+ self.camberCircleMarkers = []
+
+ circles = []
+ center_x, center_y = camber_data.display_coordinates()
+ radii = camber_data.display_radius()
+
+ for x, y, radius in zip(center_x, center_y, radii):
+ if radius <= 0.0:
+ continue
+
+ circle = gic.GraphicsCollection()
+ circle.pen.setColor(palette['camber_circle_pen'])
+ circle.pen.setWidthF(1.1)
+ circle.pen.setCosmetic(True)
+ circle.brush.setColor(palette['camber_circle_fill'])
+ circle.brush.setStyle(QtCore.Qt.NoBrush)
+ circle.Circle(float(x), float(y), float(radius))
+
+ circle_item = GraphicsItem.GraphicsItem(circle)
+ circle_item.setAcceptHoverEvents(False)
+ circles.append(circle_item)
+
+ center_marker = gic.GraphicsCollection()
+ center_marker.pen.setColor(palette['camber_circle_pen'])
+ center_marker.pen.setWidthF(0.8)
+ center_marker.pen.setCosmetic(True)
+ center_marker.brush.setColor(palette['camber_circle_pen'])
+ center_marker.brush.setStyle(QtCore.Qt.SolidPattern)
+ center_marker.Circle(float(x), float(y), 0.0016)
+
+ center_marker_item = GraphicsItem.GraphicsItem(center_marker)
+ center_marker_item.setAcceptHoverEvents(False)
+ circles.append(center_marker_item)
+ self.camberCircleMarkers.append(center_marker_item)
+
+ if not circles:
+ self.camber_circles = None
+ return
+
+ self.camber_circles = self.mw.scene.createItemGroup(circles)
+ self.camber_circles.setZValue(34)
+ self.mw.mainArea.airfoil_camber_circles_checkbox.setChecked(True)
+ self.mw.mainArea.airfoil_camber_circles_checkbox.setEnabled(True)
+ if hasattr(self.mw, 'view') and self.mw.view is not None:
+ self.mw.view.adjustMarkerSize()
+
+ def _camberMaximumMarkerIndex(self, camber_data, quantity):
+ if camber_data.point_count == 0:
+ return None, None
+
+ if quantity == 'thickness':
+ values = 2.0 * np.asarray(camber_data.radius, dtype=float)
+ minimum_value = 1.0e-9
+ elif quantity == 'camber':
+ values = np.asarray(camber_data.coordinates[1], dtype=float)
+ minimum_value = 1.0e-6
+ else:
+ raise ValueError(f'Unsupported camber quantity: {quantity}')
+
+ finite = np.isfinite(values)
+ if not np.any(finite):
+ return None, None
+
+ masked_values = np.where(finite, values, -np.inf)
+ index = int(np.argmax(masked_values))
+ value = float(masked_values[index])
+ if not np.isfinite(value) or value <= minimum_value:
+ return None, None
+ return index, value
+
+ def _makeMaximumMarker(self, x_value, y_value, pen_color, fill_color):
+ marker = gic.GraphicsCollection()
+ marker.pen.setColor(pen_color)
+ marker.pen.setWidthF(1.55)
+ marker.pen.setCosmetic(True)
+ marker.brush.setColor(fill_color)
+ marker.brush.setStyle(QtCore.Qt.SolidPattern)
+ marker.Circle(float(x_value), float(y_value), 0.0044)
+
+ marker_item = GraphicsItem.GraphicsItem(marker)
+ marker_item.setAcceptHoverEvents(False)
+ marker_item.setZValue(146)
+ return marker_item
+
+ def _setMarkerToggleState(self, name, visible):
+ toggle = getattr(getattr(self.mw, 'mainArea', None), name, None)
+ if toggle is None:
+ return
+ toggle.setChecked(visible)
+ toggle.setEnabled(visible)
+
+ def drawCamberMaximumMarkers(self, camber_data):
+ palette = self._display_palette()
+
+ self._removeSceneItem(self.max_thickness_marker)
+ self._removeSceneItem(self.max_camber_marker)
+ self.max_thickness_marker = None
+ self.maxThicknessMarkers = []
+ self.max_thickness_marker_coordinates = None
+ self.max_camber_marker = None
+ self.maxCamberMarkers = []
+ self.max_camber_marker_coordinates = None
+
+ if not isinstance(camber_data, CamberData):
+ self._setMarkerToggleState('airfoil_max_thickness_checkbox', False)
+ self._setMarkerToggleState('airfoil_max_camber_checkbox', False)
+ return
+
+ self.camber_data = camber_data
+ x_coordinates = np.asarray(camber_data.coordinates[0], dtype=float)
+ y_coordinates = np.asarray(camber_data.coordinates[1], dtype=float)
+
+ thickness_index, _ = self._camberMaximumMarkerIndex(
+ camber_data,
+ quantity='thickness',
+ )
+ if thickness_index is not None:
+ x_value = x_coordinates[thickness_index]
+ y_value = y_coordinates[thickness_index]
+ self.max_thickness_marker = self._makeMaximumMarker(
+ x_value,
+ y_value,
+ palette['max_thickness_pen'],
+ palette['max_thickness_fill'],
+ )
+ self.mw.scene.addItem(self.max_thickness_marker)
+ self.maxThicknessMarkers = [self.max_thickness_marker]
+ self.max_thickness_marker_coordinates = (
+ np.array((x_value,), dtype=float),
+ np.array((y_value,), dtype=float),
+ )
+ self._setMarkerToggleState('airfoil_max_thickness_checkbox', True)
+ else:
+ self._setMarkerToggleState('airfoil_max_thickness_checkbox', False)
+
+ camber_index, _ = self._camberMaximumMarkerIndex(
+ camber_data,
+ quantity='camber',
+ )
+ if camber_index is not None:
+ x_value = x_coordinates[camber_index]
+ y_value = y_coordinates[camber_index]
+ self.max_camber_marker = self._makeMaximumMarker(
+ x_value,
+ y_value,
+ palette['max_camber_pen'],
+ palette['max_camber_fill'],
+ )
+ self.mw.scene.addItem(self.max_camber_marker)
+ self.maxCamberMarkers = [self.max_camber_marker]
+ self.max_camber_marker_coordinates = (
+ np.array((x_value,), dtype=float),
+ np.array((y_value,), dtype=float),
+ )
+ self._setMarkerToggleState('airfoil_max_camber_checkbox', True)
+ else:
+ self._setMarkerToggleState('airfoil_max_camber_checkbox', False)
+
+ if hasattr(self.mw, 'view') and self.mw.view is not None:
+ self.mw.view.adjustMarkerSize()
def setPenColor(self, r, g, b, a):
self.pencolor = QtGui.QColor(r, g, b, a)
diff --git a/src/BatchMode.py b/src/BatchMode.py
index a6c86ac..bf0aed0 100644
--- a/src/BatchMode.py
+++ b/src/BatchMode.py
@@ -2,11 +2,9 @@
import json
import Airfoil
-import SplineRefine
-import TrailingEdge
import Meshing
-import Connect
-from Settings import DATAPATH
+import MeshBuilders
+import ToolboxServices
import logging
logger = logging.getLogger(__name__)
@@ -18,6 +16,7 @@ def __init__(self, app, batch_controlfile, __version__):
self.app = app
self.app.mainwindow = self
self.load_batch_control(batch_controlfile)
+ self.workflow = ToolboxServices.WorkflowService(self)
stars = 50
message_stars = stars*'*'
@@ -29,7 +28,6 @@ def __init__(self, app, batch_controlfile, __version__):
logger.info(message)
print(message_stars + '\n')
-
def load_batch_control(self, batch_controlfile):
with open(batch_controlfile, 'r') as f:
self.batch_control = json.load(f)
@@ -58,99 +56,100 @@ def run_batch(self):
print('\n')
for i, airfoil in enumerate(airfoils):
-
- message = f'Starting batch meshing for airfoil {airfoil}'
+ message = f'Starting batch processing for airfoil {airfoil}'
print(message)
logger.info(message)
# load airfoil
basename = os.path.splitext(airfoil)[0]
- self.airfoil = Airfoil.Airfoil(basename)
- self.airfoil.readContour(os.path.join(airfoil_path, airfoil), '#')
-
- # spline and refine
- refinement = self.batch_control['Airfoil contour refinement']
- refine = SplineRefine.SplineRefine()
- refine.doSplineRefine(tolerance=refinement['Refinement tolerance'],
- points=refinement['Number of points on spline'],
- ref_te=refinement['Refine trailing edge old'],
- ref_te_n=refinement['Refine trailing edge new'],
- ref_te_ratio=refinement['Refine trailing edge ratio'])
-
- # trailing edge
- if trailing_edges[i] == 'yes':
-
- self.app.mainwindow.airfoil.has_TE = True
-
- te = self.batch_control['Airfoil trailing edge']
- trailing = TrailingEdge.TrailingEdge()
-
- trailing.trailingEdge(blend=te['Upper side blending length'] / 100.0,
- ex=te['Upper blending polynomial exponent'],
- thickness=te['Trailing edge thickness relative to chord'],
- side='upper')
-
- trailing.trailingEdge(blend=te['Lower side blending length'] / 100.0,
- ex=te['Lower blending polynomial exponent'],
- thickness=te['Trailing edge thickness relative to chord'],
- side='lower')
-
- # make mesh
- wind_tunnel = Meshing.Windtunnel()
- contour = self.app.mainwindow.airfoil.spline_data[0]
-
- # mesh around airfoil
- acm = self.batch_control['Airfoil contour mesh']
- wind_tunnel.AirfoilMesh(name='block_airfoil',
- contour=contour,
- divisions=acm['Divisions normal to airfoil'],
- ratio=acm['Cell growth rate'],
- thickness=acm['1st cell layer thickness'])
-
- # mesh at trailing edge
- tem = self.batch_control['Airfoil trailing edge mesh']
- wind_tunnel.TrailingEdgeMesh(name='block_TE',
- te_divisions=tem['Divisions at trailing edge'],
- thickness=tem['1st cell layer thickness'],
- divisions=tem['Divisions downstream'],
- ratio=tem['Cell growth rate'])
-
- # mesh tunnel airfoil
- tam = self.batch_control['Windtunnel mesh airfoil']
- wind_tunnel.TunnelMesh(name='block_tunnel',
- tunnel_height=tam['Windtunnel height'],
- divisions_height=tam['Divisions of tunnel height'],
- ratio_height=tam['Cell thickness ratio'],
- dist=tam['Distribution biasing'],
- smoothing_algorithm=tam['Smoothing algorithm'],
- smoothing_iterations=tam['Smoothing iterations'],
- smoothing_tolerance=tam['Smoothing tolerance'])
-
- # mesh tunnel wake
- twm = self.batch_control['Windtunnel mesh wake']
- wind_tunnel.TunnelMeshWake(name='block_tunnel_wake',
- tunnel_wake=twm['Windtunnel wake'],
- divisions=twm['Divisions in the wake'],
- ratio=twm['Cell thickness ratio'],
- spread=twm['Equalize vertical wake line at'] / 100.0)
-
- # connect mesh blocks
- connect = Connect.Connect(None)
- vertices, connectivity, _ = connect.connectAllBlocks(wind_tunnel.blocks)
-
- # add mesh to Wind-tunnel instance
- wind_tunnel.mesh = vertices, connectivity
-
- # generate cell to edge connectivity from mesh
- wind_tunnel.makeLCE()
-
- # generate cell to edge connectivity from mesh
- wind_tunnel.makeLCE()
-
- # generate boundaries from mesh connectivity
- wind_tunnel.makeBoundaries()
-
- message = f'Finished batch meshing for airfoil {airfoil}'
+ self.airfoil = Airfoil.Airfoil.from_file(
+ os.path.join(airfoil_path, airfoil),
+ comment='#',
+ mainwindow=self,
+ )
+ if self.airfoil is None:
+ message = f'Failed to load airfoil {airfoil}'
+ print(message)
+ logger.error(message)
+ continue
+
+ try:
+ # spline and refine
+ refinement = self.batch_control['Airfoil contour refinement']
+ refinement_settings = ToolboxServices.SplineRefineSettings(
+ tolerance=refinement['Refinement tolerance'],
+ points=refinement['Number of points on spline'],
+ ref_te=refinement['Refine trailing edge old'],
+ ref_te_n=refinement['Refine trailing edge new'],
+ ref_te_ratio=refinement['Refine trailing edge ratio'],
+ )
+ self.workflow.spline_and_refine(refinement_settings)
+
+ # trailing edge
+ if trailing_edges[i] == 'yes':
+ te = self.batch_control['Airfoil trailing edge']
+ trailing_edge_settings = ToolboxServices.TrailingEdgeSettings(
+ upper_blend=te['Upper side blending length'] / 100.0,
+ lower_blend=te['Lower side blending length'] / 100.0,
+ upper_exponent=te['Upper blending polynomial exponent'],
+ lower_exponent=te['Lower blending polynomial exponent'],
+ thickness=te['Trailing edge thickness relative to chord'],
+ )
+ self.workflow.add_trailing_edge(trailing_edge_settings)
+
+ # mesh settings
+ acm = self.batch_control['Airfoil contour mesh']
+ tem = self.batch_control['Airfoil trailing edge mesh']
+ tam = self.batch_control['Windtunnel mesh airfoil']
+ twm = self.batch_control['Windtunnel mesh wake']
+ mesh_settings = Meshing.WindtunnelMeshSettings(
+ airfoil=MeshBuilders.AirfoilBlockSettings(
+ name='block_airfoil',
+ divisions=acm['Divisions normal to airfoil'],
+ growth=acm['Cell growth rate'],
+ thickness=acm['1st cell layer thickness'],
+ ),
+ trailing_edge=MeshBuilders.TrailingEdgeBlockSettings(
+ name='block_TE',
+ trailing_edge_divisions=tem['Divisions at trailing edge'],
+ thickness=tem['1st cell layer thickness'],
+ divisions=tem['Divisions downstream'],
+ growth=tem['Cell growth rate'],
+ ),
+ tunnel=MeshBuilders.TunnelBlockSettings(
+ name='block_tunnel',
+ tunnel_height=tam['Windtunnel height'],
+ divisions_height=tam['Divisions of tunnel height'],
+ height_growth=tam['Cell thickness ratio'],
+ distribution=tam['Distribution biasing'],
+ smoothing_algorithm=tam['Smoothing algorithm'],
+ smoothing_iterations=tam['Smoothing iterations'],
+ smoothing_tolerance=tam['Smoothing tolerance'],
+ outer_boundary_slide=1.0,
+ elliptic_relaxation=1.0,
+ protected_guide_relaxation=0.25,
+ protected_guide_layers=8,
+ protected_guide_decay=0.20,
+ protected_guide_smoothing=15,
+ ),
+ wake=MeshBuilders.WakeBlockSettings(
+ name='block_tunnel_wake',
+ tunnel_wake=twm['Windtunnel wake'],
+ divisions=twm['Divisions in the wake'],
+ growth=twm['Cell thickness ratio'],
+ spread=twm['Equalize vertical wake line at'] / 100.0,
+ ),
+ )
+ wind_tunnel = self.workflow.generate_mesh(mesh_settings)
+ if wind_tunnel is None:
+ raise ValueError('Mesh generation was canceled.')
+ except ValueError as error:
+ message = f'Failed to process airfoil {airfoil}: {error}'
+ print(message)
+ logger.error(message)
+ continue
+
+ message = f'Finished batch mesh generation for airfoil {airfoil}'
print(message)
logger.info(message)
@@ -159,17 +158,16 @@ def run_batch(self):
print(message)
logger.info(message)
- for output_format in output_formats:
- extension = {'FLMA': '.flma',
- 'SU2': '.su2',
- 'GMSH': '.msh',
- 'VTK': '.vtk',
- 'CGNS': '.cgns',
- 'ABAQUS': '.inp',
- 'OBJ': '.obj'}
- mesh_name = os.path.join(mesh_path, basename + extension[output_format])
- getattr(Meshing.BlockMesh, 'write'+output_format)(wind_tunnel, name=mesh_name)
+ export_settings = ToolboxServices.MeshExportSettings(
+ formats=output_formats,
+ )
+ exported_files = self.workflow.export_mesh(
+ wind_tunnel,
+ os.path.join(mesh_path, basename),
+ export_settings,
+ )
+ for mesh_name in exported_files:
message = f'Finished mesh export for airfoil {airfoil} to {mesh_name}'
print(message)
logger.info(message)
diff --git a/src/BlockMesh.py b/src/BlockMesh.py
new file mode 100644
index 0000000..6b9291d
--- /dev/null
+++ b/src/BlockMesh.py
@@ -0,0 +1,435 @@
+from __future__ import annotations
+
+from typing import Iterable, Sequence
+
+import numpy as np
+from scipy import interpolate
+
+from MathUtils import VectorUtils
+from QuadRedistribute import redistribute_polyline
+from Shape import Point2D
+
+
+class BlockMesh:
+ """Legacy structured block primitive used by the current mesh builders.
+
+ The canonical stored point type is ``tuple[float, float]``.
+ NumPy arrays are only used locally inside numerical routines.
+ """
+
+ def __init__(self, name='block'):
+ self.name = name
+ self.ULines: list[list[Point2D]] = []
+
+ @staticmethod
+ def as_point(point: Sequence[float]) -> Point2D:
+ if len(point) != 2:
+ raise ValueError('Expected a 2D point.')
+ return float(point[0]), float(point[1])
+
+ @classmethod
+ def as_line(cls, line: Iterable[Sequence[float]]) -> list[Point2D]:
+ return [cls.as_point(point) for point in line]
+
+ @classmethod
+ def as_ulines(cls, ulines: Iterable[Iterable[Sequence[float]]]) -> list[list[Point2D]]:
+ return [cls.as_line(uline) for uline in ulines]
+
+ def addLine(self, line):
+ self.ULines.append(self.as_line(line))
+
+ def getULines(self):
+ return self.ULines
+
+ def setUlines(self, ulines):
+ self.ULines = self.as_ulines(ulines)
+
+ def getVLines(self):
+ vlines = []
+ u_divisions, _ = self.getDivUV()
+
+ for index in range(u_divisions + 1):
+ vline = []
+ for uline in self.getULines():
+ vline.append(uline[index])
+ vlines.append(vline)
+
+ return vlines
+
+ def getLine(self, number=0, direction='u'):
+ direction = direction.lower()
+ if direction == 'u':
+ return self.getULines()[number]
+ if direction == 'v':
+ return self.getVLines()[number]
+ raise ValueError(f'Unknown line direction: {direction}')
+
+ def getDivUV(self):
+ u = len(self.getULines()[0]) - 1
+ v = len(self.getULines()) - 1
+ return u, v
+
+ def getNodeCoo(self, node):
+ i_index, j_index = node
+ point = self.getULines()[j_index][i_index]
+ return np.asarray(point, dtype=float)
+
+ def setNodeCoo(self, node, new_pos):
+ i_index, j_index = node
+ self.getULines()[j_index][i_index] = self.as_point(new_pos)
+
+ @staticmethod
+ def makeLine(p1, p2, divisions=1, ratio=1.0):
+ start = np.asarray(p1, dtype=float)
+ end = np.asarray(p2, dtype=float)
+ vector = end - start
+ distance = np.linalg.norm(vector)
+ spacing = BlockMesh.spacing(
+ divisions=divisions,
+ ratio=ratio,
+ length=distance,
+ )
+
+ line = [(float(start[0]), float(start[1]))]
+ direction = VectorUtils.unit_vector(vector)
+ for index in range(1, len(spacing)):
+ point = start + spacing[index] * direction
+ line.append((float(point[0]), float(point[1])))
+ line[-1] = (float(end[0]), float(end[1]))
+ return line
+
+ def extrudeLine_cell_thickness(self, line, cell_thickness=0.04,
+ growth=1.05, divisions=1, direction=3):
+ x_values, y_values = list(zip(*line))
+ x_values = np.asarray(x_values, dtype=float)
+ y_values = np.asarray(y_values, dtype=float)
+ spacing, _ = self.spacing_cell_thickness(
+ cell_thickness=cell_thickness,
+ growth=growth,
+ divisions=divisions,
+ )
+
+ if direction == 3:
+ normals = self.curveNormals(x_values, y_values)
+ for index in range(1, len(spacing)):
+ x_offset = x_values + spacing[index] * normals[:, 0]
+ y_offset = y_values + spacing[index] * normals[:, 1]
+ self.addLine(list(zip(x_offset.tolist(), y_offset.tolist())))
+ elif direction == 4:
+ normals = self.curveNormals(x_values, y_values)
+ normal_x = normals[:, 0].mean()
+ normal_y = normals[:, 1].mean()
+ for index in range(1, len(spacing)):
+ x_offset = x_values + spacing[index] * normal_x
+ y_offset = y_values + spacing[index] * normal_y
+ self.addLine(list(zip(x_offset.tolist(), y_offset.tolist())))
+ else:
+ raise ValueError(f'Unsupported extrusion direction: {direction}')
+
+ def extrudeLine(self, line, direction=0, length=0.1, divisions=1,
+ ratio=1.00001, constant=False):
+ x_values, y_values = list(zip(*line))
+ x_values = np.asarray(x_values, dtype=float)
+ y_values = np.asarray(y_values, dtype=float)
+
+ if constant and direction == 0:
+ x_values.fill(length)
+ self.addLine(list(zip(x_values.tolist(), y_values.tolist())))
+ return
+ if constant and direction == 1:
+ y_values.fill(length)
+ self.addLine(list(zip(x_values.tolist(), y_values.tolist())))
+ return
+
+ if direction not in (3, 4):
+ raise ValueError(f'Unsupported extrusion direction: {direction}')
+
+ spacing = self.spacing(divisions=divisions, ratio=ratio, length=length)
+ normals = self.curveNormals(x_values, y_values)
+ if direction == 3:
+ for index in range(1, len(spacing)):
+ x_offset = x_values + spacing[index] * normals[:, 0]
+ y_offset = y_values + spacing[index] * normals[:, 1]
+ self.addLine(list(zip(x_offset.tolist(), y_offset.tolist())))
+ else:
+ normal_x = normals[:, 0].mean()
+ normal_y = normals[:, 1].mean()
+ for index in range(1, len(spacing)):
+ x_offset = x_values + spacing[index] * normal_x
+ y_offset = y_values + spacing[index] * normal_y
+ self.addLine(list(zip(x_offset.tolist(), y_offset.tolist())))
+
+ def distribute(self, direction='u', number=0, type='constant'):
+ if direction == 'u':
+ line = np.asarray(self.getULines()[number], dtype=float)
+ elif direction == 'v':
+ line = np.asarray(self.getVLines()[number], dtype=float)
+ else:
+ raise ValueError(f'Unknown line direction: {direction}')
+
+ tck, _ = interpolate.splprep(line.T, s=0, k=1)
+
+ if type == 'constant':
+ parameters = np.linspace(0.0, 1.0, num=len(line))
+ elif type == 'transition':
+ first = np.asarray(self.getULines()[0], dtype=float)
+ last = np.asarray(self.getULines()[-1], dtype=float)
+ _, first_parameters = interpolate.splprep(first.T, s=0, k=1)
+ _, last_parameters = interpolate.splprep(last.T, s=0, k=1)
+ if number < 0:
+ number = len(self.getVLines())
+ blend = float(number) / float(len(self.getVLines()))
+ parameters = (1.0 - blend) * first_parameters + \
+ blend * last_parameters
+ else:
+ raise ValueError(f'Unknown distribution type: {type}')
+
+ redistributed = interpolate.splev(parameters, tck, der=0)
+ redistributed = list(
+ zip(redistributed[0].tolist(), redistributed[1].tolist())
+ )
+
+ if direction == 'u':
+ self.getULines()[number] = redistributed
+ else:
+ for index, uline in enumerate(self.getULines()):
+ uline[number] = redistributed[index]
+
+ def redistributeLine(self, direction='u', number=0, monitor=None, metric=None,
+ point_count=None):
+ if direction == 'u':
+ line = np.asarray(self.getULines()[number], dtype=float)
+ elif direction == 'v':
+ line = np.asarray(self.getVLines()[number], dtype=float)
+ else:
+ raise ValueError(f'Unknown line direction: {direction}')
+
+ if point_count is None:
+ point_count = len(line)
+ if int(point_count) != len(line):
+ raise ValueError(
+ 'Structured block redistribution must preserve the line point count.'
+ )
+
+ redistributed = redistribute_polyline(
+ line,
+ point_count=point_count,
+ monitor=monitor,
+ metric=metric,
+ )
+ redistributed_points = list(
+ zip(redistributed[:, 0].tolist(), redistributed[:, 1].tolist())
+ )
+
+ if direction == 'u':
+ self.getULines()[number] = redistributed_points
+ else:
+ for index, uline in enumerate(self.getULines()):
+ uline[number] = redistributed_points[index]
+
+ return redistributed_points
+
+ @staticmethod
+ def spacing_cell_thickness(cell_thickness=0.04, growth=1.1, divisions=10):
+ spacing = [cell_thickness]
+ for _ in range(divisions - 1):
+ spacing.append(spacing[0] + spacing[-1] * growth)
+ spacing.insert(0, 0.0)
+ return spacing, np.sum(spacing)
+
+ @staticmethod
+ def spacing(divisions=10, ratio=1.0, length=1.0):
+ if divisions == 1:
+ return np.array([0.0, 1.0])
+
+ growth = ratio ** (1.0 / (float(divisions) - 1.0))
+ if growth == 1.0:
+ growth = 1.0 + 1.0e-10
+
+ spacing = [1.0]
+ for index in range(1, divisions + 1):
+ spacing.append(growth ** index)
+
+ spacing = np.asarray(spacing, dtype=float)
+ spacing -= spacing[0]
+ spacing /= spacing[-1]
+ spacing *= length
+ return spacing
+
+ def mapLines(self, line_1, line_2):
+ pass
+
+ @staticmethod
+ def curveNormals(x, y, closed=False):
+ start_offset = 0
+ end_offset = 0
+ normals = []
+
+ for index, _ in enumerate(x):
+ if closed:
+ if index == len(x) - 1:
+ end_offset = -index - 1
+ else:
+ if index == 0:
+ start_offset = 1
+ if index == len(x) - 1:
+ end_offset = -1
+
+ tangent = np.array(
+ [
+ x[index + 1 + end_offset] - x[index - 1 + start_offset],
+ y[index + 1 + end_offset] - y[index - 1 + start_offset],
+ ],
+ dtype=float,
+ )
+ unit = VectorUtils.unit_vector(tangent)
+ normals.append([unit[1], -unit[0]])
+ start_offset = 0
+ end_offset = 0
+
+ return np.asarray(normals, dtype=float)
+
+ def transfinite(self, boundary=None, ij=None):
+ if boundary:
+ lower, upper, left, right = boundary
+ elif ij:
+ lower = self.getULines()[ij[2]][ij[0]:ij[1] + 1]
+ upper = self.getULines()[ij[3]][ij[0]:ij[1] + 1]
+ left = self.getVLines()[ij[0]][ij[2]:ij[3] + 1]
+ right = self.getVLines()[ij[1]][ij[2]:ij[3] + 1]
+ else:
+ lower = self.getULines()[0]
+ upper = self.getULines()[-1]
+ left = self.getVLines()[0]
+ right = self.getVLines()[-1]
+
+ lower = np.asarray(lower, dtype=float)
+ upper = np.asarray(upper, dtype=float)
+ left = np.asarray(left, dtype=float)
+ right = np.asarray(right, dtype=float)
+
+ _, u_lower = interpolate.splprep(lower.T, s=0, k=1)
+ _, u_left = interpolate.splprep(left.T, s=0, k=1)
+
+ nodes = np.zeros((len(left) * len(lower), 2), dtype=float)
+ c1 = lower[0]
+ c2 = upper[0]
+ c3 = lower[-1]
+ c4 = upper[-1]
+
+ for i_index, xi in enumerate(u_lower):
+ for j_index, eta in enumerate(u_left):
+ node = i_index * len(u_left) + j_index
+ point = (
+ (1.0 - xi) * left[j_index] + xi * right[j_index] +
+ (1.0 - eta) * lower[i_index] + eta * upper[i_index] -
+ (
+ (1.0 - xi) * (1.0 - eta) * c1 +
+ (1.0 - xi) * eta * c2 +
+ xi * (1.0 - eta) * c3 +
+ xi * eta * c4
+ )
+ )
+ nodes[node, 0] = point[0]
+ nodes[node, 1] = point[1]
+
+ vlines = []
+ vline = []
+ for index, node in enumerate(nodes, start=1):
+ vline.append(node)
+ if index % len(left) == 0:
+ vlines.append(vline)
+ vline = []
+
+ vlines.reverse()
+
+ if ij:
+ ulines = self.makeUfromV(vlines)
+ for offset, k_index in enumerate(range(ij[2], ij[3] + 1)):
+ self.ULines[k_index][ij[0]:ij[1] + 1] = ulines[offset]
+ else:
+ self.ULines = self.makeUfromV(vlines)
+
+ @staticmethod
+ def makeUfromV(vlines):
+ ulines = []
+ for index in range(len(vlines[0])):
+ uline = []
+ for vline in vlines:
+ x_value, y_value = vline[index][0], vline[index][1]
+ uline.append((float(x_value), float(y_value)))
+ ulines.append(uline[::-1])
+ return ulines
+
+
+class Smooth:
+ """Legacy block smoother used by the simple smoothing adapter."""
+
+ def __init__(self, block):
+ self.block = block
+
+ def getNeighbours(self, node):
+ i_index, j_index = node
+ return {
+ 1: (i_index - 1, j_index - 1),
+ 2: (i_index, j_index - 1),
+ 3: (i_index + 1, j_index - 1),
+ 4: (i_index + 1, j_index),
+ 5: (i_index + 1, j_index + 1),
+ 6: (i_index, j_index + 1),
+ 7: (i_index - 1, j_index + 1),
+ 8: (i_index - 1, j_index),
+ }
+
+ def smooth(self, nodes, iterations=1, algorithm='laplace'):
+ for _ in range(iterations):
+ for node in nodes:
+ neighbours = self.getNeighbours(node)
+
+ if algorithm == 'laplace':
+ new_position = (
+ self.block.getNodeCoo(neighbours[2]) +
+ self.block.getNodeCoo(neighbours[4]) +
+ self.block.getNodeCoo(neighbours[6]) +
+ self.block.getNodeCoo(neighbours[8])
+ ) / 4.0
+ elif algorithm == 'parallelogram':
+ new_position = (
+ self.block.getNodeCoo(neighbours[1]) +
+ self.block.getNodeCoo(neighbours[3]) +
+ self.block.getNodeCoo(neighbours[5]) +
+ self.block.getNodeCoo(neighbours[7])
+ ) / 4.0 - (
+ self.block.getNodeCoo(neighbours[2]) +
+ self.block.getNodeCoo(neighbours[4]) +
+ self.block.getNodeCoo(neighbours[6]) +
+ self.block.getNodeCoo(neighbours[8])
+ ) / 2.0
+ else:
+ continue
+
+ self.block.setNodeCoo(
+ node,
+ (float(new_position[0]), float(new_position[1])),
+ )
+
+ return self.block
+
+ def selectNodes(self, domain='interior', ij=None):
+ u_divisions, v_divisions = self.block.getDivUV()
+ nodes = []
+
+ if domain == 'interior':
+ i_start, i_end = 1, u_divisions
+ j_start, j_end = 1, v_divisions
+ elif domain == 'ij' and ij is not None:
+ i_start, i_end = ij[0], ij[1]
+ j_start, j_end = ij[2], ij[3]
+ else:
+ raise ValueError(f'Unknown node selection domain: {domain}')
+
+ for i_index in range(i_start, i_end):
+ for j_index in range(j_start, j_end):
+ nodes.append((i_index, j_index))
+
+ return nodes
diff --git a/src/CSTAirfoil.py b/src/CSTAirfoil.py
new file mode 100644
index 0000000..f7ea658
--- /dev/null
+++ b/src/CSTAirfoil.py
@@ -0,0 +1,482 @@
+from __future__ import annotations
+
+from dataclasses import dataclass
+from math import comb
+
+import numpy as np
+
+from ContourData import SplineData
+
+
+METHOD_BSPLINE = 'bspline'
+METHOD_CST_MODIFIED = 'cst_modified'
+
+
+@dataclass(slots=True)
+class CSTSurfaceFit:
+ order: int
+ coefficients: np.ndarray
+ nose_coefficient: float
+ trailing_edge_offset: float
+ binomial_coefficients: np.ndarray
+ exponent_s: np.ndarray
+ exponent_tail: np.ndarray
+
+
+def _as_coordinate_array(coordinates):
+ x, y = coordinates
+ return np.column_stack((
+ np.asarray(x, dtype=float),
+ np.asarray(y, dtype=float),
+ ))
+
+
+def _prepare_surface(points):
+ surface = np.asarray(points, dtype=float)
+ if surface.ndim != 2 or surface.shape[1] != 2 or len(surface) < 3:
+ raise ValueError('At least three surface points are required for CST fitting.')
+
+ order = np.argsort(surface[:, 0], kind='mergesort')
+ surface = surface[order]
+
+ rounded_x = np.round(surface[:, 0], decimals=12)
+ unique_x, inverse = np.unique(rounded_x, return_inverse=True)
+
+ if len(unique_x) == len(surface):
+ return surface
+
+ counts = np.bincount(inverse).astype(float)
+ x_mean = np.bincount(inverse, weights=surface[:, 0]) / counts
+ y_mean = np.bincount(inverse, weights=surface[:, 1]) / counts
+ return np.column_stack((x_mean, y_mean))
+
+
+def split_airfoil_surfaces(coordinates):
+ contour = _as_coordinate_array(coordinates)
+ if len(contour) < 5:
+ raise ValueError('At least five contour points are required for CST fitting.')
+
+ le_index = int(np.argmin(contour[:, 0]))
+ upper = _prepare_surface(contour[:le_index + 1][::-1])
+ lower = _prepare_surface(contour[le_index:])
+ return upper, lower
+
+
+def _standard_basis_value(s, order, index):
+ s = np.asarray(s, dtype=float)
+ exponent_s = 2 * index + 1
+ exponent_tail = order - index + 1
+ return comb(order, index) * (
+ np.power(s, exponent_s) *
+ np.power(1.0 - s * s, exponent_tail)
+ )
+
+
+def _standard_basis_first_derivative(s, order, index):
+ s = np.asarray(s, dtype=float)
+ exponent_s = 2 * index + 1
+ exponent_tail = order - index + 1
+ tail = 1.0 - s * s
+ coefficient = float(comb(order, index))
+
+ first = exponent_s * np.power(s, exponent_s - 1) * np.power(tail, exponent_tail)
+ second = (
+ -2.0 * exponent_tail *
+ np.power(s, exponent_s + 1) *
+ np.power(tail, exponent_tail - 1)
+ )
+ return coefficient * (first + second)
+
+
+def _standard_basis_second_derivative(s, order, index):
+ s = np.asarray(s, dtype=float)
+ exponent_s = 2 * index + 1
+ exponent_tail = order - index + 1
+ tail = 1.0 - s * s
+ coefficient = float(comb(order, index))
+
+ derivative = np.zeros_like(s, dtype=float)
+ if exponent_s >= 2:
+ derivative += (
+ exponent_s * (exponent_s - 1) *
+ np.power(s, exponent_s - 2) *
+ np.power(tail, exponent_tail)
+ )
+
+ derivative += (
+ -2.0 * exponent_tail * (2 * exponent_s + 1) *
+ np.power(s, exponent_s) *
+ np.power(tail, exponent_tail - 1)
+ )
+
+ if exponent_tail >= 2:
+ derivative += (
+ 4.0 * exponent_tail * (exponent_tail - 1) *
+ np.power(s, exponent_s + 2) *
+ np.power(tail, exponent_tail - 2)
+ )
+
+ return coefficient * derivative
+
+
+def _nose_basis_value(s):
+ s = np.asarray(s, dtype=float)
+ return s * s * (1.0 - s * s)
+
+
+def _nose_basis_first_derivative(s):
+ s = np.asarray(s, dtype=float)
+ return 2.0 * s - 4.0 * np.power(s, 3)
+
+
+def _nose_basis_second_derivative(s):
+ s = np.asarray(s, dtype=float)
+ return 2.0 - 12.0 * s * s
+
+
+def _fit_surface(points, order):
+ surface = _prepare_surface(points)
+ x = np.clip(surface[:, 0], 0.0, 1.0)
+ y = surface[:, 1]
+
+ if len(surface) < 3:
+ raise ValueError('At least three points are required per surface for CST fitting.')
+
+ capped_order = min(max(1, int(order)), max(1, len(surface) - 2))
+ s = np.sqrt(x)
+ trailing_edge_offset = float(y[-1])
+ rhs = y - trailing_edge_offset * x
+
+ basis_columns = [
+ _standard_basis_value(s, capped_order, index)
+ for index in range(capped_order + 1)
+ ]
+ basis_columns.append(_nose_basis_value(s))
+ design_matrix = np.column_stack(basis_columns)
+
+ coefficients, _residuals, _rank, _singular_values = np.linalg.lstsq(
+ design_matrix,
+ rhs,
+ rcond=None,
+ )
+
+ exponents = np.arange(capped_order + 1, dtype=int)
+
+ return CSTSurfaceFit(
+ order=capped_order,
+ coefficients=np.asarray(coefficients[:-1], dtype=float),
+ nose_coefficient=float(coefficients[-1]),
+ trailing_edge_offset=trailing_edge_offset,
+ binomial_coefficients=np.array(
+ [comb(capped_order, index) for index in exponents],
+ dtype=float,
+ ),
+ exponent_s=2 * exponents + 1,
+ exponent_tail=capped_order - exponents + 1,
+ )
+
+
+def _surface_basis_matrices(surface, s):
+ s = np.asarray(s, dtype=float)
+ s_row = np.atleast_1d(s)[None, :]
+ tail_row = 1.0 - s_row * s_row
+
+ binomial = surface.binomial_coefficients[:, None]
+ exponent_s = surface.exponent_s[:, None]
+ exponent_tail = surface.exponent_tail[:, None]
+
+ basis = (
+ binomial *
+ np.power(s_row, exponent_s) *
+ np.power(tail_row, exponent_tail)
+ )
+ first_derivative = (
+ binomial * (
+ exponent_s *
+ np.power(s_row, exponent_s - 1) *
+ np.power(tail_row, exponent_tail) -
+ 2.0 * exponent_tail *
+ np.power(s_row, exponent_s + 1) *
+ np.power(tail_row, exponent_tail - 1)
+ )
+ )
+
+ second_derivative = np.zeros_like(basis, dtype=float)
+ exponent_s_flat = surface.exponent_s
+ exponent_tail_flat = surface.exponent_tail
+
+ mask_s = exponent_s_flat >= 2
+ if np.any(mask_s):
+ second_derivative[mask_s] += (
+ surface.binomial_coefficients[mask_s, None] *
+ exponent_s_flat[mask_s, None] *
+ (exponent_s_flat[mask_s, None] - 1) *
+ np.power(s_row, exponent_s_flat[mask_s, None] - 2) *
+ np.power(tail_row, exponent_tail_flat[mask_s, None])
+ )
+
+ second_derivative += (
+ -2.0 *
+ surface.binomial_coefficients[:, None] *
+ exponent_tail *
+ (2 * exponent_s + 1) *
+ np.power(s_row, exponent_s) *
+ np.power(tail_row, exponent_tail - 1)
+ )
+
+ mask_tail = exponent_tail_flat >= 2
+ if np.any(mask_tail):
+ second_derivative[mask_tail] += (
+ 4.0 *
+ surface.binomial_coefficients[mask_tail, None] *
+ exponent_tail_flat[mask_tail, None] *
+ (exponent_tail_flat[mask_tail, None] - 1) *
+ np.power(s_row, exponent_s_flat[mask_tail, None] + 2) *
+ np.power(tail_row, exponent_tail_flat[mask_tail, None] - 2)
+ )
+
+ return basis, first_derivative, second_derivative
+
+
+class ModifiedCSTAirfoilEvaluator:
+ DENSE_ARCLENGTH_SAMPLES = 1024
+
+ def __init__(self, upper_surface, lower_surface):
+ self.upper_surface = upper_surface
+ self.lower_surface = lower_surface
+ self.leading_edge_parameter = self._leading_edge_parameter_from_arclength()
+
+ @classmethod
+ def fit(cls, coordinates, order=8):
+ upper_points, lower_points = split_airfoil_surfaces(coordinates)
+ return cls(
+ upper_surface=_fit_surface(upper_points, order=order),
+ lower_surface=_fit_surface(lower_points, order=order),
+ )
+
+ @property
+ def order(self):
+ return max(self.upper_surface.order, self.lower_surface.order)
+
+ def surface_parameters(self, stations, side):
+ stations = np.asarray(stations, dtype=float)
+ root = np.sqrt(np.clip(stations, 0.0, 1.0))
+ if side == 'upper':
+ return self.leading_edge_parameter * (1.0 - root)
+ if side == 'lower':
+ return self.leading_edge_parameter + (
+ (1.0 - self.leading_edge_parameter) * root
+ )
+ raise ValueError(f'Unsupported CST surface side: {side}')
+
+ def fit_parameters_from_contour(self, coordinates):
+ contour = _as_coordinate_array(coordinates)
+ parameters = np.empty(len(contour), dtype=float)
+ le_index = int(np.argmin(contour[:, 0]))
+
+ upper_root = np.sqrt(np.clip(contour[:le_index + 1, 0], 0.0, 1.0))
+ parameters[:le_index + 1] = self.leading_edge_parameter * (1.0 - upper_root)
+
+ lower_root = np.sqrt(np.clip(contour[le_index:, 0], 0.0, 1.0))
+ parameters[le_index:] = self.leading_edge_parameter + (
+ (1.0 - self.leading_edge_parameter) * lower_root
+ )
+
+ return parameters
+
+ def initial_sample_parameters(self, point_count):
+ return np.linspace(0.0, 1.0, int(point_count))
+
+ def evaluate(self, parameters, der=0):
+ values = np.asarray(parameters, dtype=float)
+ scalar_input = values.ndim == 0
+ values = np.atleast_1d(values)
+
+ x_result = np.empty_like(values, dtype=float)
+ y_result = np.empty_like(values, dtype=float)
+
+ upper_mask = values <= self.leading_edge_parameter
+ lower_mask = ~upper_mask
+
+ if np.any(upper_mask):
+ x_upper, y_upper = self._evaluate_side(
+ values[upper_mask],
+ surface=self.upper_surface,
+ upper_side=True,
+ der=der,
+ )
+ x_result[upper_mask] = x_upper
+ y_result[upper_mask] = y_upper
+
+ if np.any(lower_mask):
+ x_lower, y_lower = self._evaluate_side(
+ values[lower_mask],
+ surface=self.lower_surface,
+ upper_side=False,
+ der=der,
+ )
+ x_result[lower_mask] = x_lower
+ y_result[lower_mask] = y_lower
+
+ if scalar_input:
+ return float(x_result[0]), float(y_result[0])
+ return x_result, y_result
+
+ def _leading_edge_parameter_from_arclength(self):
+ upper_length = self._surface_arclength(self.upper_surface)
+ lower_length = self._surface_arclength(self.lower_surface)
+ total_length = upper_length + lower_length
+ if total_length <= 0.0:
+ return 0.5
+ return upper_length / total_length
+
+ def _surface_arclength(self, surface):
+ s = np.linspace(0.0, 1.0, self.DENSE_ARCLENGTH_SAMPLES)
+ dx_ds = 2.0 * s
+ dy_ds = self._surface_y_derivative(surface, s)
+ integrand = np.sqrt(dx_ds * dx_ds + dy_ds * dy_ds)
+ return float(np.trapz(integrand, s))
+
+ def _evaluate_side(self, parameters, surface, upper_side, der):
+ if upper_side:
+ delta = max(self.leading_edge_parameter, 1.0e-12)
+ s = 1.0 - parameters / delta
+ ds_dt = -1.0 / delta
+ else:
+ delta = max(1.0 - self.leading_edge_parameter, 1.0e-12)
+ s = (parameters - self.leading_edge_parameter) / delta
+ ds_dt = 1.0 / delta
+
+ s = np.clip(s, 0.0, 1.0)
+ x = s * s
+ y = self._surface_y(surface, s)
+
+ if der == 0:
+ return x, y
+ if der == 1:
+ return 2.0 * s * ds_dt, self._surface_y_derivative(surface, s) * ds_dt
+ if der == 2:
+ scale = ds_dt * ds_dt
+ return np.full_like(s, 2.0 * scale), self._surface_y_second_derivative(surface, s) * scale
+ raise ValueError(f'Unsupported derivative order: {der}')
+
+ def _surface_y(self, surface, s):
+ values = np.asarray(s, dtype=float)
+ basis, _first_derivative, _second_derivative = _surface_basis_matrices(
+ surface,
+ values,
+ )
+ values = np.dot(surface.coefficients, basis)
+ values += surface.nose_coefficient * _nose_basis_value(s)
+ values += surface.trailing_edge_offset * (np.asarray(s, dtype=float) ** 2)
+ return values
+
+ def _surface_y_derivative(self, surface, s):
+ values = np.asarray(s, dtype=float)
+ _basis, first_derivative, _second_derivative = _surface_basis_matrices(
+ surface,
+ values,
+ )
+ derivatives = np.dot(surface.coefficients, first_derivative)
+ derivatives += surface.nose_coefficient * _nose_basis_first_derivative(s)
+ derivatives += 2.0 * surface.trailing_edge_offset * values
+ return derivatives
+
+ def _surface_y_second_derivative(self, surface, s):
+ values = np.asarray(s, dtype=float)
+ _basis, _first_derivative, second_derivative = _surface_basis_matrices(
+ surface,
+ values,
+ )
+ derivatives = np.dot(surface.coefficients, second_derivative)
+ derivatives += surface.nose_coefficient * _nose_basis_second_derivative(s)
+ derivatives += 2.0 * surface.trailing_edge_offset
+ return derivatives
+
+
+def build_modified_cst_spline_data(
+ coordinates,
+ point_count=None,
+ order=8,
+ sample_parameters=None,
+):
+ evaluator = ModifiedCSTAirfoilEvaluator.fit(coordinates, order=order)
+ fit_parameters = evaluator.fit_parameters_from_contour(coordinates)
+
+ if sample_parameters is None:
+ sample_count = point_count if point_count is not None else len(fit_parameters)
+ sample_parameters = evaluator.initial_sample_parameters(sample_count)
+
+ sample_parameters = np.asarray(sample_parameters, dtype=float)
+ coordinates_sampled = evaluator.evaluate(sample_parameters, der=0)
+ first_derivative = evaluator.evaluate(sample_parameters, der=1)
+ second_derivative = evaluator.evaluate(sample_parameters, der=2)
+
+ return SplineData(
+ coordinates=coordinates_sampled,
+ fit_parameters=fit_parameters,
+ sample_parameters=sample_parameters,
+ first_derivative=first_derivative,
+ second_derivative=second_derivative,
+ spline=None,
+ method=METHOD_CST_MODIFIED,
+ evaluator=evaluator,
+ metadata={
+ 'order': evaluator.order,
+ 'label': 'CST',
+ },
+ leading_edge_parameter=float(evaluator.leading_edge_parameter),
+ )
+
+
+def cst_parameters_from_spline_data(spline_data):
+ if spline_data is None:
+ raise ValueError('No prepared contour available.')
+ if getattr(spline_data, 'method', None) != METHOD_CST_MODIFIED:
+ raise ValueError('Prepared contour is not using CST.')
+
+ evaluator = getattr(spline_data, 'evaluator', None)
+ if evaluator is None:
+ raise ValueError('No CST evaluator available for this contour.')
+
+ def _surface_parameters(name, surface):
+ return {
+ 'name': name,
+ 'order': int(surface.order),
+ 'coefficients': np.asarray(surface.coefficients, dtype=float).tolist(),
+ 'nose_coefficient': float(surface.nose_coefficient),
+ 'trailing_edge_offset': float(surface.trailing_edge_offset),
+ }
+
+ return {
+ 'method': 'CST',
+ 'order': int(evaluator.order),
+ 'leading_edge_parameter': float(evaluator.leading_edge_parameter),
+ 'upper_surface': _surface_parameters('upper', evaluator.upper_surface),
+ 'lower_surface': _surface_parameters('lower', evaluator.lower_surface),
+ }
+
+
+def format_cst_parameters_text(parameter_data):
+ def _surface_lines(title, surface):
+ lines = [
+ f'{title}',
+ f' Order: {surface["order"]}',
+ f' Nose coefficient: {surface["nose_coefficient"]:.16g}',
+ f' Trailing-edge offset: {surface["trailing_edge_offset"]:.16g}',
+ ' Coefficients:',
+ ]
+ for index, value in enumerate(surface['coefficients']):
+ lines.append(f' A{index}: {value:.16g}')
+ return lines
+
+ lines = [
+ 'CST parameters',
+ f'Order: {parameter_data["order"]}',
+ f'Leading-edge parameter: {parameter_data["leading_edge_parameter"]:.16g}',
+ '',
+ ]
+ lines.extend(_surface_lines('Upper surface', parameter_data['upper_surface']))
+ lines.append('')
+ lines.extend(_surface_lines('Lower surface', parameter_data['lower_surface']))
+ return '\n'.join(lines)
diff --git a/src/Camber.py b/src/Camber.py
new file mode 100644
index 0000000..2e855a2
--- /dev/null
+++ b/src/Camber.py
@@ -0,0 +1,167 @@
+from __future__ import annotations
+
+import numpy as np
+
+import CamberMedialAxis
+from ContourData import CamberData
+from MathUtils import VectorUtils
+
+import logging
+logger = logging.getLogger(__name__)
+
+
+CAMBER_METHOD_INSCRIBED_CIRCLES = 'inscribed_circles'
+
+
+class CamberBuilder:
+ DEFAULT_CALCULATION_POINTS = 240
+ DEFAULT_DISPLAY_CIRCLES = 17
+
+ def build(
+ self,
+ spline_data,
+ rc,
+ xc,
+ yc,
+ xle,
+ yle,
+ calculation_points=None,
+ display_circles=None,
+ ):
+ self.spline_data = spline_data
+ self.t_le = float(spline_data.leading_edge_parameter_value())
+
+ point_count = calculation_points or max(
+ self.DEFAULT_CALCULATION_POINTS,
+ spline_data.point_count,
+ )
+ point_count = max(40, int(point_count))
+ display_count = max(3, int(display_circles or self.DEFAULT_DISPLAY_CIRCLES))
+
+ naive_midline = self._build_naive_midline(
+ point_count=point_count,
+ display_count=display_count,
+ )
+ inscribed = self._build_inscribed(
+ naive_midline,
+ rc=rc,
+ xc=xc,
+ yc=yc,
+ xle=xle,
+ yle=yle,
+ display_count=display_count,
+ )
+ self._log_metrics(inscribed, label='inscribed circles')
+ fallback_count = int(np.count_nonzero(inscribed.fallback_used))
+ if fallback_count:
+ logger.info(
+ 'Camber inscribed-circle fallback used for %d of %d stations.',
+ fallback_count,
+ inscribed.point_count,
+ )
+ return inscribed
+
+ def _upper_parameter(self, station):
+ return self.spline_data.upper_surface_parameters(station)
+
+ def _lower_parameter(self, station):
+ return self.spline_data.lower_surface_parameters(station)
+
+ def _legacy_parameters(self, point_count):
+ stations = np.linspace(0.0, 1.0, point_count)
+ upper_parameters = self._upper_parameter(stations)
+ lower_parameters = self._lower_parameter(stations)
+ return stations, upper_parameters, lower_parameters
+
+ def _build_naive_midline(self, point_count, display_count):
+ """Simple upper/lower-surface-midpoint construction. Not exposed as
+ a user-selectable camber method -- it exists purely to seed the
+ medial-axis tracer's trailing-edge blend fallback (see
+ CamberMedialAxis._append_legacy_tail), for the stretch near a sharp
+ trailing edge where a true inscribed circle isn't well-conditioned.
+ """
+ stations, upper_parameters, lower_parameters = self._legacy_parameters(point_count)
+ del stations
+
+ upper = np.array(
+ self.spline_data.evaluate(upper_parameters, der=0),
+ dtype=float,
+ ).T
+ lower = np.array(
+ self.spline_data.evaluate(lower_parameters, der=0),
+ dtype=float,
+ ).T
+
+ centers = 0.5 * (upper + lower)
+ radius = 0.5 * VectorUtils.vector_length(upper - lower)
+ display_indices = self._display_indices(centers, display_count)
+
+ valid = np.ones(point_count, dtype=bool)
+ fallback_used = np.zeros(point_count, dtype=bool)
+
+ return CamberData(
+ method='legacy_midpoint',
+ coordinates=(centers[:, 0], centers[:, 1]),
+ radius=radius,
+ upper_contact=(upper[:, 0], upper[:, 1]),
+ lower_contact=(lower[:, 0], lower[:, 1]),
+ upper_parameters=upper_parameters,
+ lower_parameters=lower_parameters,
+ display_indices=display_indices,
+ valid=valid,
+ fallback_used=fallback_used,
+ )
+
+ def _build_inscribed(self, naive_midline, rc, xc, yc, xle, yle, display_count):
+ return CamberMedialAxis.trace(
+ spline_data=self.spline_data,
+ t_le=self.t_le,
+ legacy=naive_midline,
+ rc=rc, xc=xc, yc=yc, xle=xle, yle=yle,
+ point_count=naive_midline.point_count,
+ display_count=display_count,
+ )
+
+ def _display_indices(self, centers, display_count):
+ count = len(centers)
+ if count == 0:
+ return np.array([], dtype=int)
+ if count <= display_count:
+ return np.arange(count, dtype=int)
+
+ deltas = np.diff(centers, axis=0)
+ arclength = np.zeros(count, dtype=float)
+ arclength[1:] = np.cumsum(VectorUtils.vector_length(deltas))
+ total_length = float(arclength[-1])
+ if total_length <= 0.0:
+ return np.linspace(0, count - 1, display_count).astype(int)
+
+ targets = np.linspace(0.0, total_length, display_count)
+ indices = []
+ for target in targets:
+ indices.append(int(np.argmin(np.abs(arclength - target))))
+ return np.array(sorted(set(indices)), dtype=int)
+
+ def _log_metrics(self, camber_data, label):
+ if camber_data.point_count == 0:
+ return
+
+ y_coordinates = np.asarray(camber_data.coordinates[1], dtype=float)
+ x_coordinates = np.asarray(camber_data.coordinates[0], dtype=float)
+ thickness = 2.0 * np.asarray(camber_data.radius, dtype=float)
+
+ max_camber_id = int(np.argmax(y_coordinates))
+ max_thickness_id = int(np.argmax(thickness))
+
+ logger.info(
+ 'Maximum thickness (%s): %5.2f%% @ %5.2f%% chord',
+ label,
+ thickness[max_thickness_id] * 100.0,
+ x_coordinates[max_thickness_id] * 100.0,
+ )
+ logger.info(
+ 'Maximum camber (%s): %5.2f%% @ %5.2f%% chord',
+ label,
+ y_coordinates[max_camber_id] * 100.0,
+ x_coordinates[max_camber_id] * 100.0,
+ )
diff --git a/src/CamberMedialAxis.py b/src/CamberMedialAxis.py
new file mode 100644
index 0000000..1f49d40
--- /dev/null
+++ b/src/CamberMedialAxis.py
@@ -0,0 +1,422 @@
+from __future__ import annotations
+
+import numpy as np
+from scipy import optimize
+
+from ContourData import CamberData
+
+MIN_RADIUS = 1.0e-8
+MIN_NORMAL_DETERMINANT = 1.0e-10
+NOSE_REFINE_MARGIN = 0.03
+
+MAX_NEWTON_ITERATIONS = 15
+MAX_STEP_SHRINKS = 30
+MIN_STEP = 1.0e-9
+MIN_PROGRESS_FRACTION = 0.2
+
+INITIAL_STEP = 0.003
+MAX_STEP = 0.02
+MAX_MARCH_STEPS = 4000
+RADIUS_FLOOR = 1.0e-4 # relative to unit chord
+
+BLEND_STATION_COUNT = 10
+UPPER_COMPLETE_TOLERANCE = 1.0e-3
+LOWER_COMPLETE_TOLERANCE = 1.0e-3
+
+
+def _unit(vector):
+ norm = np.linalg.norm(vector)
+ return vector / norm if norm > 0 else vector
+
+
+def _point_and_derivative(spline_data, t):
+ x, y = spline_data.evaluate(t, der=0)
+ dx, dy = spline_data.evaluate(t, der=1)
+ return np.array((float(x), float(y))), np.array((float(dx), float(dy)))
+
+
+def inward_normal(derivative, point, reference):
+ tangent = _unit(np.asarray(derivative, dtype=float))
+ left = np.array((-tangent[1], tangent[0]))
+ right = -left
+ if np.dot(reference - point, left) >= np.dot(reference - point, right):
+ return left
+ return right
+
+
+def intersect_normals(upper_point, upper_normal, lower_point, lower_normal):
+ matrix = np.column_stack((upper_normal, -lower_normal))
+ determinant = float(np.linalg.det(matrix))
+ if abs(determinant) < MIN_NORMAL_DETERMINANT:
+ return None
+ try:
+ distance_upper, distance_lower = np.linalg.solve(matrix, lower_point - upper_point)
+ except np.linalg.LinAlgError:
+ return None
+ if distance_upper <= MIN_RADIUS or distance_lower <= MIN_RADIUS:
+ return None
+ center = upper_point + distance_upper * upper_normal
+ return center, float(distance_upper), float(distance_lower)
+
+
+def _radius_of_curvature(spline_data, t):
+ dx, dy = spline_data.evaluate(t, der=1)
+ x2, y2 = spline_data.evaluate(t, der=2)
+ numerator = dx * y2 - dy * x2
+ if abs(numerator) < 1.0e-14:
+ return np.inf
+ speed_sq = dx * dx + dy * dy
+ return (speed_sq ** 1.5) / abs(numerator)
+
+
+def refine_nose_start(spline_data, t_le, xc, yc, rc, margin=NOSE_REFINE_MARGIN):
+ """Continuously refine the curvature extremum near t_le, independent of
+ ContourAnalysis.getLeRadius's discrete sampled result. Falls back to the
+ discrete (xc, yc, rc) whenever refinement isn't possible -- never raises.
+ """
+ lower = max(0.0, t_le - margin)
+ upper = min(1.0, t_le + margin)
+ fallback = (float(t_le), np.array((xc, yc), dtype=float), float(rc))
+ if upper <= lower:
+ return fallback
+ result = optimize.minimize_scalar(
+ lambda t: _radius_of_curvature(spline_data, t),
+ bounds=(lower, upper), method='bounded',
+ options={'xatol': 1.0e-10, 'maxiter': 200},
+ )
+ if not result.success or not np.isfinite(result.fun) or result.fun <= MIN_RADIUS:
+ return fallback
+ t_star = float(result.x)
+ point, derivative = _point_and_derivative(spline_data, t_star)
+ radius = float(result.fun)
+ reference = (
+ np.array((xc, yc), dtype=float)
+ if np.isfinite(xc) and np.isfinite(yc) else point
+ )
+ normal = inward_normal(derivative, point, reference)
+ center = point + radius * normal
+ return t_star, center, radius
+
+
+def _circle_from_parameters(spline_data, t_upper, t_lower, reference):
+ upper_point, upper_derivative = _point_and_derivative(spline_data, t_upper)
+ lower_point, lower_derivative = _point_and_derivative(spline_data, t_lower)
+ upper_normal = inward_normal(upper_derivative, upper_point, reference)
+ lower_normal = inward_normal(lower_derivative, lower_point, reference)
+ result = intersect_normals(upper_point, upper_normal, lower_point, lower_normal)
+ if result is None:
+ return None
+ center, radius_upper, radius_lower = result
+ return {
+ 'center': center, 'radius_upper': radius_upper, 'radius_lower': radius_lower,
+ 'upper_point': upper_point, 'lower_point': lower_point,
+ }
+
+
+def _radius_mismatch(spline_data, t_upper, t_lower, reference):
+ solution = _circle_from_parameters(spline_data, t_upper, t_lower, reference)
+ if solution is None:
+ return None
+ return solution['radius_upper'] - solution['radius_lower'], solution
+
+
+def _continuation_step(spline_data, t_upper_prev, t_lower_prev, reference,
+ t_upper_guess, t_lower_guess, step, min_step=MIN_STEP,
+ max_newton=MAX_NEWTON_ITERATIONS, max_shrinks=MAX_STEP_SHRINKS,
+ min_progress_fraction=MIN_PROGRESS_FRACTION):
+ """Advance one arclength step via true arc-length (sphere) continuation:
+ solve radius_mismatch(t_upper, t_lower) == 0 and
+ (t_upper - t_upper_prev)**2 + (t_lower - t_lower_prev)**2 == step**2
+ with 2D Newton, seeded from (t_upper_guess, t_lower_guess). The sphere
+ constraint (rather than a fixed tangent-plane constraint) needs no a
+ priori direction estimate, which matters right at the nose where the
+ two contact points start out coincident and any linearized-tangent
+ estimate is dominated by floating-point noise.
+
+ Returns (t_upper, t_lower, solution, step_used), or None if even the
+ smallest step fails to converge to a solution that has actually made
+ forward progress (rejects the trivial t_upper == t_lower branch, which
+ is always also a root of radius_mismatch).
+ """
+ guess_direction = np.array((t_upper_guess - t_upper_prev, t_lower_guess - t_lower_prev))
+ guess_norm = np.linalg.norm(guess_direction)
+ guess_direction = (
+ guess_direction / guess_norm if guess_norm > 1.0e-14
+ else np.array((-1.0, 1.0)) / np.sqrt(2.0)
+ )
+
+ current_step = step
+ for _ in range(max_shrinks):
+ t_upper = min(max(t_upper_prev + current_step * guess_direction[0], 0.0), 1.0)
+ t_lower = min(max(t_lower_prev + current_step * guess_direction[1], 0.0), 1.0)
+
+ converged = False
+ solution = None
+ for _ in range(max_newton):
+ probe = _radius_mismatch(spline_data, t_upper, t_lower, reference)
+ if probe is None:
+ break
+ mismatch, solution = probe
+ sphere_residual = (
+ (t_upper - t_upper_prev) ** 2 + (t_lower - t_lower_prev) ** 2
+ - current_step ** 2
+ )
+
+ epsilon = max(1.0e-6, 1.0e-3 * current_step)
+ probe_u = _radius_mismatch(spline_data, t_upper + epsilon, t_lower, reference)
+ probe_l = _radius_mismatch(spline_data, t_upper, t_lower + epsilon, reference)
+ if probe_u is None or probe_l is None:
+ break
+ d_mismatch_du = (probe_u[0] - mismatch) / epsilon
+ d_mismatch_dl = (probe_l[0] - mismatch) / epsilon
+ d_sphere_du = 2.0 * (t_upper - t_upper_prev)
+ d_sphere_dl = 2.0 * (t_lower - t_lower_prev)
+
+ jacobian = np.array((
+ (d_mismatch_du, d_mismatch_dl),
+ (d_sphere_du, d_sphere_dl),
+ ))
+ residuals = np.array((-mismatch, -sphere_residual))
+ try:
+ delta = np.linalg.solve(jacobian, residuals)
+ except np.linalg.LinAlgError:
+ break
+
+ delta_norm = np.linalg.norm(delta)
+ if delta_norm > 4.0 * current_step:
+ delta = delta * (4.0 * current_step / delta_norm)
+
+ t_upper_new = t_upper + delta[0]
+ t_lower_new = t_lower + delta[1]
+ if not (0.0 <= t_upper_new <= 1.0) or not (0.0 <= t_lower_new <= 1.0) \
+ or t_upper_new >= t_lower_new:
+ break
+ t_upper, t_lower = t_upper_new, t_lower_new
+
+ if abs(mismatch) < 1.0e-11 * max(solution['radius_upper'], 1.0e-6) \
+ and abs(sphere_residual) < 1.0e-10 * current_step ** 2:
+ converged = True
+ break
+
+ if converged:
+ probe = _radius_mismatch(spline_data, t_upper, t_lower, reference)
+ if probe is not None:
+ mismatch, solution = probe
+ progress = (
+ (t_upper - t_upper_prev) * guess_direction[0]
+ + (t_lower - t_lower_prev) * guess_direction[1]
+ )
+ if abs(mismatch) < 1.0e-8 * max(solution['radius_upper'], 1.0e-6) \
+ and progress > min_progress_fraction * current_step:
+ return t_upper, t_lower, solution, current_step
+
+ current_step *= 0.5
+ if current_step < min_step:
+ break
+ return None
+
+
+def _march(spline_data, t_star, center0, radius0, initial_step=INITIAL_STEP,
+ max_step=MAX_STEP, max_steps=MAX_MARCH_STEPS, radius_floor=RADIUS_FLOOR):
+ """Trace the medial axis outward from the refined nose circle in both
+ directions at once (t_upper decreasing, t_lower increasing). Returns a
+ list of (t_upper, t_lower, solution) triples starting at the nose and
+ ending wherever continuation could no longer make progress -- contour
+ end, Newton failure even at the minimum step, or the radius dropping
+ below `radius_floor` (meaningless this close to a sharp trailing edge).
+ """
+ point0, _ = _point_and_derivative(spline_data, t_star)
+ states = [(t_star, t_star, {
+ 'center': center0, 'radius_upper': radius0, 'radius_lower': radius0,
+ 'upper_point': point0, 'lower_point': point0,
+ })]
+
+ t_upper, t_lower, reference = t_star, t_star, center0
+ step = initial_step
+ guess_upper = t_star - step / np.sqrt(2.0)
+ guess_lower = t_star + step / np.sqrt(2.0)
+ easy_streak = 0
+
+ for _ in range(max_steps):
+ result = _continuation_step(
+ spline_data, t_upper, t_lower, reference, guess_upper, guess_lower, step,
+ )
+ if result is None:
+ break
+ t_upper_new, t_lower_new, solution, used_step = result
+ radius = 0.5 * (solution['radius_upper'] + solution['radius_lower'])
+ if radius < radius_floor:
+ break
+ states.append((t_upper_new, t_lower_new, solution))
+
+ if used_step == step:
+ easy_streak += 1
+ if easy_streak >= 3:
+ step = min(step * 1.5, max_step)
+ easy_streak = 0
+ else:
+ step = used_step
+ easy_streak = 0
+
+ secant = np.array((t_upper_new - t_upper, t_lower_new - t_lower))
+ secant_norm = np.linalg.norm(secant)
+ secant = (
+ secant / secant_norm if secant_norm > 1.0e-14
+ else np.array((-1.0, 1.0)) / np.sqrt(2.0)
+ )
+
+ t_upper, t_lower = t_upper_new, t_lower_new
+ reference = solution['center']
+ guess_upper = t_upper + step * secant[0]
+ guess_lower = t_lower + step * secant[1]
+
+ if t_upper <= 1.0e-9 or t_lower >= 1.0 - 1.0e-9:
+ break
+
+ return states
+
+
+def _states_to_arrays(states):
+ centers = np.array([s['center'] for _, _, s in states])
+ radii = np.array([0.5 * (s['radius_upper'] + s['radius_lower']) for _, _, s in states])
+ upper = np.array([s['upper_point'] for _, _, s in states])
+ lower = np.array([s['lower_point'] for _, _, s in states])
+ upper_params = np.array([t for t, _, _ in states])
+ lower_params = np.array([t for _, t, _ in states])
+ return centers, radii, upper, lower, upper_params, lower_params
+
+
+def _append_legacy_tail(states, legacy, point_count):
+ """If the march didn't reach both contour ends, fill the remainder with
+ the existing naive midpoint construction, fading the offset between the
+ march's last circle and the legacy curve to zero over a short blend
+ region so there's no visible seam."""
+ t_upper_last, t_lower_last, last_solution = states[-1]
+ upper_complete = t_upper_last <= UPPER_COMPLETE_TOLERANCE
+ lower_complete = t_lower_last >= 1.0 - LOWER_COMPLETE_TOLERANCE
+ if upper_complete and lower_complete:
+ return states, None
+
+ legacy_upper = np.asarray(legacy.upper_parameters, dtype=float)
+ legacy_lower = np.asarray(legacy.lower_parameters, dtype=float)
+ distances = np.abs(legacy_upper - t_upper_last) + np.abs(legacy_lower - t_lower_last)
+ handoff_index = int(np.argmin(distances))
+
+ legacy_centers = np.column_stack(legacy.coordinates)
+ legacy_upper_contact = np.column_stack(legacy.upper_contact)
+ legacy_lower_contact = np.column_stack(legacy.lower_contact)
+ legacy_radius = np.asarray(legacy.radius, dtype=float)
+
+ center_offset = last_solution['center'] - legacy_centers[handoff_index]
+ radius_offset = (
+ 0.5 * (last_solution['radius_upper'] + last_solution['radius_lower'])
+ - legacy_radius[handoff_index]
+ )
+
+ tail_indices = np.arange(handoff_index + 1, point_count)
+ fallback_from = len(states)
+ blend_span = max(1, min(BLEND_STATION_COUNT, len(tail_indices)))
+ tail_states = []
+ for offset_index, station_index in enumerate(tail_indices):
+ weight = max(0.0, 1.0 - (offset_index + 1) / blend_span)
+ center = legacy_centers[station_index] + weight * center_offset
+ radius = legacy_radius[station_index] + weight * radius_offset
+ tail_states.append((
+ legacy_upper[station_index], legacy_lower[station_index],
+ {
+ 'center': center, 'radius_upper': radius, 'radius_lower': radius,
+ 'upper_point': legacy_upper_contact[station_index],
+ 'lower_point': legacy_lower_contact[station_index],
+ },
+ ))
+ return states + tail_states, fallback_from
+
+
+def _resample_by_arclength(centers, radii, upper, lower, upper_params, lower_params, point_count):
+ """Resample onto `point_count` arc-length-uniform points by linearly
+ interpolating center/radius/contact-point coordinates (and parameters)
+ directly against cumulative arc length.
+
+ A variant that instead interpolated only the (t_upper, t_lower)
+ parameters and re-derived each output circle exactly via
+ `_circle_from_parameters` was tried and rejected: near max thickness the
+ upper and lower surface normals can become nearly parallel, making that
+ 2x2 solve ill-conditioned, so a tiny interpolated-parameter difference
+ could swing the recomputed center wildly off course -- worse than the
+ small, bounded error plain linear interpolation leaves between raw
+ (already Newton-validated) march points.
+ """
+ deltas = np.diff(centers, axis=0)
+ segment_lengths = np.linalg.norm(deltas, axis=1)
+ arclength = np.concatenate(([0.0], np.cumsum(segment_lengths)))
+ total_length = arclength[-1]
+ if total_length <= 0.0:
+ targets = np.zeros(point_count)
+ else:
+ targets = np.linspace(0.0, total_length, point_count)
+
+ def interp(values):
+ return np.interp(targets, arclength, values)
+
+ return (
+ interp(centers[:, 0]), interp(centers[:, 1]), interp(radii),
+ interp(upper[:, 0]), interp(upper[:, 1]),
+ interp(lower[:, 0]), interp(lower[:, 1]),
+ interp(upper_params), interp(lower_params),
+ )
+
+
+def _display_indices(centers, display_count):
+ count = len(centers)
+ if count == 0:
+ return np.array([], dtype=int)
+ if count <= display_count:
+ return np.arange(count, dtype=int)
+ deltas = np.diff(centers, axis=0)
+ arclength = np.zeros(count, dtype=float)
+ arclength[1:] = np.cumsum(np.linalg.norm(deltas, axis=1))
+ total_length = float(arclength[-1])
+ if total_length <= 0.0:
+ return np.linspace(0, count - 1, display_count).astype(int)
+ targets = np.linspace(0.0, total_length, display_count)
+ indices = [int(np.argmin(np.abs(arclength - target))) for target in targets]
+ return np.array(sorted(set(indices)), dtype=int)
+
+
+def trace(spline_data, t_le, legacy, rc, xc, yc, xle, yle, point_count, display_count):
+ """Build the CAMBER_METHOD_INSCRIBED_CIRCLES camberline: a continuously
+ refined nose circle, an outward arc-length continuation march, and (if
+ needed) a blended handoff to the naive midpoint construction near the
+ trailing edge -- resampled onto `point_count` arc-length-uniform points.
+ Always returns a complete CamberData; degrades gracefully rather than
+ raising when refinement/continuation can't make progress (nose
+ refinement and the legacy tail are both unconditional fallbacks).
+ """
+ t_star, center0, radius0 = refine_nose_start(spline_data, t_le, xc, yc, rc)
+ states = _march(spline_data, t_star, center0, radius0)
+ states, fallback_from = _append_legacy_tail(states, legacy, point_count)
+
+ centers, radii, upper, lower, upper_params, lower_params = _states_to_arrays(states)
+ (
+ x_coords, y_coords, out_radius, upper_x, upper_y, lower_x, lower_y,
+ out_upper_params, out_lower_params,
+ ) = _resample_by_arclength(centers, radii, upper, lower, upper_params, lower_params, point_count)
+
+ out_centers = np.column_stack((x_coords, y_coords))
+ fallback_used = np.zeros(point_count, dtype=bool)
+ if fallback_from is not None:
+ march_arclength_fraction = fallback_from / len(states)
+ fallback_used[int(round(march_arclength_fraction * point_count)):] = True
+
+ return CamberData(
+ method='inscribed_circles',
+ coordinates=(x_coords, y_coords),
+ radius=out_radius,
+ upper_contact=(upper_x, upper_y),
+ lower_contact=(lower_x, lower_y),
+ upper_parameters=out_upper_params,
+ lower_parameters=out_lower_params,
+ display_indices=_display_indices(out_centers, display_count),
+ valid=np.ones(point_count, dtype=bool),
+ fallback_used=fallback_used,
+ )
diff --git a/src/Connect.py b/src/Connect.py
index ae792fd..f3aea21 100644
--- a/src/Connect.py
+++ b/src/Connect.py
@@ -1,30 +1,32 @@
-import os
-import copy
-
-import numpy as np
-from scipy import spatial
-
-from PySide6 import QtCore, QtGui
-
-import GraphicsItemsCollection as gic
-import GraphicsItem
-
-
-class Connect:
- """docstring"""
-
- def __init__(self, progdialog):
-
- # get MainWindow instance (overcomes handling parents)
- self.mainwindow = QtCore.QCoreApplication.instance().mainwindow
-
- self.progdialog = progdialog
-
- def getVertices(self, block):
- """Make a list of point tuples from a BlockMesh object
-
- Args:
- block (BlockMesh): BlockMesh object
+from __future__ import annotations
+
+import os
+
+import numpy as np
+from scipy import spatial
+
+class Connect:
+ """Merge structured mesh blocks into a single vertex/connectivity set."""
+
+ MERGE_RADIUS = 1.0e-6
+
+ def __init__(self, progdialog=None, progress_callback=None):
+ self.progdialog = progdialog
+ self.progress_callback = progress_callback
+ if self.progress_callback is None and progdialog is not None:
+ callback = getattr(progdialog, 'setValue', None)
+ if callable(callback):
+ self.progress_callback = callback
+
+ def _setProgress(self, value):
+ if self.progress_callback is not None:
+ self.progress_callback(value)
+
+ def getVertices(self, block):
+ """Make a list of point tuples from a BlockMesh object
+
+ Args:
+ block (BlockMesh): BlockMesh object
Returns:
list: list of point tuples
@@ -60,16 +62,17 @@ def getMinMaxConnectivityIDs(self, connectivity):
id_max = max(id_max, id)
return id_min, id_max
- def getNearestNeighboursPairs(self, vertices, radius=1.e-6):
- tree = spatial.cKDTree(vertices)
- pairs = tree.query_pairs(radius, p=2., eps=0)
- return pairs
-
- def getNearestNeighboursBiDirectional(d1, d2, radius=1.e-6):
- """Get all indices ofts in d1 which are within distance r to d2"""
- tree_1 = spatial.cKDTree(d1)
- tree_2 = spatial.cKDTree(d2)
- idx1 = tree_2.query_ball_tree(tree_1, radius, p=2., eps=0)
+ def getNearestNeighboursPairs(self, vertices, radius=1.e-6):
+ tree = spatial.cKDTree(vertices)
+ pairs = tree.query_pairs(radius, p=2., eps=0)
+ return pairs
+
+ @staticmethod
+ def getNearestNeighboursBiDirectional(d1, d2, radius=1.e-6):
+ """Get matching point indices between two point sets within ``radius``."""
+ tree_1 = spatial.cKDTree(d1)
+ tree_2 = spatial.cKDTree(d2)
+ idx1 = tree_2.query_ball_tree(tree_1, radius, p=2., eps=0)
idx2 = tree_1.query_ball_tree(tree_2, radius, p=2., eps=0)
matching = [e[0] for e in idx1 if e]
opposite = [e[0] for e in idx2 if e]
@@ -102,107 +105,98 @@ def getNearestNeighbours(self, vertices, neighbours, radius=1.e-6):
return vertex_and_neighbours
- def shiftConnectivity(self, connectivity, shift):
-
- if shift == 0:
- return connectivity
-
- connectivity_shifted = list()
- for cell in connectivity:
- new_cell = [vertex + shift for vertex in cell]
- connectivity_shifted.append(new_cell)
-
- return connectivity_shifted
-
- def connectAllBlocks(self, blocks):
-
- # compile global vertex list and cell connectivity from all blocks
- vertices = list()
- connectivity = list()
-
- for i, block in enumerate(blocks):
-
- # accumulated number of vertices
- # for i = 0 shift is automatically 0
- # so the connectivity of the first block doesn't get shifted
- # thus, this variable must be set before 'vertices += ...'
- shift = len(vertices)
-
- # concatenate vertices of all blocks
- # vertices += [vertex for vertex in self.getVertices(block)]
- vertices += self.getVertices(block)
-
- # shift the block connectivity by accumulated number of vertices
- # from all blocks before this one
- connectivity_block = \
- self.shiftConnectivity(self.getConnectivity(block), shift)
- connectivity += [tuple(cell) for cell in connectivity_block]
-
- if self.progdialog:
- self.progdialog.setValue(80)
-
- # FIXME
- # FIXME for some reason tuples need to be redefined
- # FIXME
- vertices = [(vertex[0], vertex[1]) for vertex in vertices]
-
- # search vertices of all blocks against themselves
- # finds itself AND multiple connections (i.e. vertices from neighbour blocks)
- # uses Scipy kd-tree for quick nearest-neighbor lookup
- # the distance tolerance is specified via the radius variable
- vertex_and_neighbours = self.getNearestNeighbours(vertices,
- vertices,
- radius=1.e-6)
-
- # substitute vertex ids in connectivity at block connections
- connectivity_connected = list()
- for cell in connectivity:
- cell_new = list()
- for node in cell:
- # if there is only one vertex in vertex_and_neighbours,
- # then it is taken as it is
- # if there is more than one vertex,
- # then the minimum vertex index is used
- # so a few vertices remain unused and need to be removed later
- node_new = min(vertex_and_neighbours[node])
- cell_new.append(node_new)
- connectivity_connected.append(cell_new)
-
- # use numpy arrays
- unconnected = np.array(connectivity)
- connected = np.array(connectivity_connected)
-
- # deleted nodes
- deleted_nodes = np.unique(unconnected[np.where(connected != unconnected)])
-
- # delete unused vertices
- vertices_clean = [v for i,v in enumerate(vertices)
- if i not in sorted(deleted_nodes.tolist())]
-
- # find remaining node ids
- remaining_nodes = np.setdiff1d(np.unique(connected), deleted_nodes)
-
- # replace node ids so that a contiguous numbering is established
- # divakar, method 3 (https://stackoverflow.com/a/55950051/2264936)
- mapping = {rn:i for i, rn in enumerate(remaining_nodes)}
- k = np.array(list(mapping.keys()))
- v = np.array(list(mapping.values()))
- mapping_ar = np.zeros(k.max()+1,dtype=v.dtype)
- mapping_ar[k] = v
- connectivity_clean = mapping_ar[connected]
-
- if self.progdialog:
- self.progdialog.setValue(90)
+ def shiftConnectivity(self, connectivity, shift):
+
+ if shift == 0:
+ return connectivity
+
+ return [
+ tuple(vertex + shift for vertex in cell)
+ for cell in connectivity
+ ]
+
+ def _collectBlocks(self, blocks):
+ vertices = []
+ connectivity = []
+
+ for block in blocks:
+ shift = len(vertices)
+ vertices.extend(self.getVertices(block))
+ connectivity.extend(
+ self.shiftConnectivity(self.getConnectivity(block), shift)
+ )
+
+ return vertices, connectivity
+
+ def _mergeConnectivity(self, vertices, connectivity):
+ vertex_and_neighbours = self.getNearestNeighbours(
+ vertices,
+ vertices,
+ radius=self.MERGE_RADIUS,
+ )
+ connectivity_connected = [
+ [min(vertex_and_neighbours[node]) for node in cell]
+ for cell in connectivity
+ ]
+
+ return np.asarray(connectivity), np.asarray(connectivity_connected)
+
+ def _compactConnectivity(self, vertices, unconnected, connected):
+ deleted_nodes = np.unique(unconnected[np.where(connected != unconnected)])
+
+ if deleted_nodes.size:
+ keep_mask = np.ones(len(vertices), dtype=bool)
+ keep_mask[deleted_nodes] = False
+ vertices_clean = [
+ vertex for vertex, keep in zip(vertices, keep_mask) if keep
+ ]
+ else:
+ vertices_clean = list(vertices)
+
+ remaining_nodes = np.setdiff1d(np.unique(connected), deleted_nodes)
+ mapping = {node: index for index, node in enumerate(remaining_nodes)}
+ mapping_keys = np.array(list(mapping.keys()))
+ mapping_values = np.array(list(mapping.values()))
+ mapping_array = np.zeros(mapping_keys.max() + 1, dtype=mapping_values.dtype)
+ mapping_array[mapping_keys] = mapping_values
+ connectivity_clean = mapping_array[connected]
+
+ return vertices_clean, connectivity_clean, deleted_nodes
+
+ def connectAllBlocks(self, blocks):
+
+ if not blocks:
+ return [], np.empty((0, 4), dtype=int)
+
+ # compile global vertex list and cell connectivity from all blocks
+ vertices, connectivity = self._collectBlocks(blocks)
+
+ self._setProgress(80)
+
+ # BlockMesh stores vertices as plain 2D float tuples, so connectivity
+ # merging can work directly on the collected point data.
+ unconnected, connected = self._mergeConnectivity(vertices, connectivity)
+ vertices_clean, connectivity_clean, deleted_nodes = self._compactConnectivity(
+ vertices,
+ unconnected,
+ connected,
+ )
+
+ self._setProgress(90)
# DEBUGGING
# self.write_debug(unconnected, connected, deleted_nodes, vertices, vertices_clean, connectivity_clean)
# self.draw_connectivity(vertices, deleted_nodes)
- return (vertices_clean, connectivity_clean, self.progdialog)
-
- def draw_connectivity(self, vertices, deleted_nodes):
-
- self.connections = list()
+ return vertices_clean, connectivity_clean
+
+ def draw_connectivity(self, vertices, deleted_nodes, scene):
+ from PySide6 import QtGui
+
+ import GraphicsItemsCollection as gic
+ import GraphicsItem
+
+ self.connections = list()
# instantiate a graphics item
marker = gic.GraphicsCollection()
@@ -213,28 +207,29 @@ def draw_connectivity(self, vertices, deleted_nodes):
# no pen thickness change when zoomed
marker.pen.setCosmetic(True)
- for node in deleted_nodes:
- marker.Circle(vertices[node][0], vertices[node][1], 0.003)
- marker_item = GraphicsItem.GraphicsItem(marker)
- self.connections.append(marker_item)
-
- # add to the scene
- self.connections = self.mainwindow.scene. \
- createItemGroup(self.connections)
-
- def write_debug(self, unconnected, connected, deleted_nodes, vertices, vertices_clean, connectivity_clean):
-
- dicts = locals()
- # print('dicts keys', dicts.keys())
- # print('type', type(dicts))
- dicts.pop('self')
+ for node in deleted_nodes:
+ marker.Circle(vertices[node][0], vertices[node][1], 0.003)
+ marker_item = GraphicsItem.GraphicsItem(marker)
+ self.connections.append(marker_item)
+
+ # add to the scene
+ self.connections = scene.createItemGroup(self.connections)
+
+ def write_debug(self, unconnected, connected, deleted_nodes, vertices,
+ vertices_clean, connectivity_clean):
+ debug_data = {
+ 'unconnected': unconnected,
+ 'connected': connected,
+ 'deleted_nodes': deleted_nodes,
+ 'vertices': vertices,
+ 'vertices_clean': vertices_clean,
+ 'connectivity_clean': connectivity_clean
+ }
folder = 'debug'
- if not os.path.isdir(folder):
- os.mkdir(folder)
-
- # write all data to individual files
- for listname in dicts:
- with open(os.path.join(folder, listname + '.txt'), 'w') as f:
- for item in dicts[listname]:
- f.write(str(item) + '\n')
+ os.makedirs(folder, exist_ok=True)
+
+ for name, data in debug_data.items():
+ with open(os.path.join(folder, f'{name}.txt'), 'w',
+ encoding='utf-8') as f:
+ f.writelines(f'{item}\n' for item in data)
diff --git a/src/ContourAnalysis.py b/src/ContourAnalysis.py
index c2660e0..89305c9 100644
--- a/src/ContourAnalysis.py
+++ b/src/ContourAnalysis.py
@@ -1,13 +1,15 @@
-import numpy as np
-
-from PySide6 import QtCore, QtGui
-from PySide6.QtWidgets import QFrame, QHBoxLayout, QVBoxLayout, QPushButton
-from PySide6.QtCharts import QChart, QChartView, QLineSeries, QValueAxis
-from PySide6.QtGui import QPainter
-
-import logging
-logger = logging.getLogger(__name__)
+import numpy as np
+
+from PySide6 import QtCore, QtGui
+from PySide6.QtWidgets import QFrame, QHBoxLayout, QVBoxLayout, QPushButton
+from PySide6.QtCharts import QChart, QChartView, QLineSeries, QValueAxis
+from PySide6.QtGui import QPainter
+
+from ContourData import CurvatureData
+from Utils import get_main_window
+import logging
+logger = logging.getLogger(__name__)
class ContourAnalysis(QFrame):
@@ -23,14 +25,16 @@ def __init__(self, canvas=False):
super().__init__()
# get MainWindow instance (overcomes handling parents)
- self.mainwindow = QtCore.QCoreApplication.instance().mainwindow
+ self.mw = get_main_window()
# run the gui part only when canvas set to true
if canvas:
self.initUI()
- def initUI(self):
- self.lineSeries = QLineSeries()
+ def initUI(self):
+ self.setFrameShape(QFrame.NoFrame)
+
+ self.lineSeries = QLineSeries()
# legend name
# self.lineSeries.setName("trend")
self.lineSeries.append(QtCore.QPoint(0, 0))
@@ -51,10 +55,12 @@ def initUI(self):
self.chart.setAxisX(self.axisX, self.lineSeries)
self.chart.setAxisY(self.axisY, self.lineSeries)
- self.chart_view = QChartView(self.chart)
- self.chart_view.setRenderHint(QPainter.Antialiasing)
- self.chart_view.setRubberBand(QChartView.RectangleRubberBand)
- self.chart_view.setDragMode(QChartView.ScrollHandDrag)
+ self.chart_view = QChartView(self.chart)
+ self.chart_view.setRenderHint(QPainter.Antialiasing)
+ self.chart_view.setRubberBand(QChartView.RectangleRubberBand)
+ self.chart_view.setDragMode(QChartView.ScrollHandDrag)
+ self.chart_view.setFrameShape(QFrame.NoFrame)
+ self.chart_view.setStyleSheet('background: transparent; border: none;')
vlayout = QVBoxLayout()
vlayout.addWidget(self.chart_view)
@@ -87,7 +93,7 @@ def home(self):
self.chart_view.chart().zoomReset()
@staticmethod
- def getCurvature(spline_data):
+ def getCurvature(spline_data):
"""Curvature and radius of curvature of a parametric curve
der1 is dx/dt and dy/dt at each point
@@ -99,31 +105,55 @@ def getCurvature(spline_data):
curvature circle centers for each point of the curve
"""
- coo = spline_data[0]
- der1 = spline_data[3]
- der2 = spline_data[4]
-
- xd = der1[0]
- yd = der1[1]
- x2d = der2[0]
- y2d = der2[1]
- n = xd**2 + yd**2
- d = xd*y2d - yd*x2d
-
- # gradient dy/dx = dy/du / dx/du
- gradient = der1[1] / der1[0]
-
- # radius of curvature
- R = n**(3./2.) / abs(d)
-
- # curvature
- C = d / n**(3./2.)
-
- # coordinates of curvature-circle center points
- xc = coo[0] - R * yd / np.sqrt(n)
- yc = coo[1] + R * xd / np.sqrt(n)
-
- return [gradient, C, R, xc, yc]
+ xd, yd = spline_data.first_derivative
+ x2d, y2d = spline_data.second_derivative
+ speed_squared = xd**2 + yd**2
+ curvature_numerator = xd * y2d - yd * x2d
+ speed = np.sqrt(speed_squared)
+ curvature_denominator = speed_squared**(3.0 / 2.0)
+
+ with np.errstate(divide='ignore', invalid='ignore'):
+ gradient = np.divide(
+ yd,
+ xd,
+ out=np.full_like(yd, np.inf, dtype=float),
+ where=xd != 0.0,
+ )
+ radius = np.divide(
+ curvature_denominator,
+ np.abs(curvature_numerator),
+ out=np.full_like(curvature_numerator, np.inf, dtype=float),
+ where=curvature_numerator != 0.0,
+ )
+ curvature = np.divide(
+ curvature_numerator,
+ curvature_denominator,
+ out=np.zeros_like(curvature_numerator, dtype=float),
+ where=curvature_denominator != 0.0,
+ )
+ normal_x = np.divide(
+ yd,
+ speed,
+ out=np.zeros_like(yd, dtype=float),
+ where=speed != 0.0,
+ )
+ normal_y = np.divide(
+ xd,
+ speed,
+ out=np.zeros_like(xd, dtype=float),
+ where=speed != 0.0,
+ )
+
+ center_x = spline_data.coordinates[0] - radius * normal_x
+ center_y = spline_data.coordinates[1] + radius * normal_y
+
+ return CurvatureData(
+ gradient=gradient,
+ curvature=curvature,
+ radius=radius,
+ center_x=center_x,
+ center_y=center_y,
+ )
@staticmethod
def getLeRadius(spline_data, curvature_data):
@@ -135,47 +165,44 @@ def getLeRadius(spline_data, curvature_data):
point and id
"""
- radius = curvature_data[2]
- rc = np.min(radius)
- # numpy where returns a tuple
- # we take the first element, which is type array
- le_id = np.where(radius == rc)[0]
- # convert the numpy array to a list and take the first element
- le_id = le_id.tolist()[0]
- # leading edge curvature circle center
- xc = curvature_data[3][le_id]
- yc = curvature_data[4][le_id]
- xr, yr = spline_data[0]
- xle = xr[le_id]
- yle = yr[le_id]
+ radius = curvature_data.radius
+ le_id = int(np.argmin(radius))
+ rc = radius[le_id]
+ # leading edge curvature circle center
+ xc = curvature_data.center_x[le_id]
+ yc = curvature_data.center_y[le_id]
+ xr, yr = spline_data.coordinates
+ xle = xr[le_id]
+ yle = yr[le_id]
return rc, xc, yc, xle, yle, le_id
def analyze(self):
"""get specific curve properties"""
- if not self.mainwindow.airfoil.spline_data:
- self.mainwindow.slots.messageBox('Please do splining first')
- return
+ if not self.mw.airfoil.spline_data:
+ self.mw.slots.messageBox('Please prepare the contour first.')
+ return
- spline_data = self.mainwindow.airfoil.spline_data
- curvature_data = ContourAnalysis.getCurvature(spline_data)
+ spline_data = self.mw.airfoil.spline_data
+ curvature_data = self.getCurvature(spline_data)
# add new attributes to airfoil instance
- self.mainwindow.airfoil.curvature_data = curvature_data
+ self.mw.airfoil.curvature_data = curvature_data
self.drawContour()
def drawContour(self, quantity='gradient'):
"""quantity is one of 'gradient', 'curvature', 'radius' """
- spline_data = self.mainwindow.airfoil.spline_data
- curvature_data = self.mainwindow.airfoil.curvature_data
-
- selector = {'gradient': 0, 'curvature': 1, 'radius': 2}
+ spline_data = self.mw.airfoil.spline_data
+ curvature_data = self.mw.airfoil.curvature_data
- points = [QtCore.QPointF(x, y) for x, y in zip(spline_data[0][0],
- curvature_data[selector[quantity]])]
+ series = curvature_data.series(quantity)
+ points = [
+ QtCore.QPointF(x, y)
+ for x, y in zip(spline_data.coordinates[0], series)
+ ]
self.lineSeries = QLineSeries()
self.lineSeries.append(points)
diff --git a/src/ContourData.py b/src/ContourData.py
new file mode 100644
index 0000000..29edb1a
--- /dev/null
+++ b/src/ContourData.py
@@ -0,0 +1,165 @@
+from __future__ import annotations
+
+from dataclasses import dataclass, field
+from typing import Any
+
+import numpy as np
+from scipy import interpolate
+
+
+@dataclass(slots=True)
+class SplineData:
+ coordinates: tuple[Any, Any]
+ fit_parameters: Any
+ sample_parameters: Any
+ first_derivative: tuple[Any, Any]
+ second_derivative: tuple[Any, Any]
+ spline: Any = None
+ method: str = 'bspline'
+ evaluator: Any = None
+ metadata: dict[str, Any] = field(default_factory=dict)
+ leading_edge_parameter: float | None = None
+
+ @property
+ def point_count(self) -> int:
+ return len(self.coordinates[0])
+
+ def evaluate(self, parameters, der: int = 0):
+ if self.evaluator is not None:
+ return self.evaluator.evaluate(parameters, der=der)
+ if self.spline is None:
+ raise ValueError('No contour evaluator available.')
+ return interpolate.splev(parameters, self.spline, der=der)
+
+ def leading_edge_parameter_value(self) -> float:
+ if self.leading_edge_parameter is not None:
+ return float(self.leading_edge_parameter)
+ if self.sample_parameters is None or len(self.sample_parameters) == 0:
+ return 0.5
+ le_index = int(np.argmin(self.coordinates[0]))
+ return float(self.sample_parameters[le_index])
+
+ def upper_surface_parameters(self, stations):
+ stations = np.asarray(stations, dtype=float)
+ if self.evaluator is not None and \
+ hasattr(self.evaluator, 'surface_parameters'):
+ return self.evaluator.surface_parameters(stations, side='upper')
+ t_le = self.leading_edge_parameter_value()
+ return t_le * (1.0 - stations)
+
+ def lower_surface_parameters(self, stations):
+ stations = np.asarray(stations, dtype=float)
+ if self.evaluator is not None and \
+ hasattr(self.evaluator, 'surface_parameters'):
+ return self.evaluator.surface_parameters(stations, side='lower')
+ t_le = self.leading_edge_parameter_value()
+ return t_le + stations * (1.0 - t_le)
+
+
+@dataclass(slots=True)
+class CurvatureData:
+ gradient: Any
+ curvature: Any
+ radius: Any
+ center_x: Any
+ center_y: Any
+
+ def series(self, quantity: str):
+ selector = {
+ 'gradient': self.gradient,
+ 'curvature': self.curvature,
+ 'radius': self.radius,
+ }
+ try:
+ return selector[quantity]
+ except KeyError as error:
+ raise ValueError(f'Unsupported contour quantity: {quantity}') from error
+
+
+@dataclass(slots=True)
+class CamberData:
+ method: str
+ coordinates: tuple[Any, Any]
+ radius: Any
+ upper_contact: tuple[Any, Any]
+ lower_contact: tuple[Any, Any]
+ upper_parameters: Any
+ lower_parameters: Any
+ display_indices: Any
+ valid: Any
+ fallback_used: Any
+ circle_radius: Any | None = None
+
+ @property
+ def point_count(self) -> int:
+ return len(self.coordinates[0])
+
+ @property
+ def display_count(self) -> int:
+ return len(self.display_indices)
+
+ def display_coordinates(self):
+ return (
+ self.coordinates[0][self.display_indices],
+ self.coordinates[1][self.display_indices],
+ )
+
+ def polyline_coordinates(self, start_at_le_tangency: bool = True):
+ if not start_at_le_tangency or self.point_count == 0:
+ return self.coordinates
+
+ x_start = 0.5 * (
+ self.upper_contact[0][0] + self.lower_contact[0][0]
+ )
+ y_start = 0.5 * (
+ self.upper_contact[1][0] + self.lower_contact[1][0]
+ )
+
+ if self.point_count == 1:
+ return (
+ self.coordinates[0][:1] * 0.0 + x_start,
+ self.coordinates[1][:1] * 0.0 + y_start,
+ )
+
+ start_index = 1
+ first_center = np.array(
+ (self.coordinates[0][0], self.coordinates[1][0]),
+ dtype=float,
+ )
+ start_point = np.array((x_start, y_start), dtype=float)
+ first_radius = float(self.radius[0])
+
+ if first_radius > 0.0 and np.linalg.norm(first_center - start_point) > 1.0e-9:
+ centers = np.column_stack(self.coordinates)
+ distances = np.linalg.norm(centers - first_center, axis=1)
+ threshold = max(first_radius * 1.02, first_radius + 1.0e-6)
+ outside = np.flatnonzero(distances > threshold)
+ outside = outside[outside > 0]
+ if outside.size:
+ start_index = int(outside[0])
+
+ x_coordinates = np.concatenate((
+ np.array((x_start,), dtype=float),
+ self.coordinates[0][start_index:],
+ ))
+ y_coordinates = np.concatenate((
+ np.array((y_start,), dtype=float),
+ self.coordinates[1][start_index:],
+ ))
+ return x_coordinates, y_coordinates
+
+ def display_radius(self):
+ radii = self.circle_radius if self.circle_radius is not None else self.radius
+ return radii[self.display_indices]
+
+ def display_upper_contact(self):
+ return (
+ self.upper_contact[0][self.display_indices],
+ self.upper_contact[1][self.display_indices],
+ )
+
+ def display_lower_contact(self):
+ return (
+ self.lower_contact[0][self.display_indices],
+ self.lower_contact[1][self.display_indices],
+ )
diff --git a/src/Domain.py b/src/Domain.py
new file mode 100644
index 0000000..d77e110
--- /dev/null
+++ b/src/Domain.py
@@ -0,0 +1,247 @@
+from __future__ import annotations
+
+from dataclasses import dataclass, field
+from typing import Any, Iterable, TYPE_CHECKING
+
+from Shape import Polygon, Shape
+
+if TYPE_CHECKING:
+ from Mesh import Mesh
+
+
+def _points_match(a, b, tolerance: float = 1.0e-9) -> bool:
+ return abs(a[0] - b[0]) <= tolerance and abs(a[1] - b[1]) <= tolerance
+
+
+@dataclass(slots=True)
+class BoundaryLoop:
+ name: str
+ segments: list[Shape] = field(default_factory=list)
+ closed: bool = True
+ metadata: dict[str, Any] = field(default_factory=dict)
+
+ def add_segment(self, shape: Shape):
+ self.segments.append(shape)
+
+ def to_polygon(self, resolution: int | None = None):
+ points = []
+ for segment in self.segments:
+ segment_points = segment.to_polygon(resolution=resolution)
+ if not segment_points:
+ continue
+
+ current = list(segment_points)
+ if points:
+ if _points_match(points[-1], current[0]):
+ points.extend(current[1:])
+ elif _points_match(points[-1], current[-1]):
+ current.reverse()
+ points.extend(current[1:])
+ else:
+ points.extend(current)
+ else:
+ points.extend(current)
+
+ if self.closed and points and not _points_match(points[0], points[-1]):
+ points.append(points[0])
+ return points
+
+ def bounds(self, resolution: int | None = None):
+ polygon = self.to_polygon(resolution=resolution)
+ if not polygon:
+ raise ValueError(f'Boundary loop {self.name} is empty.')
+ return Polygon(polygon[:-1] if self.closed else polygon).bounds()
+
+ @property
+ def is_closed(self) -> bool:
+ return self.closed
+
+
+@dataclass(slots=True)
+class DomainPart:
+ name: str
+ boundary: BoundaryLoop | Shape | None = None
+ holes: list[BoundaryLoop | Shape] = field(default_factory=list)
+ role: str = 'part'
+ mesh: 'Mesh | None' = None
+ metadata: dict[str, Any] = field(default_factory=dict)
+
+
+class Domain:
+ """Computational domain described by boundary loops and attached meshes."""
+
+ def __init__(self, name: str, outer_boundary: BoundaryLoop | Shape,
+ inner_shapes: Iterable[BoundaryLoop | Shape] | None = None,
+ mesh: 'Mesh | None' = None,
+ metadata: dict[str, Any] | None = None):
+ self.name = name
+ self.outer_boundary = self._coerce_loop(
+ outer_boundary,
+ default_name=f'{name}_outer_boundary',
+ )
+ self.inner_shapes = [
+ self._coerce_loop(shape, default_name=f'{name}_inner_{index}')
+ for index, shape in enumerate(inner_shapes or [])
+ ]
+ self.mesh = mesh
+ self.meshes = [mesh] if mesh is not None else []
+ self.meshable_parts: list[DomainPart] = []
+ self.metadata = dict(metadata or {})
+
+ @staticmethod
+ def _coerce_loop(shape_or_loop: BoundaryLoop | Shape,
+ default_name: str) -> BoundaryLoop:
+ if isinstance(shape_or_loop, BoundaryLoop):
+ return shape_or_loop
+ return BoundaryLoop(
+ name=default_name,
+ segments=[shape_or_loop],
+ closed=shape_or_loop.is_closed,
+ )
+
+ def iter_boundaries(self):
+ yield self.outer_boundary
+ yield from self.inner_shapes
+
+ def add_inner_shape(self, shape: BoundaryLoop | Shape, name: str | None = None):
+ loop = self._coerce_loop(shape, default_name=name or f'{self.name}_inner')
+ self.inner_shapes.append(loop)
+ return loop
+
+ def add_part(self, part: DomainPart):
+ self.meshable_parts.append(part)
+ if part.mesh is not None and part.mesh not in self.meshes:
+ self.meshes.append(part.mesh)
+
+ def split_into_meshable_parts(self):
+ if not self.meshable_parts:
+ self.meshable_parts.append(
+ DomainPart(
+ name='fluid',
+ boundary=self.outer_boundary,
+ holes=list(self.inner_shapes),
+ role='fluid',
+ mesh=self.mesh,
+ )
+ )
+ return self.meshable_parts
+
+ def add_mesh(self, mesh: 'Mesh', part_name: str | None = None):
+ if mesh not in self.meshes:
+ self.meshes.append(mesh)
+
+ if part_name is None:
+ self.mesh = mesh
+ return mesh
+
+ for part in self.meshable_parts:
+ if part.name == part_name:
+ part.mesh = mesh
+ return mesh
+
+ raise ValueError(f'Unknown domain part: {part_name}')
+
+ def generate_domain(self):
+ self.validate()
+ return self.split_into_meshable_parts()
+
+ def connect_meshes(self):
+ return [part.mesh for part in self.meshable_parts if part.mesh is not None]
+
+ def bounds(self):
+ return self.outer_boundary.bounds()
+
+ def validate(self):
+ if not self.outer_boundary.segments:
+ raise ValueError('The domain outer boundary must contain segments.')
+
+ if not self.outer_boundary.is_closed:
+ raise ValueError('The domain outer boundary must be closed.')
+
+ for inner_shape in self.inner_shapes:
+ if not inner_shape.is_closed:
+ raise ValueError(
+ f'Inner boundary {inner_shape.name} must be closed.'
+ )
+
+ def display_domain(self):
+ return {
+ 'outer_boundary': self.outer_boundary.to_polygon(),
+ 'inner_boundaries': [
+ shape.to_polygon() for shape in self.inner_shapes
+ ],
+ }
+
+
+class DomainBuilder:
+ outer_boundary_order = ('top', 'outlet', 'bottom', 'inlet')
+
+ @classmethod
+ def from_mesh(cls, mesh: 'Mesh', airfoil=None, name: str | None = None,
+ metadata: dict[str, Any] | None = None) -> Domain:
+ mesh_data = getattr(mesh, 'data', None)
+ if mesh_data is None:
+ raise ValueError('Mesh model with mesh data is required.')
+
+ outer_boundary = cls.build_outer_boundary(mesh)
+ inner_shapes = cls.build_inner_boundaries(airfoil)
+ airfoil_name = getattr(airfoil, 'name', None) or getattr(mesh, 'name', 'domain')
+ domain = Domain(
+ name=name or f'{airfoil_name}_domain',
+ outer_boundary=outer_boundary,
+ inner_shapes=inner_shapes,
+ mesh=mesh,
+ metadata=metadata,
+ )
+ domain.generate_domain()
+ return domain
+
+ @classmethod
+ def build_outer_boundary(cls, mesh: 'Mesh',
+ name: str = 'wind_tunnel_outer_boundary') -> BoundaryLoop:
+ points = mesh.data.outer_boundary_vertices(excluded_tags=('airfoil',))
+ if not points:
+ segments = []
+ else:
+ polygon_points = points[:-1] if points[0] == points[-1] else points
+ segments = [Polygon(polygon_points, name=name)]
+
+ return BoundaryLoop(
+ name=name,
+ segments=segments,
+ closed=True,
+ metadata={'source': 'mesh_outer_boundary_edges'},
+ )
+
+ @staticmethod
+ def build_inner_boundaries(airfoil) -> list[BoundaryLoop]:
+ if airfoil is None:
+ return []
+
+ if isinstance(airfoil, BoundaryLoop):
+ return [airfoil]
+
+ if isinstance(airfoil, Shape):
+ return [
+ BoundaryLoop(
+ name=getattr(airfoil, 'name', 'airfoil'),
+ segments=[airfoil],
+ closed=airfoil.is_closed,
+ metadata={'role': 'airfoil'},
+ )
+ ]
+
+ to_shape = getattr(airfoil, 'to_shape', None)
+ if callable(to_shape):
+ airfoil_shape = airfoil.to_shape(prefer_spline=True, closed=True)
+ if airfoil_shape is not None:
+ return [
+ BoundaryLoop(
+ name=getattr(airfoil, 'name', 'airfoil'),
+ segments=[airfoil_shape],
+ closed=True,
+ metadata={'role': 'airfoil'},
+ )
+ ]
+
+ return []
diff --git a/src/Elliptic.py b/src/Elliptic.py
index 54c0a10..b810187 100644
--- a/src/Elliptic.py
+++ b/src/Elliptic.py
@@ -1,145 +1,940 @@
-
-import copy
-
-import numpy as np
-
-from Utils import Utils
-
-import logging
-logger = logging.getLogger(__name__)
-
-
-class Elliptic:
- def __init__(self, ulines):
-
- self.ulines = ulines
-
- self.nx = np.array(self.ulines).shape[1]
- self.ny = np.array(self.ulines).shape[0]
-
- # initialize coordinate array
- self.x = np.empty((self.nx, self.ny))
- self.y = np.empty_like(self.x)
-
-
- # map coordinates from ulines to i,j index
- self.mapUlines()
-
- def mapUlines(self):
- for j, uline in enumerate(self.ulines):
- for i, u in enumerate(uline):
- self.x[i, j] = u[0]
- self.y[i, j] = u[1]
-
- def mapToUlines(self):
- self.new_ulines = list()
- for j, uline in enumerate(self.ulines):
- new_uline = list()
- for i, u in enumerate(uline):
- new_uline.append((self.xn[i, j], self.yn[i, j]))
- self.new_ulines.append(new_uline)
-
- @staticmethod
- def curveNormals(x, y, closed=False):
- istart = 0
- iend = 0
- n = list()
-
- for i, _ in enumerate(x):
-
- if closed:
- if i == len(x) - 1:
- iend = -i - 1
- else:
- if i == 0:
- istart = 1
- if i == len(x) - 1:
- iend = -1
-
- a = np.array([x[i + 1 + iend] - x[i - 1 + istart],
- y[i + 1 + iend] - y[i - 1 + istart]])
- e = Utils.unit_vector(a)
- n.append([e[1], -e[0]])
- istart = 0
- iend = 0
- return np.array(n)
-
- def smooth(self, iterations=10, tolerance=1e-3, bnd_type=None, verbose=False):
-
- self.mapUlines()
-
- self.xn = copy.deepcopy(self.x)
- self.yn = copy.deepcopy(self.y)
-
- # calculate normals at boundaries
- # used for Neumann boundary conditions
- normals_left = self.curveNormals(self.xn[0, :], self.yn[0, :])
- normals_right = self.curveNormals(self.xn[-1, :], self.yn[-1, :])
- normals_top = self.curveNormals(self.xn[:, -1], self.yn[:, -1])
- normals_bottom = self.curveNormals(self.xn[:, 0], self.yn[:, 0])
-
- for iteration in range(iterations):
-
- # loop internal nodes, index a[0, 0] refers to the first internal node
- for i in range(1, self.nx - 1):
- for j in range(1, self.ny - 1):
-
- # g22
- alpha = 1./4. * ( (self.x[i, j+1] - self.x[i, j-1])**2 \
- + (self.y[i, j+1] - self.y[i, j-1])**2 )
- # g11
- gamma = 1./4. * ( (self.x[i+1, j] - self.x[i-1, j])**2 \
- + (self.y[i+1, j] - self.y[i-1, j])**2 )
- # g12
- beta = 1./16. * ( ( self.x[i+1, j] - self.x[i-1, j] ) \
- * ( self.x[i, j+1] - self.x[i, j-1] ) \
- + ( self.y[i+1, j] - self.y[i-1, j] ) \
- * ( self.y[i, j+1] - self.y[i, j-1] ) )
- # calculate new x-coordinate
- self.xn[i,j] = -0.5 / (alpha + gamma + 1.e-9) \
- * (2. * beta * ( self.x[i+1, j+1] - self.x[i-1, j+1] \
- - self.x[i+1, j-1] + self.x[i-1, j-1] ) \
- - alpha * ( self.x[i+1, j] + self.x[i-1, j] ) \
- - gamma * ( self.x[i, j+1] + self.x[i, j-1] ) )
- # calculate new y-coordinate
- self.yn[i,j] = -0.5 / (alpha + gamma + 1.e-9) \
- * (2. * beta * ( self.y[i+1, j+1] - self.y[i-1, j+1] \
- - self.y[i+1, j-1] + self.y[i-1, j-1] ) \
- - alpha * ( self.y[i+1, j] + self.y[i-1, j] ) \
- - gamma * ( self.y[i, j+1] + self.y[i, j-1] ) )
-
- # Neumann boundary conditions (normal to boundary here)
- # project vector a (boundary node to internal node)
- # onto vector b (normal vector at boundary) and move
- # internal node to this position
- if bnd_type == 'Neumann':
- if j == 1:
- a = [self.xn[i,1] - self.xn[i,0],
- self.yn[i,1] - self.yn[i,0]]
- b = normals_bottom[i]
- projected = np.dot(a, b) / np.dot(b, b) * b
- self.xn[i,1] = self.xn[i,0] + projected[0]
- self.yn[i,1] = self.yn[i,0] + projected[1]
- elif i == self.nx - 1:
- pass
- elif j == 1:
- pass
- elif j == self.ny - 1:
- pass
-
- tol = np.max(np.abs(self.xn - self.x)) + np.max(np.abs(self.yn - self.y))
-
- if verbose:
- logger.info(f'Iteration={iteration+1:3d}, residual={tol:.3e}')
-
- if tol < tolerance:
- break
-
- # update coordinates for next iteration
- self.x = copy.deepcopy(self.xn)
- self.y = copy.deepcopy(self.yn)
-
- # map coordinates back to uline data structure
- self.mapToUlines()
-
- return self.new_ulines
\ No newline at end of file
+from __future__ import annotations
+
+from dataclasses import dataclass
+
+import numpy as np
+
+from MathUtils import VectorUtils
+
+import logging
+logger = logging.getLogger(__name__)
+
+
+@dataclass(frozen=True)
+class BoundaryGuide:
+ """Preferred spacing/direction profile for a structured elliptic boundary."""
+
+ target_vectors: np.ndarray
+ relaxation: float = 0.25
+ layers: int = 5
+ decay: float = 0.8
+ layer_scale_factors: np.ndarray | None = None
+
+ def __post_init__(self):
+ vectors = np.asarray(self.target_vectors, dtype=float)
+ if vectors.ndim != 2 or vectors.shape[1] != 2:
+ raise ValueError('Boundary guide vectors must be an N x 2 array.')
+
+ object.__setattr__(self, 'target_vectors', vectors)
+ object.__setattr__(
+ self,
+ 'relaxation',
+ float(np.clip(self.relaxation, 0.0, 1.0)),
+ )
+ object.__setattr__(self, 'layers', max(1, int(self.layers)))
+ object.__setattr__(self, 'decay', max(0.0, float(self.decay)))
+
+ scale_factors = self.layer_scale_factors
+ if scale_factors is None:
+ object.__setattr__(self, 'layer_scale_factors', None)
+ return
+
+ normalized = np.asarray(scale_factors, dtype=float)
+ if normalized.ndim == 1:
+ normalized = normalized[np.newaxis, :]
+ if normalized.ndim != 2 or normalized.shape[1] != vectors.shape[0]:
+ raise ValueError(
+ 'Boundary guide scale factors must be an L x N array '
+ 'matching the guide point count.'
+ )
+ object.__setattr__(self, 'layer_scale_factors', normalized)
+ object.__setattr__(self, 'layers', normalized.shape[0])
+
+
+@dataclass(frozen=True)
+class SlidingBoundary:
+ """Boundary constrained to exact geometry with redistributed parameter spacing."""
+
+ geometry: np.ndarray
+ relaxation: float = 0.35
+ control_layers: int = 4
+ smoothing_passes: int = 2
+ spacing_weight: float = 4.0
+ min_spacing_fraction: float = 1.0e-4
+ control_origins: np.ndarray | None = None
+ control_directions: np.ndarray | None = None
+ control_segment: str | None = None
+
+ def __post_init__(self):
+ geometry = np.asarray(self.geometry, dtype=float)
+ if geometry.ndim != 2 or geometry.shape[1] != 2:
+ raise ValueError('Sliding boundary geometry must be an N x 2 array.')
+
+ object.__setattr__(self, 'geometry', geometry)
+ object.__setattr__(
+ self,
+ 'relaxation',
+ float(np.clip(self.relaxation, 0.0, 1.0)),
+ )
+ object.__setattr__(
+ self,
+ 'control_layers',
+ max(1, int(self.control_layers)),
+ )
+ object.__setattr__(
+ self,
+ 'smoothing_passes',
+ max(0, int(self.smoothing_passes)),
+ )
+ object.__setattr__(
+ self,
+ 'spacing_weight',
+ max(0.0, float(self.spacing_weight)),
+ )
+ object.__setattr__(
+ self,
+ 'min_spacing_fraction',
+ max(0.0, float(self.min_spacing_fraction)),
+ )
+ segment = self.control_segment
+ if segment is None:
+ object.__setattr__(self, 'control_segment', None)
+ else:
+ object.__setattr__(
+ self,
+ 'control_segment',
+ str(segment).strip().lower() or None,
+ )
+ origins = self.control_origins
+ directions = self.control_directions
+ if origins is None and directions is None:
+ object.__setattr__(self, 'control_origins', None)
+ object.__setattr__(self, 'control_directions', None)
+ return
+ if origins is None or directions is None:
+ raise ValueError(
+ 'Sliding boundary control origins and directions must be provided together.'
+ )
+
+ normalized_origins = np.asarray(origins, dtype=float)
+ normalized_directions = np.asarray(directions, dtype=float)
+ if normalized_origins.shape != geometry.shape:
+ raise ValueError(
+ 'Sliding boundary control origins must match the geometry point layout.'
+ )
+ if normalized_directions.shape != geometry.shape:
+ raise ValueError(
+ 'Sliding boundary control directions must match the geometry point layout.'
+ )
+ object.__setattr__(self, 'control_origins', normalized_origins)
+ object.__setattr__(self, 'control_directions', normalized_directions)
+
+
+class EllipticSolver:
+ """Structured elliptic grid smoother with exact-geometry sliding boundaries."""
+
+ EPSILON = 1.0e-9
+ DEFAULT_LOG_INTERVAL = 10
+ DEFAULT_RELAXATION = 0.6
+ SLIDING_CONTINUATION_FACTOR = 1.0
+ MIN_UNIFORM_PARAMETER_FRACTION = 0.60
+ RAY_TARGET_BLEND = 0.45
+ LOCAL_TARGET_BLEND = 0.30
+ CURRENT_TARGET_BLEND = 0.25
+ RAY_SPACING_BLEND = 0.20
+ LOCAL_SPACING_BLEND = 0.20
+ UNIFORM_SPACING_BLEND = 0.60
+ PAIR_TARGET_BLEND = 0.45
+ ARC_RAY_TARGET_BLEND = 0.25
+ PAIR_LOCAL_TARGET_BLEND = 0.20
+ PAIR_CURRENT_TARGET_BLEND = 0.10
+ PAIR_SPACING_BLEND = 0.50
+ PAIR_LOCAL_SPACING_BLEND = 0.20
+ PAIR_UNIFORM_SPACING_BLEND = 0.30
+
+ def __init__(self, x, y):
+ self.x = np.asarray(x, dtype=float)
+ self.y = np.asarray(y, dtype=float)
+
+ if self.x.shape != self.y.shape:
+ raise ValueError('Elliptic smoother requires x/y arrays with equal shape.')
+ if self.x.ndim != 2:
+ raise ValueError('Elliptic smoother requires 2D structured coordinate arrays.')
+
+ @staticmethod
+ def curveNormals(x, y, closed=False):
+ x = np.asarray(x, dtype=float)
+ y = np.asarray(y, dtype=float)
+ point_count = len(x)
+ if point_count == 0:
+ return np.empty((0, 2), dtype=float)
+ if point_count == 1:
+ return np.array([[0.0, 1.0]], dtype=float)
+
+ tangents = np.empty((point_count, 2), dtype=float)
+ if closed:
+ previous = np.column_stack((np.roll(x, 1), np.roll(y, 1)))
+ following = np.column_stack((np.roll(x, -1), np.roll(y, -1)))
+ tangents[:, 0] = following[:, 0] - previous[:, 0]
+ tangents[:, 1] = following[:, 1] - previous[:, 1]
+ else:
+ tangents[0] = [x[1] - x[0], y[1] - y[0]]
+ tangents[-1] = [x[-1] - x[-2], y[-1] - y[-2]]
+ if point_count > 2:
+ tangents[1:-1, 0] = x[2:] - x[:-2]
+ tangents[1:-1, 1] = y[2:] - y[:-2]
+
+ units = VectorUtils.unit_vector(tangents)
+ return np.column_stack((units[:, 1], -units[:, 0]))
+
+ @staticmethod
+ def _should_log_iteration(iteration, iterations, log_interval):
+ return (
+ iteration == 1 or
+ iteration == iterations or
+ iteration % log_interval == 0
+ )
+
+ @staticmethod
+ def _smooth_profile(values, passes):
+ profile = np.asarray(values, dtype=float)
+ if profile.ndim != 1 or profile.size < 3 or passes <= 0:
+ return np.array(profile, copy=True, dtype=float)
+
+ smoothed = np.array(profile, copy=True, dtype=float)
+ for _ in range(int(passes)):
+ updated = np.array(smoothed, copy=True, dtype=float)
+ updated[1:-1] = (
+ 0.25 * smoothed[:-2] +
+ 0.50 * smoothed[1:-1] +
+ 0.25 * smoothed[2:]
+ )
+ smoothed = updated
+
+ return smoothed
+
+ @classmethod
+ def _normalize_boundary_guides(cls, boundary_guides, shape):
+ if not boundary_guides:
+ return {}
+
+ normalized = {}
+ for side, guide in boundary_guides.items():
+ key = str(side).strip().lower()
+ if key not in ('bottom', 'top'):
+ raise ValueError(f'Unsupported guided elliptic boundary: {side}')
+
+ normalized_guide = guide
+ if not isinstance(normalized_guide, BoundaryGuide):
+ normalized_guide = BoundaryGuide(**guide)
+
+ expected_points = shape[0]
+ if normalized_guide.target_vectors.shape[0] != expected_points:
+ raise ValueError(
+ f'Boundary guide for {key} requires {expected_points} '
+ f'points, got {normalized_guide.target_vectors.shape[0]}.'
+ )
+ normalized[key] = normalized_guide
+
+ return normalized
+
+ @classmethod
+ def _normalize_sliding_boundaries(cls, sliding_boundaries, shape):
+ if not sliding_boundaries:
+ return {}
+
+ normalized = {}
+ for side, boundary in sliding_boundaries.items():
+ key = str(side).strip().lower()
+ if key not in ('bottom', 'top'):
+ raise ValueError(f'Unsupported sliding elliptic boundary: {side}')
+
+ normalized_boundary = boundary
+ if not isinstance(normalized_boundary, SlidingBoundary):
+ normalized_boundary = SlidingBoundary(**boundary)
+
+ expected_points = shape[0]
+ if normalized_boundary.geometry.shape[0] != expected_points:
+ raise ValueError(
+ f'Sliding boundary for {key} requires {expected_points} '
+ f'points, got {normalized_boundary.geometry.shape[0]}.'
+ )
+ normalized[key] = normalized_boundary
+
+ return normalized
+
+ @staticmethod
+ def _polyline_parameters(points):
+ points = np.asarray(points, dtype=float)
+ if len(points) <= 1:
+ return np.zeros(len(points), dtype=float)
+
+ deltas = np.linalg.norm(np.diff(points, axis=0), axis=1)
+ cumulative = np.concatenate(([0.0], np.cumsum(deltas)))
+ total_length = cumulative[-1]
+ if total_length <= 0.0:
+ return np.linspace(0.0, 1.0, len(points))
+ return cumulative / total_length
+
+ @classmethod
+ def _sample_polyline(cls, geometry, parameters):
+ geometry = np.asarray(geometry, dtype=float)
+ parameters = np.asarray(parameters, dtype=float)
+
+ if len(geometry) == 0:
+ empty = np.empty((0, 2), dtype=float)
+ return empty, empty
+ if len(geometry) == 1:
+ points = np.repeat(geometry, len(parameters), axis=0)
+ tangents = np.tile(np.array([[1.0, 0.0]], dtype=float), (len(parameters), 1))
+ return points, tangents
+
+ reference_parameters = cls._polyline_parameters(geometry)
+ x_values = np.interp(parameters, reference_parameters, geometry[:, 0])
+ y_values = np.interp(parameters, reference_parameters, geometry[:, 1])
+ sampled_points = np.column_stack((x_values, y_values))
+
+ step = 1.0e-4
+ previous = np.clip(parameters - step, 0.0, 1.0)
+ following = np.clip(parameters + step, 0.0, 1.0)
+ prev_x = np.interp(previous, reference_parameters, geometry[:, 0])
+ prev_y = np.interp(previous, reference_parameters, geometry[:, 1])
+ next_x = np.interp(following, reference_parameters, geometry[:, 0])
+ next_y = np.interp(following, reference_parameters, geometry[:, 1])
+ tangents = np.column_stack((next_x - prev_x, next_y - prev_y))
+ norms = np.linalg.norm(tangents, axis=1, keepdims=True)
+ tangents = np.divide(
+ tangents,
+ norms,
+ out=np.zeros_like(tangents),
+ where=norms > cls.EPSILON,
+ )
+ return sampled_points, tangents
+
+ @classmethod
+ def _project_points_onto_polyline(cls, geometry, points):
+ geometry = np.asarray(geometry, dtype=float)
+ points = np.asarray(points, dtype=float)
+
+ if len(geometry) == 0:
+ empty = np.empty((0, 2), dtype=float)
+ return empty, np.empty((0,), dtype=float)
+ if len(geometry) == 1:
+ projected = np.repeat(geometry, len(points), axis=0)
+ return projected, np.zeros(len(points), dtype=float)
+
+ reference_parameters = cls._polyline_parameters(geometry)
+ segment_start = geometry[:-1]
+ segment_end = geometry[1:]
+ segment_vectors = segment_end - segment_start
+ segment_length_sq = np.sum(segment_vectors * segment_vectors, axis=1)
+
+ projected = np.empty_like(points, dtype=float)
+ projected_parameters = np.empty(len(points), dtype=float)
+
+ for index, point in enumerate(points):
+ offsets = point - segment_start
+ local = np.divide(
+ np.sum(offsets * segment_vectors, axis=1),
+ segment_length_sq,
+ out=np.zeros_like(segment_length_sq),
+ where=segment_length_sq > cls.EPSILON,
+ )
+ local = np.clip(local, 0.0, 1.0)
+ candidates = segment_start + local[:, None] * segment_vectors
+ distance_sq = np.sum((candidates - point) ** 2, axis=1)
+ best = int(np.argmin(distance_sq))
+ projected[index] = candidates[best]
+ projected_parameters[index] = (
+ reference_parameters[best] +
+ local[best] * (reference_parameters[best + 1] - reference_parameters[best])
+ )
+
+ return projected, projected_parameters
+
+ @classmethod
+ def _control_segment_slice(cls, geometry, segment):
+ if segment is None:
+ return 0, len(geometry)
+
+ if segment != 'c_arc':
+ raise ValueError(f'Unsupported sliding boundary control segment: {segment}')
+
+ if len(geometry) < 3:
+ return 0, len(geometry)
+
+ x_values = np.asarray(geometry[:, 0], dtype=float)
+ tolerance = max(1.0e-8, 1.0e-8 * np.max(np.abs(x_values)))
+ zero_crossings = np.where(np.abs(x_values) <= tolerance)[0]
+ if zero_crossings.size < 2:
+ return 0, len(geometry)
+
+ start = int(zero_crossings[0])
+ stop = int(zero_crossings[-1]) + 1
+ if stop - start < 2:
+ return 0, len(geometry)
+ return start, stop
+
+ @staticmethod
+ def _cross_2d(first, second):
+ return first[0] * second[1] - first[1] * second[0]
+
+ @classmethod
+ def _ray_parameters_on_polyline(cls, geometry, origins, directions):
+ geometry = np.asarray(geometry, dtype=float)
+ origins = np.asarray(origins, dtype=float)
+ directions = np.asarray(directions, dtype=float)
+
+ if len(geometry) <= 1 or len(origins) == 0:
+ return np.zeros(len(origins), dtype=float)
+
+ reference_parameters = cls._polyline_parameters(geometry)
+ segment_start = geometry[:-1]
+ segment_end = geometry[1:]
+ segment_vectors = segment_end - segment_start
+ parameters = np.empty(len(origins), dtype=float)
+
+ for point_index, (origin, direction) in enumerate(zip(origins, directions)):
+ direction_norm = np.linalg.norm(direction)
+ if direction_norm <= cls.EPSILON:
+ fallback_point = origin[np.newaxis, :]
+ _, projected = cls._project_points_onto_polyline(geometry, fallback_point)
+ parameters[point_index] = projected[0]
+ continue
+
+ best_parameter = None
+ best_distance = np.inf
+
+ for segment_index, (start, segment) in enumerate(zip(segment_start, segment_vectors)):
+ denominator = cls._cross_2d(direction, segment)
+ if abs(denominator) <= cls.EPSILON:
+ continue
+
+ delta = start - origin
+ ray_distance = cls._cross_2d(delta, segment) / denominator
+ segment_fraction = cls._cross_2d(delta, direction) / denominator
+
+ if ray_distance < 0.0:
+ continue
+ if segment_fraction < -1.0e-9 or segment_fraction > 1.0 + 1.0e-9:
+ continue
+
+ clipped_fraction = float(np.clip(segment_fraction, 0.0, 1.0))
+ if ray_distance < best_distance:
+ best_distance = ray_distance
+ best_parameter = (
+ reference_parameters[segment_index] +
+ clipped_fraction * (
+ reference_parameters[segment_index + 1] -
+ reference_parameters[segment_index]
+ )
+ )
+
+ if best_parameter is None:
+ fallback_point = (origin + direction)[np.newaxis, :]
+ _, projected = cls._project_points_onto_polyline(geometry, fallback_point)
+ best_parameter = projected[0]
+
+ parameters[point_index] = best_parameter
+
+ return parameters
+
+ @classmethod
+ def _ray_parameters_on_control_segment(cls, geometry, origins, directions, segment):
+ start, stop = cls._control_segment_slice(geometry, segment)
+ if start == 0 and stop == len(geometry):
+ return cls._ray_parameters_on_polyline(geometry, origins, directions)
+
+ reference_parameters = cls._polyline_parameters(geometry)
+ segment_geometry = geometry[start:stop]
+ segment_parameters = cls._ray_parameters_on_polyline(
+ segment_geometry,
+ origins,
+ directions,
+ )
+ local_reference = cls._polyline_parameters(segment_geometry)
+ return np.interp(
+ segment_parameters,
+ local_reference,
+ reference_parameters[start:stop],
+ )
+
+ @classmethod
+ def _parameter_spacing(cls, geometry, min_spacing_fraction):
+ parameters = cls._polyline_parameters(geometry)
+ if len(parameters) <= 1:
+ return 0.0
+ average_spacing = 1.0 / float(len(parameters) - 1)
+ return float(
+ max(
+ max(np.min(np.diff(parameters)), cls.EPSILON) *
+ max(0.0, float(min_spacing_fraction)),
+ average_spacing * cls.MIN_UNIFORM_PARAMETER_FRACTION,
+ )
+ )
+
+ @classmethod
+ def _guard_parameters(cls, parameters, min_spacing):
+ guarded = np.array(parameters, copy=True, dtype=float)
+ if len(guarded) <= 2:
+ return guarded
+
+ guarded[0] = 0.0
+ guarded[-1] = 1.0
+
+ for index in range(1, len(guarded) - 1):
+ guarded[index] = max(guarded[index], guarded[index - 1] + min_spacing)
+
+ for index in range(len(guarded) - 2, 0, -1):
+ guarded[index] = min(guarded[index], guarded[index + 1] - min_spacing)
+
+ guarded[1:-1] = np.clip(guarded[1:-1], 0.0, 1.0)
+ guarded[0] = 0.0
+ guarded[-1] = 1.0
+ return guarded
+
+ @staticmethod
+ def _boundary_row(x, y, column):
+ return np.column_stack((x[:, column], y[:, column]))
+
+ @classmethod
+ def _control_row_pairs(cls, x, y, side, layer_count):
+ pairs = []
+ ny = x.shape[1]
+ available_pairs = max(0, ny - 3)
+ pair_count = min(max(1, int(layer_count)), available_pairs)
+
+ if pair_count <= 0:
+ return pairs
+
+ if side == 'top':
+ for depth in range(1, pair_count + 1):
+ near_column = -1 - depth
+ far_column = near_column - 1
+ pairs.append(
+ (
+ cls._boundary_row(x, y, near_column),
+ cls._boundary_row(x, y, far_column),
+ )
+ )
+ elif side == 'bottom':
+ for depth in range(1, pair_count + 1):
+ near_column = depth
+ far_column = near_column + 1
+ pairs.append(
+ (
+ cls._boundary_row(x, y, near_column),
+ cls._boundary_row(x, y, far_column),
+ )
+ )
+ else:
+ raise ValueError(f'Unsupported sliding boundary: {side}')
+
+ return pairs
+
+ @classmethod
+ def _solve_parameter_distribution(cls, projection_target, edge_fractions, spacing_weight):
+ point_count = len(projection_target)
+ if point_count <= 2:
+ return np.array(projection_target, copy=True, dtype=float)
+
+ spacing_weight = max(0.0, float(spacing_weight))
+ if spacing_weight <= 0.0:
+ solution = np.array(projection_target, copy=True, dtype=float)
+ solution[0] = 0.0
+ solution[-1] = 1.0
+ return solution
+
+ interior_count = point_count - 2
+ matrix = np.zeros((interior_count, interior_count), dtype=float)
+ rhs = np.array(projection_target[1:-1], copy=True, dtype=float)
+
+ for row_index in range(interior_count):
+ parameter_index = row_index + 1
+ matrix[row_index, row_index] = 1.0 + 2.0 * spacing_weight
+ rhs[row_index] += spacing_weight * (
+ edge_fractions[parameter_index - 1] - edge_fractions[parameter_index]
+ )
+
+ if row_index > 0:
+ matrix[row_index, row_index - 1] = -spacing_weight
+ if row_index < interior_count - 1:
+ matrix[row_index, row_index + 1] = -spacing_weight
+ else:
+ rhs[row_index] += spacing_weight
+
+ solution = np.zeros(point_count, dtype=float)
+ solution[0] = 0.0
+ solution[-1] = 1.0
+ solution[1:-1] = np.linalg.solve(matrix, rhs)
+ return solution
+
+ @classmethod
+ def _sliding_target_parameters(cls, x, y, side, boundary, current_parameters):
+ adjacent_column = -2 if side == 'top' else 1
+ adjacent = cls._boundary_row(x, y, adjacent_column)
+ _, local_target = cls._project_points_onto_polyline(
+ boundary.geometry,
+ adjacent,
+ )
+ local_spacing = np.linalg.norm(np.diff(adjacent, axis=0), axis=1)
+
+ pairs = cls._control_row_pairs(
+ x,
+ y,
+ side,
+ boundary.control_layers,
+ )
+
+ if pairs:
+ weights = np.linspace(float(len(pairs)), 1.0, len(pairs))
+ weights /= np.sum(weights)
+ pair_target = np.zeros(x.shape[0], dtype=float)
+ pair_spacing = np.zeros(x.shape[0] - 1, dtype=float)
+
+ for weight, (near, far) in zip(weights, pairs):
+ step_vectors = near - far
+ extrapolated = near + cls.SLIDING_CONTINUATION_FACTOR * step_vectors
+ _, projected_parameters = cls._project_points_onto_polyline(
+ boundary.geometry,
+ extrapolated,
+ )
+ pair_target += weight * projected_parameters
+ pair_spacing += weight * np.linalg.norm(
+ np.diff(near, axis=0),
+ axis=1,
+ )
+ else:
+ pair_target = local_target
+ pair_spacing = local_spacing
+
+ if (
+ boundary.control_origins is not None and
+ boundary.control_directions is not None
+ ):
+ ray_target = cls._ray_parameters_on_control_segment(
+ boundary.geometry,
+ boundary.control_origins,
+ boundary.control_directions,
+ boundary.control_segment,
+ )
+ projection_target = cls._smooth_profile(
+ (
+ cls.PAIR_TARGET_BLEND * pair_target +
+ cls.ARC_RAY_TARGET_BLEND * ray_target +
+ cls.PAIR_LOCAL_TARGET_BLEND * local_target +
+ cls.PAIR_CURRENT_TARGET_BLEND * current_parameters
+ ),
+ max(boundary.smoothing_passes, 1),
+ )
+ projection_target[0] = 0.0
+ projection_target[-1] = 1.0
+ uniform_spacing = np.full_like(local_spacing, np.mean(local_spacing))
+ spacing_target = (
+ cls.PAIR_SPACING_BLEND * pair_spacing +
+ cls.PAIR_LOCAL_SPACING_BLEND * local_spacing +
+ cls.PAIR_UNIFORM_SPACING_BLEND * uniform_spacing
+ )
+ spacing_target = np.maximum(spacing_target, cls.EPSILON)
+ spacing_target = cls._smooth_profile(
+ spacing_target,
+ max(boundary.smoothing_passes, 1),
+ )
+ edge_fractions = spacing_target / np.sum(spacing_target)
+ return cls._solve_parameter_distribution(
+ projection_target,
+ edge_fractions,
+ boundary.spacing_weight,
+ )
+
+ projection_target = pair_target
+ spacing_target = pair_spacing
+
+ projection_target = cls._smooth_profile(
+ projection_target,
+ boundary.smoothing_passes,
+ )
+ projection_target[0] = 0.0
+ projection_target[-1] = 1.0
+
+ spacing_target = np.maximum(spacing_target, cls.EPSILON)
+ spacing_target = cls._smooth_profile(
+ spacing_target,
+ boundary.smoothing_passes,
+ )
+ spacing_target = np.maximum(spacing_target, cls.EPSILON)
+ edge_fractions = spacing_target / np.sum(spacing_target)
+
+ target = cls._solve_parameter_distribution(
+ projection_target,
+ edge_fractions,
+ boundary.spacing_weight,
+ )
+ return target
+
+ @classmethod
+ def _apply_boundary_guide(cls, x, y, side, guide):
+ if guide.relaxation <= 0.0:
+ return
+
+ interior_layers = max(0, x.shape[1] - 2)
+ if interior_layers <= 0:
+ return
+
+ boundary_column = 0 if side == 'bottom' else -1
+ direction = 1.0 if side == 'bottom' else -1.0
+ boundary = np.column_stack((x[:, boundary_column], y[:, boundary_column]))
+ layers = min(guide.layers, interior_layers)
+ scale_factors = guide.layer_scale_factors
+
+ for layer in range(1, layers + 1):
+ weight = guide.relaxation * (guide.decay ** (layer - 1))
+ if weight <= 0.0:
+ continue
+
+ if scale_factors is None:
+ target = boundary + direction * float(layer) * guide.target_vectors
+ else:
+ target = boundary + direction * scale_factors[layer - 1][:, None] * guide.target_vectors
+
+ column = layer if side == 'bottom' else -layer - 1
+ x[1:-1, column] = (
+ (1.0 - weight) * x[1:-1, column] +
+ weight * target[1:-1, 0]
+ )
+ y[1:-1, column] = (
+ (1.0 - weight) * y[1:-1, column] +
+ weight * target[1:-1, 1]
+ )
+
+ @classmethod
+ def _apply_sliding_boundary(cls, x, y, side, boundary, parameters):
+ if boundary.relaxation <= 0.0 or len(parameters) <= 2:
+ sampled_points, _ = cls._sample_polyline(boundary.geometry, parameters)
+ return sampled_points, parameters
+
+ target_parameters = cls._sliding_target_parameters(
+ x,
+ y,
+ side,
+ boundary,
+ parameters,
+ )
+ min_spacing = cls._parameter_spacing(
+ boundary.geometry,
+ boundary.min_spacing_fraction,
+ )
+
+ updated_parameters = (
+ (1.0 - boundary.relaxation) * parameters +
+ boundary.relaxation * target_parameters
+ )
+ updated_parameters = cls._smooth_profile(
+ updated_parameters,
+ boundary.smoothing_passes,
+ )
+ updated_parameters = cls._guard_parameters(updated_parameters, min_spacing)
+
+ sampled_points, _ = cls._sample_polyline(boundary.geometry, updated_parameters)
+ return sampled_points, updated_parameters
+
+ def smooth(self, iterations=10, tolerance=1.0e-3,
+ boundary_condition=None, boundary_guides=None,
+ sliding_boundaries=None, relaxation=None,
+ verbose=False, log_interval=None):
+ x = np.array(self.x, copy=True, dtype=float)
+ y = np.array(self.y, copy=True, dtype=float)
+
+ iterations = int(iterations)
+ log_interval = (
+ self.DEFAULT_LOG_INTERVAL if log_interval is None else max(1, int(log_interval))
+ )
+ if relaxation is None:
+ relaxation = self.DEFAULT_RELAXATION
+ relaxation = float(np.clip(relaxation, 0.0, 1.0))
+
+ if x.shape[0] < 3 or x.shape[1] < 3 or iterations <= 0:
+ return x, y
+
+ if boundary_condition is None:
+ normalized_boundary = None
+ else:
+ normalized_boundary = str(boundary_condition).strip().lower()
+
+ if normalized_boundary not in (None, 'neumann'):
+ raise ValueError(
+ f'Unsupported elliptic boundary condition: {boundary_condition}'
+ )
+
+ normalized_guides = self._normalize_boundary_guides(boundary_guides, x.shape)
+ normalized_sliding = self._normalize_sliding_boundaries(sliding_boundaries, x.shape)
+
+ sliding_parameters = {
+ side: self._polyline_parameters(boundary.geometry)
+ for side, boundary in normalized_sliding.items()
+ }
+
+ xn = np.array(x, copy=True, dtype=float)
+ yn = np.array(y, copy=True, dtype=float)
+ interior = np.s_[1:-1, 1:-1]
+
+ normals_bottom = None
+ if normalized_boundary == 'neumann':
+ normals_bottom = self.curveNormals(x[:, 0], y[:, 0])[1:-1]
+
+ for side, boundary in normalized_sliding.items():
+ sampled_points, updated_parameters = self._apply_sliding_boundary(
+ x,
+ y,
+ side,
+ boundary,
+ sliding_parameters[side],
+ )
+ sliding_parameters[side] = updated_parameters
+ if side == 'bottom':
+ x[:, 0] = sampled_points[:, 0]
+ y[:, 0] = sampled_points[:, 1]
+ else:
+ x[:, -1] = sampled_points[:, 0]
+ y[:, -1] = sampled_points[:, 1]
+
+ for iteration in range(1, iterations + 1):
+ xn[:, :] = x
+ yn[:, :] = y
+
+ x_ip1 = x[2:, 1:-1]
+ x_im1 = x[:-2, 1:-1]
+ x_jp1 = x[1:-1, 2:]
+ x_jm1 = x[1:-1, :-2]
+ y_ip1 = y[2:, 1:-1]
+ y_im1 = y[:-2, 1:-1]
+ y_jp1 = y[1:-1, 2:]
+ y_jm1 = y[1:-1, :-2]
+
+ alpha = 0.25 * ((x_jp1 - x_jm1) ** 2 + (y_jp1 - y_jm1) ** 2)
+ gamma = 0.25 * ((x_ip1 - x_im1) ** 2 + (y_ip1 - y_im1) ** 2)
+ beta = 0.0625 * (
+ (x_ip1 - x_im1) * (x_jp1 - x_jm1) +
+ (y_ip1 - y_im1) * (y_jp1 - y_jm1)
+ )
+ denominator = alpha + gamma + self.EPSILON
+
+ x_candidate = -0.5 / denominator * (
+ 2.0 * beta * (
+ x[2:, 2:] - x[:-2, 2:] - x[2:, :-2] + x[:-2, :-2]
+ ) -
+ alpha * (x_ip1 + x_im1) -
+ gamma * (x_jp1 + x_jm1)
+ )
+ y_candidate = -0.5 / denominator * (
+ 2.0 * beta * (
+ y[2:, 2:] - y[:-2, 2:] - y[2:, :-2] + y[:-2, :-2]
+ ) -
+ alpha * (y_ip1 + y_im1) -
+ gamma * (y_jp1 + y_jm1)
+ )
+
+ xn[interior] = (
+ (1.0 - relaxation) * x[interior] +
+ relaxation * x_candidate
+ )
+ yn[interior] = (
+ (1.0 - relaxation) * y[interior] +
+ relaxation * y_candidate
+ )
+
+ if normalized_boundary == 'neumann':
+ boundary_points = np.column_stack((xn[1:-1, 0], yn[1:-1, 0]))
+ interior_points = np.column_stack((xn[1:-1, 1], yn[1:-1, 1]))
+ normal_scale = np.sum(
+ (interior_points - boundary_points) * normals_bottom,
+ axis=1,
+ ) / np.maximum(
+ np.sum(normals_bottom * normals_bottom, axis=1),
+ self.EPSILON,
+ )
+ projected = boundary_points + normal_scale[:, None] * normals_bottom
+ xn[1:-1, 1] = projected[:, 0]
+ yn[1:-1, 1] = projected[:, 1]
+
+ for side, guide in normalized_guides.items():
+ self._apply_boundary_guide(xn, yn, side, guide)
+
+ for side, boundary in normalized_sliding.items():
+ sampled_points, updated_parameters = self._apply_sliding_boundary(
+ xn,
+ yn,
+ side,
+ boundary,
+ sliding_parameters[side],
+ )
+ sliding_parameters[side] = updated_parameters
+ if side == 'bottom':
+ xn[:, 0] = sampled_points[:, 0]
+ yn[:, 0] = sampled_points[:, 1]
+ else:
+ xn[:, -1] = sampled_points[:, 0]
+ yn[:, -1] = sampled_points[:, 1]
+
+ residual = np.max(np.abs(xn - x)) + np.max(np.abs(yn - y))
+
+ if verbose and self._should_log_iteration(
+ iteration,
+ iterations,
+ log_interval,
+ ):
+ logger.info(f'Iteration={iteration:3d}, residual={residual:.3e}')
+
+ x, xn = xn, x
+ y, yn = yn, y
+ if residual < tolerance:
+ break
+
+ return x, y
+
+
+class Elliptic(EllipticSolver):
+ """Backward-compatible wrapper preserving the former ulines API."""
+
+ def __init__(self, ulines):
+ self.ulines = ulines
+ x, y = self._map_ulines(ulines)
+ super().__init__(x, y)
+
+ @staticmethod
+ def _map_ulines(ulines):
+ ny = len(ulines)
+ nx = len(ulines[0]) if ulines else 0
+ x = np.empty((nx, ny), dtype=float)
+ y = np.empty_like(x)
+
+ for j_index, uline in enumerate(ulines):
+ coordinates = np.asarray(uline, dtype=float)
+ x[:, j_index] = coordinates[:, 0]
+ y[:, j_index] = coordinates[:, 1]
+
+ return x, y
+
+ def _map_to_ulines(self, x, y):
+ ulines = []
+ for j_index, uline in enumerate(self.ulines):
+ new_uline = []
+ for i_index, _ in enumerate(uline):
+ new_uline.append((float(x[i_index, j_index]), float(y[i_index, j_index])))
+ ulines.append(new_uline)
+ return ulines
+
+ def smooth(self, iterations=10, tolerance=1.0e-3, bnd_type=None,
+ boundary_guides=None, sliding_boundaries=None,
+ relaxation=None, verbose=False, log_interval=None):
+ x, y = super().smooth(
+ iterations=iterations,
+ tolerance=tolerance,
+ boundary_condition=bnd_type,
+ boundary_guides=boundary_guides,
+ sliding_boundaries=sliding_boundaries,
+ relaxation=relaxation,
+ verbose=verbose,
+ log_interval=log_interval,
+ )
+ return self._map_to_ulines(x, y)
diff --git a/src/ExperimentalCGrid.py b/src/ExperimentalCGrid.py
new file mode 100644
index 0000000..2573d65
--- /dev/null
+++ b/src/ExperimentalCGrid.py
@@ -0,0 +1,758 @@
+from __future__ import annotations
+
+from dataclasses import dataclass
+
+import numpy as np
+
+from BlockMesh import BlockMesh
+from Elliptic import EllipticSolver
+
+
+@dataclass(slots=True)
+class ExperimentalCGridSettings:
+ name: str = 'block_experimental_c_grid'
+ surface_points: int = 0
+ normal_divisions: int = 100
+ first_layer_thickness: float = 0.004
+ wake_points: int = 75
+ farfield_wake_length_ratio: float = 1.0
+ farfield_wake_start_ratio: float = 10.0
+ initial_smoothing_iterations: int = 100
+ final_smoothing_iterations: int = 20
+ local_te_smoothing_iterations: int = 10
+ smoothing_tolerance: float = 1.0e-5
+ relaxation: float = 0.6
+ orthogonal_control_length: float = 0.5
+
+ def __post_init__(self):
+ self.surface_points = max(0, int(self.surface_points))
+ self.normal_divisions = max(4, int(self.normal_divisions))
+ self.first_layer_thickness = max(1.0e-12, float(self.first_layer_thickness))
+ self.wake_points = max(4, int(self.wake_points))
+ self.farfield_wake_length_ratio = float(
+ np.clip(self.farfield_wake_length_ratio, 0.05, 1.0)
+ )
+ self.farfield_wake_start_ratio = max(
+ 1.0,
+ float(self.farfield_wake_start_ratio),
+ )
+ self.initial_smoothing_iterations = max(
+ 0,
+ int(self.initial_smoothing_iterations),
+ )
+ self.final_smoothing_iterations = max(
+ 0,
+ int(self.final_smoothing_iterations),
+ )
+ self.local_te_smoothing_iterations = max(
+ 0,
+ int(self.local_te_smoothing_iterations),
+ )
+ self.smoothing_tolerance = max(1.0e-12, float(self.smoothing_tolerance))
+ self.relaxation = float(np.clip(self.relaxation, 0.01, 1.0))
+ self.orthogonal_control_length = max(
+ 1.0e-6,
+ float(self.orthogonal_control_length),
+ )
+
+
+class ExperimentalCGridGenerator:
+ """Experimental C-grid generator with sharp- and finite-TE paths."""
+
+ cut_interface_scale = 0.15
+
+ def build_block(self, contour, *, radius: float, wake_length: float,
+ settings: ExperimentalCGridSettings) -> BlockMesh:
+ contour_points = self._prepare_contour(
+ contour,
+ settings.surface_points,
+ closed=True,
+ )
+ reference_te_x = self._reference_trailing_edge_x(contour_points)
+ te_spacing = self._trailing_edge_spacing(contour_points)
+ upper_wake, lower_wake = self._build_wake_branches(
+ start_point=contour_points[0],
+ end_point=contour_points[-1],
+ reference_te_x=reference_te_x,
+ wake_length=float(wake_length),
+ point_count=settings.wake_points,
+ first_spacing=te_spacing,
+ )
+ inner_boundary = self._compose_inner_boundary(
+ contour_points,
+ upper_wake,
+ lower_wake,
+ )
+ outer_boundary = self._build_outer_boundary(
+ reference_te_x=reference_te_x,
+ radius=float(radius),
+ wake_length=float(wake_length),
+ upper_points=len(upper_wake),
+ middle_points=len(contour_points),
+ lower_points=len(lower_wake),
+ te_spacing=te_spacing,
+ wake_length_ratio=settings.farfield_wake_length_ratio,
+ wake_start_ratio=settings.farfield_wake_start_ratio,
+ )
+ x_grid, y_grid = self._build_algebraic_grid(
+ inner_boundary,
+ outer_boundary,
+ point_count=settings.normal_divisions + 1,
+ control_length=settings.orthogonal_control_length,
+ )
+ x_grid, y_grid = self._refine_outer_grid(x_grid, y_grid, settings)
+ return self._arrays_to_block(x_grid, y_grid, settings.name)
+
+ def build_blunt_blocks(self, contour, *, radius: float, wake_length: float,
+ settings: ExperimentalCGridSettings,
+ trailing_edge_settings) -> list[BlockMesh]:
+ contour_points = self._prepare_contour(
+ contour,
+ settings.surface_points,
+ closed=False,
+ )
+ reference_te_x = self._reference_trailing_edge_x(contour_points)
+ te_spacing = self._trailing_edge_spacing(contour_points)
+
+ te_block = self._build_trailing_edge_patch(
+ contour_points,
+ trailing_edge_settings,
+ name=f'{settings.name}_te_patch',
+ )
+ upper_side, lower_side = self._extract_patch_side_lines(
+ te_block,
+ contour_points,
+ )
+ downstream_face = self._extract_downstream_face(
+ te_block,
+ upper_side[-1],
+ lower_side[-1],
+ )
+ cut_line = self._build_cut_line(
+ downstream_face,
+ reference_te_x=reference_te_x,
+ wake_length=float(wake_length),
+ first_layer=settings.first_layer_thickness,
+ )
+
+ upper_wake, lower_wake = self._build_wake_branches(
+ start_point=downstream_face[0],
+ end_point=downstream_face[-1],
+ reference_te_x=reference_te_x,
+ wake_length=float(wake_length),
+ point_count=settings.wake_points,
+ first_spacing=te_spacing,
+ cut_line=cut_line,
+ )
+
+ surface_path = self._compose_segments(
+ upper_side[::-1],
+ contour_points,
+ lower_side,
+ )
+ inner_boundary = self._compose_inner_boundary(
+ surface_path,
+ upper_wake,
+ lower_wake,
+ )
+ outer_boundary = self._build_outer_boundary(
+ reference_te_x=reference_te_x,
+ radius=float(radius),
+ wake_length=float(wake_length),
+ upper_points=len(upper_wake),
+ middle_points=len(surface_path),
+ lower_points=len(lower_wake),
+ te_spacing=te_spacing,
+ wake_length_ratio=settings.farfield_wake_length_ratio,
+ wake_start_ratio=settings.farfield_wake_start_ratio,
+ )
+
+ left_boundary = BlockMesh.makeLine(
+ cut_line[0],
+ outer_boundary[0],
+ divisions=settings.normal_divisions,
+ ratio=1.0,
+ )
+ right_boundary = BlockMesh.makeLine(
+ cut_line[-1],
+ outer_boundary[-1],
+ divisions=settings.normal_divisions,
+ ratio=1.0,
+ )
+ x_outer, y_outer = self._build_transfinite_grid(
+ inner_boundary,
+ outer_boundary,
+ left_boundary,
+ right_boundary,
+ )
+ x_outer, y_outer = self._refine_outer_grid(x_outer, y_outer, settings)
+
+ wake_bridge = self._build_wake_bridge_block(
+ downstream_face,
+ cut_line,
+ upper_wake,
+ lower_wake,
+ name=f'{settings.name}_wake_bridge',
+ settings=settings,
+ )
+
+ return [
+ te_block,
+ wake_bridge,
+ self._arrays_to_block(x_outer, y_outer, settings.name),
+ ]
+
+ @staticmethod
+ def _prepare_contour(contour, surface_points: int, *,
+ closed: bool | None = None) -> np.ndarray:
+ points = np.column_stack(contour).astype(float, copy=False)
+ if points.ndim != 2 or points.shape[1] != 2 or len(points) < 4:
+ raise ValueError('Experimental C-grid requires a valid 2D contour.')
+
+ is_closed = np.allclose(points[0], points[-1], atol=1.0e-8)
+ if closed is True and not is_closed:
+ raise ValueError(
+ 'Experimental C-grid currently requires a sharp trailing edge.'
+ )
+ if closed is False and is_closed:
+ raise ValueError(
+ 'Finite trailing-edge experimental meshing requires an open contour.'
+ )
+
+ if surface_points > 0 and surface_points != len(points):
+ points = ExperimentalCGridGenerator._resample_polyline_count(
+ points,
+ surface_points,
+ )
+ if is_closed:
+ points[-1] = points[0]
+
+ return points
+
+ @staticmethod
+ def _reference_trailing_edge_x(contour_points: np.ndarray) -> float:
+ return float(max(contour_points[0, 0], contour_points[-1, 0]))
+
+ @staticmethod
+ def _trailing_edge_spacing(contour_points: np.ndarray) -> float:
+ upper = np.linalg.norm(contour_points[1] - contour_points[0])
+ lower = np.linalg.norm(contour_points[-2] - contour_points[-1])
+ spacing = 0.5 * (float(upper) + float(lower))
+ return max(spacing, 1.0e-6)
+
+ @staticmethod
+ def _polyline_cumulative(points: np.ndarray) -> np.ndarray:
+ if len(points) == 0:
+ return np.zeros(0, dtype=float)
+ deltas = np.diff(points, axis=0)
+ segment_lengths = np.linalg.norm(deltas, axis=1)
+ return np.concatenate(([0.0], np.cumsum(segment_lengths)))
+
+ @classmethod
+ def _resample_polyline_count(cls, points: np.ndarray, count: int) -> np.ndarray:
+ cumulative = cls._polyline_cumulative(points)
+ if len(cumulative) == 0 or np.isclose(cumulative[-1], 0.0):
+ return np.repeat(points[:1], count, axis=0)
+ targets = np.linspace(0.0, cumulative[-1], count)
+ return cls._sample_polyline_distances(points, targets)
+
+ @classmethod
+ def _sample_polyline_distances(cls, points: np.ndarray,
+ distances: np.ndarray) -> np.ndarray:
+ cumulative = cls._polyline_cumulative(points)
+ if len(cumulative) == 0 or np.isclose(cumulative[-1], 0.0):
+ return np.repeat(points[:1], len(distances), axis=0)
+ distances = np.asarray(distances, dtype=float)
+ distances = np.clip(distances, 0.0, cumulative[-1])
+ x_values = np.interp(distances, cumulative, points[:, 0])
+ y_values = np.interp(distances, cumulative, points[:, 1])
+ return np.column_stack((x_values, y_values))
+
+ @staticmethod
+ def _solve_growth_ratio(length: float, first_spacing: float,
+ segments: int) -> float:
+ if segments <= 1 or length <= 0.0 or first_spacing <= 0.0:
+ return 1.0
+
+ def series_sum(growth: float) -> float:
+ if np.isclose(growth, 1.0):
+ return first_spacing * segments
+ return first_spacing * (1.0 - growth ** segments) / (1.0 - growth)
+
+ base = series_sum(1.0)
+ if np.isclose(base, length, rtol=1.0e-10, atol=1.0e-12):
+ return 1.0
+
+ if base > length:
+ low = 1.0e-6
+ high = 1.0
+ else:
+ low = 1.0
+ high = 2.0
+ while series_sum(high) < length and high < 1.0e6:
+ high *= 2.0
+
+ for _ in range(120):
+ mid = 0.5 * (low + high)
+ value = series_sum(mid)
+ if value < length:
+ low = mid
+ else:
+ high = mid
+ return 0.5 * (low + high)
+
+ @classmethod
+ def _geometric_distances(cls, length: float, point_count: int,
+ first_spacing: float) -> np.ndarray:
+ if point_count <= 1:
+ return np.zeros(1, dtype=float)
+ if length <= 0.0:
+ return np.zeros(point_count, dtype=float)
+
+ segments = point_count - 1
+ growth = cls._solve_growth_ratio(length, first_spacing, segments)
+ distances = np.zeros(point_count, dtype=float)
+ spacing = float(first_spacing)
+ for index in range(1, point_count):
+ distances[index] = distances[index - 1] + spacing
+ spacing *= growth
+ if distances[-1] <= 0.0:
+ return np.linspace(0.0, length, point_count)
+ return distances * (length / distances[-1])
+
+ @staticmethod
+ def _compose_segments(*segments) -> np.ndarray:
+ combined = []
+ for segment in segments:
+ points = np.asarray(segment, dtype=float)
+ if points.size == 0:
+ continue
+ if points.ndim != 2 or points.shape[1] != 2:
+ raise ValueError('Boundary segments must be N x 2 point arrays.')
+ if not combined:
+ combined.append(points)
+ continue
+
+ previous = combined[-1][-1]
+ if np.allclose(points[0], previous, atol=1.0e-10):
+ combined.append(points[1:])
+ else:
+ combined.append(points)
+
+ if not combined:
+ return np.empty((0, 2), dtype=float)
+ return np.vstack(combined)
+
+ @classmethod
+ def _sample_segment_count(cls, start: np.ndarray, end: np.ndarray,
+ count: int) -> np.ndarray:
+ start = np.asarray(start, dtype=float)
+ end = np.asarray(end, dtype=float)
+ length = float(np.linalg.norm(end - start))
+ return cls._sample_segment_with_first_spacing(
+ start,
+ end,
+ count,
+ first_spacing=(length / max(1, count - 1)) if count > 1 else length,
+ )
+
+ @classmethod
+ def _sample_segment_with_first_spacing(cls, start: np.ndarray,
+ end: np.ndarray, count: int,
+ first_spacing: float) -> np.ndarray:
+ start = np.asarray(start, dtype=float)
+ end = np.asarray(end, dtype=float)
+ count = max(1, int(count))
+ if count == 1:
+ return start[np.newaxis, :]
+
+ vector = end - start
+ length = float(np.linalg.norm(vector))
+ if length <= 0.0:
+ return np.repeat(start[np.newaxis, :], count, axis=0)
+
+ distances = cls._geometric_distances(length, count, first_spacing)
+ direction = vector / length
+ return start + distances[:, np.newaxis] * direction
+
+ @classmethod
+ def _build_wake_branch(cls, start_point: np.ndarray, end_point: np.ndarray,
+ point_count: int, first_spacing: float) -> np.ndarray:
+ return cls._sample_segment_with_first_spacing(
+ start_point,
+ end_point,
+ point_count,
+ first_spacing,
+ )
+
+ @classmethod
+ def _build_wake_branches(cls, start_point: np.ndarray, end_point: np.ndarray,
+ reference_te_x: float, wake_length: float,
+ point_count: int, first_spacing: float,
+ cut_line: np.ndarray | None = None,
+ ) -> tuple[np.ndarray, np.ndarray]:
+ if cut_line is None:
+ cut_x = float(reference_te_x) + max(0.0, float(wake_length))
+ cut_upper = np.array((cut_x, 0.0), dtype=float)
+ cut_lower = np.array((cut_x, 0.0), dtype=float)
+ else:
+ cut_points = np.asarray(cut_line, dtype=float)
+ cut_upper = np.array(cut_points[0], copy=True, dtype=float)
+ cut_lower = np.array(cut_points[-1], copy=True, dtype=float)
+
+ upper = cls._build_wake_branch(
+ np.asarray(start_point, dtype=float),
+ cut_upper,
+ point_count,
+ first_spacing,
+ )
+ lower = cls._build_wake_branch(
+ np.asarray(end_point, dtype=float),
+ cut_lower,
+ point_count,
+ first_spacing,
+ )
+ return upper, lower
+
+ @classmethod
+ def _compose_inner_boundary(cls, surface_path: np.ndarray,
+ upper_wake: np.ndarray,
+ lower_wake: np.ndarray) -> np.ndarray:
+ return cls._compose_segments(
+ upper_wake[::-1],
+ surface_path,
+ lower_wake,
+ )
+
+ @classmethod
+ def _build_outer_middle_boundary(cls, start_x: float, radius: float,
+ point_count: int) -> np.ndarray:
+ start_x = max(0.5, float(start_x))
+ radius = max(1.0e-6, float(radius))
+
+ top_straight = np.array(
+ [
+ (start_x, radius),
+ (0.5, radius),
+ ],
+ dtype=float,
+ )
+ arc_angles = np.linspace(0.5 * np.pi, 1.5 * np.pi, 256)
+ arc = np.column_stack((
+ 0.5 + radius * np.cos(arc_angles),
+ radius * np.sin(arc_angles),
+ ))
+ bottom_straight = np.array(
+ [
+ (0.5, -radius),
+ (start_x, -radius),
+ ],
+ dtype=float,
+ )
+ path = np.vstack((
+ top_straight[:-1],
+ arc[:-1],
+ bottom_straight,
+ ))
+ return cls._resample_polyline_count(path, point_count)
+
+ @classmethod
+ def _build_outer_boundary(cls, reference_te_x: float, radius: float,
+ wake_length: float, upper_points: int,
+ middle_points: int, lower_points: int,
+ te_spacing: float, wake_length_ratio: float,
+ wake_start_ratio: float) -> np.ndarray:
+ cut_x = float(reference_te_x) + max(0.0, float(wake_length))
+ outer_start_x = cut_x - wake_length_ratio * max(0.0, float(wake_length))
+ outer_start_x = max(0.55, min(cut_x, outer_start_x))
+ outer_spacing = max(1.0e-6, te_spacing * wake_start_ratio)
+
+ upper = cls._sample_segment_with_first_spacing(
+ np.array((outer_start_x, radius), dtype=float),
+ np.array((cut_x, radius), dtype=float),
+ upper_points,
+ outer_spacing,
+ )[::-1]
+ middle = cls._build_outer_middle_boundary(
+ outer_start_x,
+ radius,
+ middle_points,
+ )
+ lower = cls._sample_segment_with_first_spacing(
+ np.array((outer_start_x, -radius), dtype=float),
+ np.array((cut_x, -radius), dtype=float),
+ lower_points,
+ outer_spacing,
+ )
+ return cls._compose_segments(upper, middle, lower)
+
+ @staticmethod
+ def _build_algebraic_grid(inner_boundary: np.ndarray, outer_boundary: np.ndarray,
+ point_count: int,
+ control_length: float) -> tuple[np.ndarray, np.ndarray]:
+ boundary_normals = BlockMesh.curveNormals(
+ inner_boundary[:, 0],
+ inner_boundary[:, 1],
+ closed=False,
+ )
+ distances = np.linalg.norm(outer_boundary - inner_boundary, axis=1)
+ controls = inner_boundary + (
+ np.minimum(control_length, 0.35 * np.maximum(distances, 1.0e-6))
+ )[:, np.newaxis] * boundary_normals
+
+ parameters = np.linspace(0.0, 1.0, point_count)
+ x_grid = np.empty((len(inner_boundary), point_count), dtype=float)
+ y_grid = np.empty_like(x_grid)
+
+ for index, eta in enumerate(parameters):
+ omt = 1.0 - eta
+ curve = (
+ (omt * omt) * inner_boundary +
+ 2.0 * omt * eta * controls +
+ (eta * eta) * outer_boundary
+ )
+ x_grid[:, index] = curve[:, 0]
+ y_grid[:, index] = curve[:, 1]
+
+ return x_grid, y_grid
+
+ @classmethod
+ def _build_transfinite_grid(cls, inner_boundary: np.ndarray,
+ outer_boundary: np.ndarray,
+ left_boundary, right_boundary
+ ) -> tuple[np.ndarray, np.ndarray]:
+ seed = BlockMesh(name='experimental_c_grid_seed')
+ seed.transfinite(
+ boundary=[
+ inner_boundary.tolist(),
+ np.asarray(outer_boundary, dtype=float).tolist(),
+ np.asarray(left_boundary, dtype=float).tolist(),
+ np.asarray(right_boundary, dtype=float).tolist(),
+ ]
+ )
+ return cls._ulines_to_grid(seed.getULines())
+
+ @staticmethod
+ def _smooth_grid(x_grid: np.ndarray, y_grid: np.ndarray, *,
+ iterations: int, tolerance: float,
+ relaxation: float,
+ boundary_condition: str | None = 'neumann'
+ ) -> tuple[np.ndarray, np.ndarray]:
+ if iterations <= 0:
+ return x_grid, y_grid
+ solver = EllipticSolver(x_grid, y_grid)
+ return solver.smooth(
+ iterations=iterations,
+ tolerance=tolerance,
+ boundary_condition=boundary_condition,
+ relaxation=relaxation,
+ )
+
+ @classmethod
+ def _apply_normal_spacing(cls, x_grid: np.ndarray, y_grid: np.ndarray, *,
+ first_layer: float) -> tuple[np.ndarray, np.ndarray]:
+ spaced_x = np.array(x_grid, copy=True, dtype=float)
+ spaced_y = np.array(y_grid, copy=True, dtype=float)
+ point_count = x_grid.shape[1]
+
+ for i_index in range(x_grid.shape[0]):
+ curve = np.column_stack((x_grid[i_index, :], y_grid[i_index, :]))
+ cumulative = cls._polyline_cumulative(curve)
+ total_length = cumulative[-1]
+ if total_length <= 0.0:
+ continue
+ targets = cls._geometric_distances(
+ total_length,
+ point_count,
+ min(first_layer, 0.5 * total_length),
+ )
+ sampled = cls._sample_polyline_distances(curve, targets)
+ spaced_x[i_index, :] = sampled[:, 0]
+ spaced_y[i_index, :] = sampled[:, 1]
+
+ return spaced_x, spaced_y
+
+ @classmethod
+ def _refine_outer_grid(cls, x_grid: np.ndarray, y_grid: np.ndarray,
+ settings: ExperimentalCGridSettings
+ ) -> tuple[np.ndarray, np.ndarray]:
+ x_grid, y_grid = cls._smooth_grid(
+ x_grid,
+ y_grid,
+ iterations=settings.initial_smoothing_iterations,
+ tolerance=settings.smoothing_tolerance,
+ relaxation=settings.relaxation,
+ )
+ x_grid, y_grid = cls._apply_normal_spacing(
+ x_grid,
+ y_grid,
+ first_layer=settings.first_layer_thickness,
+ )
+ x_grid, y_grid = cls._smooth_grid(
+ x_grid,
+ y_grid,
+ iterations=settings.final_smoothing_iterations,
+ tolerance=settings.smoothing_tolerance,
+ relaxation=settings.relaxation,
+ )
+ x_grid, y_grid = cls._apply_normal_spacing(
+ x_grid,
+ y_grid,
+ first_layer=settings.first_layer_thickness,
+ )
+ return x_grid, y_grid
+
+ @classmethod
+ def _build_trailing_edge_patch(cls, contour_points: np.ndarray,
+ trailing_edge_settings, *,
+ name: str) -> BlockMesh:
+ upper_te = np.asarray(contour_points[0], dtype=float)
+ lower_te = np.asarray(contour_points[-1], dtype=float)
+ count = max(2, int(trailing_edge_settings.trailing_edge_divisions) + 1)
+
+ line = cls._sample_segment_count(lower_te, upper_te, count)
+ tangents = np.diff(line, axis=0)
+ average_tangent = np.mean(tangents, axis=0)
+ normal = np.array((average_tangent[1], -average_tangent[0]), dtype=float)
+ if normal[0] < 0.0:
+ line = line[::-1]
+
+ block = BlockMesh(name=name)
+ block.addLine(line.tolist())
+ block.extrudeLine_cell_thickness(
+ line.tolist(),
+ cell_thickness=trailing_edge_settings.thickness,
+ growth=trailing_edge_settings.growth,
+ divisions=trailing_edge_settings.divisions,
+ direction=4,
+ )
+ block.distribute(direction='u', number=-1)
+ block.transfinite()
+ return block
+
+ @staticmethod
+ def _orient_line_to_start(line: np.ndarray, start_point: np.ndarray) -> np.ndarray:
+ line = np.asarray(line, dtype=float)
+ start_point = np.asarray(start_point, dtype=float)
+ if np.linalg.norm(line[0] - start_point) <= np.linalg.norm(line[-1] - start_point):
+ return line
+ return line[::-1]
+
+ @classmethod
+ def _extract_patch_side_lines(cls, te_block: BlockMesh,
+ contour_points: np.ndarray
+ ) -> tuple[np.ndarray, np.ndarray]:
+ vlines = [
+ np.asarray(vline, dtype=float)
+ for vline in te_block.getVLines()
+ ]
+ upper_side = max(vlines, key=lambda line: float(np.mean(line[:, 1])))
+ lower_side = min(vlines, key=lambda line: float(np.mean(line[:, 1])))
+ upper_side = cls._orient_line_to_start(upper_side, contour_points[0])
+ lower_side = cls._orient_line_to_start(lower_side, contour_points[-1])
+ return upper_side, lower_side
+
+ @classmethod
+ def _extract_downstream_face(cls, te_block: BlockMesh,
+ downstream_upper: np.ndarray,
+ downstream_lower: np.ndarray) -> np.ndarray:
+ downstream_face = np.asarray(te_block.getULines()[-1], dtype=float)
+ downstream_face = cls._orient_line_to_start(
+ downstream_face,
+ downstream_upper,
+ )
+ if np.linalg.norm(downstream_face[-1] - downstream_lower) > \
+ np.linalg.norm(downstream_face[0] - downstream_lower):
+ downstream_face = downstream_face[::-1]
+ return downstream_face
+
+ @classmethod
+ def _build_cut_line(cls, downstream_face: np.ndarray, *,
+ reference_te_x: float, wake_length: float,
+ first_layer: float) -> np.ndarray:
+ cut_x = float(reference_te_x) + max(0.0, float(wake_length))
+ center_y = 0.5 * float(downstream_face[0, 1] + downstream_face[-1, 1])
+ offsets = downstream_face[:, 1] - center_y
+ offset_extent = float(np.max(np.abs(offsets)))
+ if offset_extent <= 1.0e-12:
+ target_half_height = max(1.0e-6, float(first_layer))
+ cut_y = np.linspace(
+ center_y + target_half_height,
+ center_y - target_half_height,
+ len(downstream_face),
+ )
+ else:
+ target_half_height = max(
+ float(first_layer),
+ cls.cut_interface_scale * offset_extent,
+ )
+ scale = min(1.0, target_half_height / offset_extent)
+ cut_y = center_y + scale * offsets
+
+ return np.column_stack((
+ np.full(len(downstream_face), cut_x, dtype=float),
+ cut_y,
+ ))
+
+ @classmethod
+ def _build_wake_bridge_block(cls, downstream_face: np.ndarray,
+ cut_line: np.ndarray,
+ upper_wake: np.ndarray,
+ lower_wake: np.ndarray, *,
+ name: str,
+ settings: ExperimentalCGridSettings
+ ) -> BlockMesh:
+ block = BlockMesh(name=name)
+ block.transfinite(
+ boundary=[
+ downstream_face.tolist(),
+ cut_line.tolist(),
+ upper_wake.tolist(),
+ lower_wake.tolist(),
+ ]
+ )
+
+ bridge_iterations = settings.local_te_smoothing_iterations
+ if bridge_iterations > 0:
+ x_grid, y_grid = cls._ulines_to_grid(block.getULines())
+ x_grid, y_grid = cls._smooth_grid(
+ x_grid,
+ y_grid,
+ iterations=bridge_iterations,
+ tolerance=settings.smoothing_tolerance,
+ relaxation=settings.relaxation,
+ boundary_condition=None,
+ )
+ block.setUlines(cls._grid_to_ulines(x_grid, y_grid))
+ return block
+
+ @staticmethod
+ def _ulines_to_grid(ulines) -> tuple[np.ndarray, np.ndarray]:
+ ny = len(ulines)
+ nx = len(ulines[0]) if ulines else 0
+ x_grid = np.empty((nx, ny), dtype=float)
+ y_grid = np.empty_like(x_grid)
+
+ for j_index, uline in enumerate(ulines):
+ coordinates = np.asarray(uline, dtype=float)
+ x_grid[:, j_index] = coordinates[:, 0]
+ y_grid[:, j_index] = coordinates[:, 1]
+
+ return x_grid, y_grid
+
+ @classmethod
+ def _arrays_to_block(cls, x_grid: np.ndarray, y_grid: np.ndarray,
+ name: str) -> BlockMesh:
+ block = BlockMesh(name=name)
+ block.setUlines(cls._grid_to_ulines(x_grid, y_grid))
+ return block
+
+ @staticmethod
+ def _grid_to_ulines(x_grid: np.ndarray, y_grid: np.ndarray):
+ ulines = []
+ for j_index in range(x_grid.shape[1]):
+ ulines.append([
+ (float(x_grid[i_index, j_index]), float(y_grid[i_index, j_index]))
+ for i_index in range(x_grid.shape[0])
+ ])
+ return ulines
diff --git a/src/ExperimentalOGrid.py b/src/ExperimentalOGrid.py
new file mode 100644
index 0000000..2021240
--- /dev/null
+++ b/src/ExperimentalOGrid.py
@@ -0,0 +1,677 @@
+from __future__ import annotations
+
+from dataclasses import dataclass
+
+import numpy as np
+
+from BlockMesh import BlockMesh
+from Elliptic import EllipticSolver
+
+
+@dataclass(slots=True)
+class ExperimentalOGridSettings:
+ name: str = 'block_experimental_o_grid'
+ surface_points: int = 0
+ normal_divisions: int = 100
+ first_layer_thickness: float = 0.004
+ farfield_shape: str = 'wind_tunnel'
+ initial_smoothing_iterations: int = 100
+ final_smoothing_iterations: int = 20
+ smoothing_tolerance: float = 1.0e-5
+ relaxation: float = 0.6
+
+ def __post_init__(self):
+ self.surface_points = max(0, int(self.surface_points))
+ self.normal_divisions = max(4, int(self.normal_divisions))
+ self.first_layer_thickness = max(1.0e-12, float(self.first_layer_thickness))
+
+ shape = str(self.farfield_shape).strip().lower()
+ if shape in ('wt', 'windtunnel', 'wind tunnel', 'wind-tunnel'):
+ shape = 'wind_tunnel'
+ if shape not in ('circle', 'wind_tunnel'):
+ shape = 'wind_tunnel'
+ self.farfield_shape = shape
+
+ self.initial_smoothing_iterations = max(
+ 0,
+ int(self.initial_smoothing_iterations),
+ )
+ self.final_smoothing_iterations = max(
+ 0,
+ int(self.final_smoothing_iterations),
+ )
+ self.smoothing_tolerance = max(1.0e-12, float(self.smoothing_tolerance))
+ self.relaxation = float(np.clip(self.relaxation, 0.01, 1.0))
+
+
+class ExperimentalOGridGenerator:
+ """Experimental multi-block O-grid generator."""
+
+ block_suffixes = ('top', 'left', 'bottom', 'right')
+ minimum_closed_point_count = 13
+ wt_le_station = 0.10
+ wt_te_station = 0.90
+
+ def build_blocks(self, contour, *, radius: float, wake_length: float,
+ settings: ExperimentalOGridSettings,
+ trailing_edge_settings=None) -> list[BlockMesh]:
+ if settings.farfield_shape == 'circle':
+ inner_segments = self._build_circle_inner_segments(
+ contour,
+ surface_points=settings.surface_points,
+ trailing_edge_settings=trailing_edge_settings,
+ )
+ outer_segments = self._build_circle_outer_segments(
+ radius=float(radius),
+ segment_lengths=[len(segment) for segment in inner_segments],
+ )
+ else:
+ inner_segments = self._build_wind_tunnel_inner_segments(
+ contour,
+ surface_points=settings.surface_points,
+ radius=float(radius),
+ wake_length=float(wake_length),
+ trailing_edge_settings=trailing_edge_settings,
+ )
+ outer_segments = self._build_wind_tunnel_outer_segments(
+ radius=float(radius),
+ wake_length=float(wake_length),
+ segment_lengths=[len(segment) for segment in inner_segments],
+ )
+
+ blocks = []
+ for suffix, inner_segment, outer_segment in zip(
+ self.block_suffixes,
+ inner_segments,
+ outer_segments):
+ block = self._build_block(
+ inner_segment,
+ outer_segment,
+ settings=settings,
+ name=f'{settings.name}_{suffix}',
+ )
+ blocks.append(block)
+
+ return blocks
+
+ @classmethod
+ def _raw_points(cls, contour) -> np.ndarray:
+ points = np.column_stack(contour).astype(float, copy=False)
+ if points.ndim != 2 or points.shape[1] != 2 or len(points) < 4:
+ raise ValueError('Experimental O-grid requires a valid 2D contour.')
+ return points
+
+ @classmethod
+ def _te_divisions(cls, trailing_edge_settings) -> int:
+ if trailing_edge_settings is None:
+ return 3
+ return max(
+ 1,
+ int(getattr(trailing_edge_settings, 'trailing_edge_divisions', 3)),
+ )
+
+ @classmethod
+ def _prepare_closed_boundary(cls, contour, surface_points: int, *,
+ trailing_edge_settings=None) -> np.ndarray:
+ points = cls._raw_points(contour)
+ is_closed = np.allclose(points[0], points[-1], atol=1.0e-8)
+ if not is_closed:
+ te_segment = cls._sample_segment_count(
+ points[-1],
+ points[0],
+ cls._te_divisions(trailing_edge_settings) + 1,
+ )
+ points = cls._compose_segments(points, te_segment)
+ elif surface_points <= 0:
+ points[-1] = points[0]
+
+ if surface_points > 0 and surface_points != len(points):
+ points = cls._resample_polyline_count(points, surface_points)
+ points[-1] = points[0]
+
+ if len(points) < cls.minimum_closed_point_count:
+ points = cls._resample_polyline_count(
+ points,
+ cls.minimum_closed_point_count,
+ )
+ points[-1] = points[0]
+
+ if not np.allclose(points[0], points[-1], atol=1.0e-8):
+ points = np.vstack((points, points[0]))
+
+ return points
+
+ @classmethod
+ def _build_circle_inner_segments(cls, contour, *, surface_points: int,
+ trailing_edge_settings=None) -> list[np.ndarray]:
+ inner_boundary = cls._prepare_closed_boundary(
+ contour,
+ surface_points,
+ trailing_edge_settings=trailing_edge_settings,
+ )
+ return cls._split_closed_boundary(inner_boundary)
+
+ @classmethod
+ def _build_wind_tunnel_inner_segments(cls, contour, *, surface_points: int,
+ radius: float, wake_length: float,
+ trailing_edge_settings=None
+ ) -> list[np.ndarray]:
+ points = cls._raw_points(contour)
+ closed_boundary = cls._prepare_closed_boundary(
+ contour,
+ surface_points,
+ trailing_edge_settings=trailing_edge_settings,
+ )
+ total_intervals = max(4, len(closed_boundary) - 1)
+
+ upper_surface, lower_surface = cls._split_upper_lower_surfaces(points)
+ outer_lengths = cls._wind_tunnel_outer_lengths(
+ radius=float(radius),
+ wake_length=float(wake_length),
+ )
+ segment_intervals = cls._allocate_segment_intervals(
+ total_intervals,
+ outer_lengths,
+ )
+
+ dense_count = max(200, 4 * max(segment_intervals) + 1)
+ upper_dense = cls._resample_polyline_count(upper_surface, dense_count)
+ lower_dense = cls._resample_polyline_count(lower_surface, dense_count)
+
+ x_min = float(np.min(points[:, 0]))
+ x_max = float(np.max(points[:, 0]))
+ chord = max(x_max - x_min, 1.0e-9)
+ x_le_limit = x_min + cls.wt_le_station * chord
+ x_te_limit = x_min + cls.wt_te_station * chord
+
+ upper_te_index = cls._first_index_leq(upper_dense[:, 0], x_te_limit)
+ upper_le_index = cls._first_index_leq(upper_dense[:, 0], x_le_limit)
+ if upper_le_index <= upper_te_index:
+ upper_te_index = max(0, min(upper_te_index, len(upper_dense) - 3))
+ upper_le_index = min(len(upper_dense) - 2, upper_te_index + 1)
+
+ lower_le_index = cls._first_index_geq(lower_dense[:, 0], x_le_limit)
+ lower_te_index = cls._first_index_geq(lower_dense[:, 0], x_te_limit)
+ if lower_te_index <= lower_le_index:
+ lower_le_index = max(0, min(lower_le_index, len(lower_dense) - 3))
+ lower_te_index = min(len(lower_dense) - 2, lower_le_index + 1)
+
+ closure = cls._te_closure_segment(
+ lower_dense[-1],
+ upper_dense[0],
+ te_divisions=cls._te_divisions(trailing_edge_settings),
+ )
+
+ top_polyline = upper_dense[upper_te_index:upper_le_index + 1]
+ left_polyline = cls._compose_segments(
+ upper_dense[upper_le_index:],
+ lower_dense[:lower_le_index + 1],
+ )
+ bottom_polyline = lower_dense[lower_le_index:lower_te_index + 1]
+ right_polyline = cls._compose_segments(
+ lower_dense[lower_te_index:],
+ closure,
+ upper_dense[:upper_te_index + 1],
+ )
+
+ polylines = (
+ top_polyline,
+ left_polyline,
+ bottom_polyline,
+ right_polyline,
+ )
+
+ return [
+ cls._resample_polyline_count(polyline, intervals + 1)
+ for polyline, intervals in zip(polylines, segment_intervals)
+ ]
+
+ @classmethod
+ def _split_upper_lower_surfaces(cls, points: np.ndarray
+ ) -> tuple[np.ndarray, np.ndarray]:
+ is_closed = np.allclose(points[0], points[-1], atol=1.0e-8)
+ if is_closed:
+ open_points = np.asarray(points[:-1], dtype=float)
+ else:
+ open_points = np.asarray(points, dtype=float)
+
+ le_index = int(np.argmin(open_points[:, 0]))
+ upper = np.asarray(open_points[:le_index + 1], dtype=float)
+ lower = np.asarray(open_points[le_index:], dtype=float)
+ if len(upper) < 2 or len(lower) < 2:
+ raise ValueError('Experimental O-grid requires distinct upper and lower surfaces.')
+ return upper, lower
+
+ @staticmethod
+ def _first_index_leq(values: np.ndarray, target: float) -> int:
+ matches = np.flatnonzero(values <= target)
+ if matches.size:
+ return int(matches[0])
+ return len(values) - 1
+
+ @staticmethod
+ def _first_index_geq(values: np.ndarray, target: float) -> int:
+ matches = np.flatnonzero(values >= target)
+ if matches.size:
+ return int(matches[0])
+ return len(values) - 1
+
+ @classmethod
+ def _te_closure_segment(cls, lower_te: np.ndarray, upper_te: np.ndarray, *,
+ te_divisions: int) -> np.ndarray:
+ lower_te = np.asarray(lower_te, dtype=float)
+ upper_te = np.asarray(upper_te, dtype=float)
+ if np.allclose(lower_te, upper_te, atol=1.0e-10):
+ return lower_te[np.newaxis, :]
+ return cls._sample_segment_count(
+ lower_te,
+ upper_te,
+ max(2, int(te_divisions) + 1),
+ )
+
+ @staticmethod
+ def _polyline_cumulative(points: np.ndarray) -> np.ndarray:
+ if len(points) == 0:
+ return np.zeros(0, dtype=float)
+ deltas = np.diff(points, axis=0)
+ segment_lengths = np.linalg.norm(deltas, axis=1)
+ return np.concatenate(([0.0], np.cumsum(segment_lengths)))
+
+ @classmethod
+ def _resample_polyline_count(cls, points: np.ndarray, count: int) -> np.ndarray:
+ cumulative = cls._polyline_cumulative(points)
+ if len(cumulative) == 0 or np.isclose(cumulative[-1], 0.0):
+ return np.repeat(points[:1], count, axis=0)
+ targets = np.linspace(0.0, cumulative[-1], count)
+ return cls._sample_polyline_distances(points, targets)
+
+ @classmethod
+ def _sample_polyline_distances(cls, points: np.ndarray,
+ distances: np.ndarray) -> np.ndarray:
+ cumulative = cls._polyline_cumulative(points)
+ if len(cumulative) == 0 or np.isclose(cumulative[-1], 0.0):
+ return np.repeat(points[:1], len(distances), axis=0)
+ distances = np.asarray(distances, dtype=float)
+ distances = np.clip(distances, 0.0, cumulative[-1])
+ x_values = np.interp(distances, cumulative, points[:, 0])
+ y_values = np.interp(distances, cumulative, points[:, 1])
+ return np.column_stack((x_values, y_values))
+
+ @staticmethod
+ def _solve_growth_ratio(length: float, first_spacing: float,
+ segments: int) -> float:
+ if segments <= 1 or length <= 0.0 or first_spacing <= 0.0:
+ return 1.0
+
+ def series_sum(growth: float) -> float:
+ if np.isclose(growth, 1.0):
+ return first_spacing * segments
+ return first_spacing * (1.0 - growth ** segments) / (1.0 - growth)
+
+ base = series_sum(1.0)
+ if np.isclose(base, length, rtol=1.0e-10, atol=1.0e-12):
+ return 1.0
+
+ if base > length:
+ low = 1.0e-6
+ high = 1.0
+ else:
+ low = 1.0
+ high = 2.0
+ while series_sum(high) < length and high < 1.0e6:
+ high *= 2.0
+
+ for _ in range(120):
+ mid = 0.5 * (low + high)
+ value = series_sum(mid)
+ if value < length:
+ low = mid
+ else:
+ high = mid
+ return 0.5 * (low + high)
+
+ @classmethod
+ def _geometric_distances(cls, length: float, point_count: int,
+ first_spacing: float) -> np.ndarray:
+ if point_count <= 1:
+ return np.zeros(1, dtype=float)
+ if length <= 0.0:
+ return np.zeros(point_count, dtype=float)
+
+ segments = point_count - 1
+ growth = cls._solve_growth_ratio(length, first_spacing, segments)
+ distances = np.zeros(point_count, dtype=float)
+ spacing = float(first_spacing)
+ for index in range(1, point_count):
+ distances[index] = distances[index - 1] + spacing
+ spacing *= growth
+ if distances[-1] <= 0.0:
+ return np.linspace(0.0, length, point_count)
+ return distances * (length / distances[-1])
+
+ @classmethod
+ def _sample_segment_with_first_spacing(cls, start: np.ndarray,
+ end: np.ndarray, count: int,
+ first_spacing: float) -> np.ndarray:
+ start = np.asarray(start, dtype=float)
+ end = np.asarray(end, dtype=float)
+ count = max(1, int(count))
+ if count == 1:
+ return start[np.newaxis, :]
+
+ vector = end - start
+ length = float(np.linalg.norm(vector))
+ if length <= 0.0:
+ return np.repeat(start[np.newaxis, :], count, axis=0)
+
+ distances = cls._geometric_distances(length, count, first_spacing)
+ direction = vector / length
+ return start + distances[:, np.newaxis] * direction
+
+ @classmethod
+ def _sample_segment_count(cls, start: np.ndarray, end: np.ndarray,
+ count: int) -> np.ndarray:
+ start = np.asarray(start, dtype=float)
+ end = np.asarray(end, dtype=float)
+ length = float(np.linalg.norm(end - start))
+ return cls._sample_segment_with_first_spacing(
+ start,
+ end,
+ count,
+ first_spacing=(length / max(1, count - 1)) if count > 1 else length,
+ )
+
+ @staticmethod
+ def _compose_segments(*segments) -> np.ndarray:
+ combined = []
+ for segment in segments:
+ points = np.asarray(segment, dtype=float)
+ if points.size == 0:
+ continue
+ if points.ndim != 2 or points.shape[1] != 2:
+ raise ValueError('Boundary segments must be N x 2 point arrays.')
+ if not combined:
+ combined.append(points)
+ continue
+
+ previous = combined[-1][-1]
+ if np.allclose(points[0], previous, atol=1.0e-10):
+ combined.append(points[1:])
+ else:
+ combined.append(points)
+
+ if not combined:
+ return np.empty((0, 2), dtype=float)
+ return np.vstack(combined)
+
+ @classmethod
+ def _split_closed_boundary(cls, boundary: np.ndarray) -> list[np.ndarray]:
+ if len(boundary) < 5:
+ raise ValueError('Experimental O-grid requires at least 4 contour points.')
+ if not np.allclose(boundary[0], boundary[-1], atol=1.0e-8):
+ raise ValueError('Experimental O-grid requires a closed inner boundary.')
+
+ cumulative = cls._polyline_cumulative(boundary)
+ total_length = cumulative[-1]
+ unique_count = len(boundary) - 1
+ if unique_count < 4:
+ raise ValueError('Experimental O-grid requires at least 4 contour points.')
+
+ indices = []
+ previous = 0
+ targets = (0.25, 0.50, 0.75)
+ for offset, fraction in enumerate(targets, start=1):
+ index = int(np.searchsorted(cumulative, fraction * total_length))
+ minimum = previous + 1
+ maximum = unique_count - (len(targets) - offset + 1)
+ index = max(minimum, min(index, maximum))
+ indices.append(index)
+ previous = index
+
+ q1, q2, q3 = indices
+ segments = [
+ np.asarray(boundary[0:q1 + 1], dtype=float),
+ np.asarray(boundary[q1:q2 + 1], dtype=float),
+ np.asarray(boundary[q2:q3 + 1], dtype=float),
+ np.vstack((boundary[q3:-1], boundary[0])),
+ ]
+
+ for segment in segments:
+ if len(segment) < 2:
+ raise ValueError('Experimental O-grid produced an invalid boundary split.')
+
+ return segments
+
+ @staticmethod
+ def _allocate_segment_intervals(total_intervals: int,
+ lengths: tuple[float, ...] | list[float]
+ ) -> list[int]:
+ weights = np.asarray(lengths, dtype=float)
+ weights = np.maximum(weights, 1.0e-12)
+ total_intervals = max(len(weights), int(total_intervals))
+
+ base = np.ones(len(weights), dtype=int)
+ remaining = total_intervals - int(np.sum(base))
+ if remaining <= 0:
+ return base.tolist()
+
+ scaled = remaining * weights / float(np.sum(weights))
+ additions = np.floor(scaled).astype(int)
+ base += additions
+ difference = remaining - int(np.sum(additions))
+
+ if difference > 0:
+ fractions = scaled - additions
+ order = np.argsort(fractions)[::-1]
+ for index in range(difference):
+ base[order[index % len(order)]] += 1
+
+ return base.tolist()
+
+ @staticmethod
+ def _wind_tunnel_outer_lengths(*, radius: float,
+ wake_length: float) -> tuple[float, float, float, float]:
+ horizontal = max(1.0e-6, 1.0 + max(0.0, float(wake_length)))
+ left_arc = max(1.0e-6, np.pi * max(1.0e-6, float(radius)))
+ right_height = max(1.0e-6, 2.0 * max(1.0e-6, float(radius)))
+ return horizontal, left_arc, horizontal, right_height
+
+ @classmethod
+ def _build_wind_tunnel_outer_segments(cls, *, radius: float,
+ wake_length: float,
+ segment_lengths: list[int]
+ ) -> list[np.ndarray]:
+ if len(segment_lengths) != 4:
+ raise ValueError('Experimental O-grid expects four segment lengths.')
+
+ x_right = 1.0 + max(0.0, float(wake_length))
+ top_right = np.array((x_right, radius), dtype=float)
+ top_left = np.array((0.0, radius), dtype=float)
+ bottom_left = np.array((0.0, -radius), dtype=float)
+ bottom_right = np.array((x_right, -radius), dtype=float)
+
+ return [
+ cls._sample_segment_count(top_right, top_left, segment_lengths[0]),
+ cls._sample_half_circle(
+ center=np.array((0.0, 0.0), dtype=float),
+ radius=radius,
+ start_angle=0.5 * np.pi,
+ end_angle=1.5 * np.pi,
+ count=segment_lengths[1],
+ ),
+ cls._sample_segment_count(bottom_left, bottom_right, segment_lengths[2]),
+ cls._sample_segment_count(bottom_right, top_right, segment_lengths[3]),
+ ]
+
+ @classmethod
+ def _build_circle_outer_segments(cls, *, radius: float,
+ segment_lengths: list[int]) -> list[np.ndarray]:
+ if len(segment_lengths) != 4:
+ raise ValueError('Experimental O-grid expects four segment lengths.')
+
+ center = np.array((0.5, 0.0), dtype=float)
+ return [
+ cls._sample_half_circle(
+ center=center,
+ radius=radius,
+ start_angle=0.0,
+ end_angle=0.5 * np.pi,
+ count=segment_lengths[0],
+ ),
+ cls._sample_half_circle(
+ center=center,
+ radius=radius,
+ start_angle=0.5 * np.pi,
+ end_angle=np.pi,
+ count=segment_lengths[1],
+ ),
+ cls._sample_half_circle(
+ center=center,
+ radius=radius,
+ start_angle=np.pi,
+ end_angle=1.5 * np.pi,
+ count=segment_lengths[2],
+ ),
+ cls._sample_half_circle(
+ center=center,
+ radius=radius,
+ start_angle=1.5 * np.pi,
+ end_angle=2.0 * np.pi,
+ count=segment_lengths[3],
+ ),
+ ]
+
+ @staticmethod
+ def _sample_half_circle(center: np.ndarray, radius: float,
+ start_angle: float, end_angle: float,
+ count: int) -> np.ndarray:
+ count = max(2, int(count))
+ angles = np.linspace(start_angle, end_angle, count)
+ center = np.asarray(center, dtype=float)
+ return np.column_stack((
+ center[0] + radius * np.cos(angles),
+ center[1] + radius * np.sin(angles),
+ ))
+
+ @classmethod
+ def _build_block(cls, inner_segment: np.ndarray, outer_segment: np.ndarray, *,
+ settings: ExperimentalOGridSettings, name: str) -> BlockMesh:
+ left_boundary = cls._sample_segment_with_first_spacing(
+ inner_segment[0],
+ outer_segment[0],
+ settings.normal_divisions + 1,
+ settings.first_layer_thickness,
+ )
+ right_boundary = cls._sample_segment_with_first_spacing(
+ inner_segment[-1],
+ outer_segment[-1],
+ settings.normal_divisions + 1,
+ settings.first_layer_thickness,
+ )
+
+ block = BlockMesh(name=name)
+ block.transfinite(
+ boundary=[
+ inner_segment.tolist(),
+ outer_segment.tolist(),
+ left_boundary.tolist(),
+ right_boundary.tolist(),
+ ]
+ )
+
+ x_grid, y_grid = cls._ulines_to_grid(block.getULines())
+ x_grid, y_grid = cls._refine_block(x_grid, y_grid, settings)
+ block.setUlines(cls._grid_to_ulines(x_grid, y_grid))
+ return block
+
+ @classmethod
+ def _refine_block(cls, x_grid: np.ndarray, y_grid: np.ndarray,
+ settings: ExperimentalOGridSettings
+ ) -> tuple[np.ndarray, np.ndarray]:
+ x_grid, y_grid = cls._smooth_grid(
+ x_grid,
+ y_grid,
+ iterations=settings.initial_smoothing_iterations,
+ tolerance=settings.smoothing_tolerance,
+ relaxation=settings.relaxation,
+ )
+ x_grid, y_grid = cls._apply_normal_spacing(
+ x_grid,
+ y_grid,
+ first_layer=settings.first_layer_thickness,
+ )
+ x_grid, y_grid = cls._smooth_grid(
+ x_grid,
+ y_grid,
+ iterations=settings.final_smoothing_iterations,
+ tolerance=settings.smoothing_tolerance,
+ relaxation=settings.relaxation,
+ )
+ x_grid, y_grid = cls._apply_normal_spacing(
+ x_grid,
+ y_grid,
+ first_layer=settings.first_layer_thickness,
+ )
+ return x_grid, y_grid
+
+ @staticmethod
+ def _smooth_grid(x_grid: np.ndarray, y_grid: np.ndarray, *,
+ iterations: int, tolerance: float,
+ relaxation: float) -> tuple[np.ndarray, np.ndarray]:
+ if iterations <= 0:
+ return x_grid, y_grid
+ solver = EllipticSolver(x_grid, y_grid)
+ return solver.smooth(
+ iterations=iterations,
+ tolerance=tolerance,
+ boundary_condition='neumann',
+ relaxation=relaxation,
+ )
+
+ @classmethod
+ def _apply_normal_spacing(cls, x_grid: np.ndarray, y_grid: np.ndarray, *,
+ first_layer: float) -> tuple[np.ndarray, np.ndarray]:
+ spaced_x = np.array(x_grid, copy=True, dtype=float)
+ spaced_y = np.array(y_grid, copy=True, dtype=float)
+ point_count = x_grid.shape[1]
+
+ for i_index in range(x_grid.shape[0]):
+ curve = np.column_stack((x_grid[i_index, :], y_grid[i_index, :]))
+ cumulative = cls._polyline_cumulative(curve)
+ total_length = cumulative[-1]
+ if total_length <= 0.0:
+ continue
+ targets = cls._geometric_distances(
+ total_length,
+ point_count,
+ min(first_layer, 0.5 * total_length),
+ )
+ sampled = cls._sample_polyline_distances(curve, targets)
+ spaced_x[i_index, :] = sampled[:, 0]
+ spaced_y[i_index, :] = sampled[:, 1]
+
+ return spaced_x, spaced_y
+
+ @staticmethod
+ def _ulines_to_grid(ulines) -> tuple[np.ndarray, np.ndarray]:
+ ny = len(ulines)
+ nx = len(ulines[0]) if ulines else 0
+ x_grid = np.empty((nx, ny), dtype=float)
+ y_grid = np.empty_like(x_grid)
+
+ for j_index, uline in enumerate(ulines):
+ coordinates = np.asarray(uline, dtype=float)
+ x_grid[:, j_index] = coordinates[:, 0]
+ y_grid[:, j_index] = coordinates[:, 1]
+
+ return x_grid, y_grid
+
+ @staticmethod
+ def _grid_to_ulines(x_grid: np.ndarray, y_grid: np.ndarray):
+ ulines = []
+ for j_index in range(x_grid.shape[1]):
+ ulines.append([
+ (float(x_grid[i_index, j_index]), float(y_grid[i_index, j_index]))
+ for i_index in range(x_grid.shape[0])
+ ])
+ return ulines
diff --git a/src/FileDialog.py b/src/FileDialog.py
index a6ce7d8..5c27097 100644
--- a/src/FileDialog.py
+++ b/src/FileDialog.py
@@ -1,56 +1,124 @@
-
-import os
-
-from Settings import DIALOGFILTER, OUTPUTDATA, AIRFOILDATA
-
-from PySide6 import QtWidgets
-
-
-class Dialog:
-
- def __init__(self, filter=DIALOGFILTER):
-
- self.names = []
- # DIALOGFILTER = 'Airfoil contour files (*.dat *.txt)'
- self.filter = filter
-
- def saveFilename(self, filename=None):
- """Summary
-
- Args:
- filename (None, optional): If given, then it is displayed as
- default value in the dialog
-
- Returns:
- string: filename inlcuding path to filename
- string: filter which was selected
- """
- path = os.path.join(OUTPUTDATA, filename)
- filename, selected_filter = QtWidgets.QFileDialog.getSaveFileName(
- None,
- 'Save File As',
- path,
- self.filter,
- selectedFilter='*')
-
- return filename, selected_filter
-
- def openFilename(self, directory=AIRFOILDATA):
- """Summary
-
-
- Returns:
- string: filename inlcuding path to filename
- string: filter which was selected
- """
- filename, selected_filter = QtWidgets.QFileDialog.getOpenFileName(
- None,
- 'Open File',
- directory,
- self.filter,
- '')
-
- return filename, selected_filter
-
- def setFilter(self, filter):
- self.filter = filter
+import os
+
+from PySide6 import QtWidgets
+
+from Utils import get_main_window
+
+class Dialog:
+
+ def __init__(self, mainwindow=None, parent_widget=None):
+
+ # get MainWindow instance (overcomes handling parents)
+ self.mw = mainwindow or get_main_window()
+ self.parent_widget = parent_widget or self.mw
+
+ self.names = []
+
+ # DIALOG_FILTER = 'Airfoil contour files (*.dat *.txt)'
+ self.filter = self.mw.DIALOG_FILTER
+
+ def _dialog_options(self):
+ options = QtWidgets.QFileDialog.Options()
+ if getattr(self.mw, 'platform', '') == 'Darwin':
+ # Use the Qt dialog on macOS to avoid the recurring NSOpenPanel
+ # warning path from the native file dialog wrapper.
+ options |= QtWidgets.QFileDialog.DontUseNativeDialog
+ return options
+
+ def _directory_for(self, role, directory, fallback):
+ if directory:
+ if os.path.isdir(directory):
+ return directory
+ parent = os.path.dirname(directory)
+ if parent:
+ return parent
+
+ last_directory = getattr(self.mw, f'_last_{role}_directory', '')
+ if last_directory and os.path.isdir(last_directory):
+ return last_directory
+ return fallback
+
+ def _update_last_directory(self, role, path):
+ if not path:
+ return
+ if os.path.isdir(path):
+ directory = path
+ else:
+ directory = os.path.dirname(path)
+ if directory:
+ setattr(self.mw, f'_last_{role}_directory', directory)
+
+ def save_filename(self, filename=None, directory=None, title='Save File As',
+ filter=None):
+ """Summary
+
+ Args:
+ filename (None, optional): If given, then it is displayed as
+ default value in the dialog
+
+ Returns:
+ string: filename inlcuding path to filename
+ string: filter which was selected
+ """
+ dialog_filter = filter or self.filter
+ base_directory = self._directory_for(
+ 'save',
+ directory,
+ self.mw.OUTPUT,
+ )
+ path = base_directory
+ if filename:
+ path = os.path.join(base_directory, filename)
+ filename, selected_filter = QtWidgets.QFileDialog.getSaveFileName(
+ self.parent_widget,
+ title,
+ path,
+ dialog_filter,
+ options=self._dialog_options(),
+ )
+ self._update_last_directory('save', filename)
+
+ return filename, selected_filter
+
+ def open_filename(self, directory=None, title='Open File', filter=None):
+ """Summary
+
+
+ Returns:
+ string: filename inlcuding path to filename
+ string: filter which was selected
+ """
+ dialog_filter = filter or self.filter
+ base_directory = self._directory_for(
+ 'open',
+ directory,
+ self.mw.AIRFOILS,
+ )
+ filename, selected_filter = QtWidgets.QFileDialog.getOpenFileName(
+ self.parent_widget,
+ title,
+ base_directory,
+ dialog_filter,
+ options=self._dialog_options(),
+ )
+ self._update_last_directory('open', filename)
+
+ return filename, selected_filter
+
+ def choose_directory(self, directory=None, title='Select Folder'):
+ base_directory = self._directory_for(
+ 'save',
+ directory,
+ self.mw.OUTPUT,
+ )
+ folder = QtWidgets.QFileDialog.getExistingDirectory(
+ self.parent_widget,
+ title,
+ base_directory,
+ options=self._dialog_options(),
+ )
+ self._update_last_directory('save', folder)
+ return folder
+
+ def setFilter(self, filter):
+ self.filter = filter
diff --git a/src/FileOperations.py b/src/FileOperations.py
new file mode 100644
index 0000000..014f8bf
--- /dev/null
+++ b/src/FileOperations.py
@@ -0,0 +1,310 @@
+import csv
+import json
+import logging
+import os
+import re
+
+from CSTAirfoil import cst_parameters_from_spline_data
+import FileDialog
+import Mesh as MeshModel
+from Utils import get_main_window
+
+
+logger = logging.getLogger(__name__)
+
+
+CONTOUR_FILTER = 'Airfoil contour files (*.dat *.txt)'
+CAMBER_FILTER = 'Camber files (*.dat *.txt)'
+CST_FILTER = 'JSON files (*.json);;CSV files (*.csv)'
+SUPPORTED_AIRFOIL_EXTENSIONS = ('.dat', '.txt')
+
+
+def _selected_extension(selected_filter, fallback):
+ matches = re.findall(r'\*\.([A-Za-z0-9]+)', selected_filter or '')
+ if matches:
+ return f'.{matches[0]}'
+ return fallback
+
+
+def _default_contour_location(airfoil, mainwindow=None):
+ mw = mainwindow or get_main_window()
+ default_directory = mw.OUTPUT
+ source_path = getattr(airfoil, 'source_path', None)
+ if source_path:
+ default_directory = os.path.dirname(source_path)
+
+ default_name = getattr(airfoil, 'name', '') or 'airfoil.dat'
+ return default_directory, default_name
+
+
+def choose_contour_save_filename(airfoil, title='Save Contour As',
+ mainwindow=None):
+ _directory, default_name = _default_contour_location(airfoil, mainwindow)
+ return choose_data_save_filename(
+ airfoil,
+ default_name=default_name,
+ title=title,
+ filter=CONTOUR_FILTER,
+ mainwindow=mainwindow,
+ )
+
+
+def choose_data_save_filename(airfoil, default_name, title='Save File As',
+ filter=None, mainwindow=None):
+ mw = mainwindow or get_main_window()
+ directory, _ = _default_contour_location(airfoil, mw)
+ dialog = FileDialog.Dialog(mainwindow=mw)
+ filename, selected_filter = dialog.save_filename(
+ filename=default_name,
+ directory=directory,
+ title=title,
+ filter=filter or CONTOUR_FILTER,
+ )
+ if not filename:
+ return None
+
+ default_extension = os.path.splitext(default_name)[1] or '.dat'
+ if not os.path.splitext(filename)[1]:
+ filename += _selected_extension(selected_filter, default_extension)
+ return filename
+
+
+def choose_camber_save_filename(airfoil, title='Export Camber',
+ mainwindow=None):
+ _directory, default_name = _default_contour_location(airfoil, mainwindow)
+ basename, _extension = os.path.splitext(default_name)
+ basename = basename or 'airfoil'
+ return choose_data_save_filename(
+ airfoil,
+ default_name=f'{basename}_camber.dat',
+ title=title,
+ filter=CAMBER_FILTER,
+ mainwindow=mainwindow,
+ )
+
+
+def choose_cst_save_filename(airfoil, title='Export CST Parameters',
+ default_extension='.json', mainwindow=None):
+ _directory, default_name = _default_contour_location(airfoil, mainwindow)
+ basename, _extension = os.path.splitext(default_name)
+ basename = basename or 'airfoil'
+ extension = default_extension if default_extension in ('.json', '.csv') else '.json'
+ return choose_data_save_filename(
+ airfoil,
+ default_name=f'{basename}_cst{extension}',
+ title=title,
+ filter=CST_FILTER,
+ mainwindow=mainwindow,
+ )
+
+
+def choose_mesh_export_basename(airfoil, formats, title='Export Mesh Files',
+ mainwindow=None):
+ mw = mainwindow or get_main_window()
+ directory, default_name = _default_contour_location(airfoil, mw)
+ basename, _ = os.path.splitext(default_name)
+ basename = basename or 'mesh'
+
+ extensions = [
+ MeshModel.MeshExportRegistry.extension_for(mesh_format)
+ for mesh_format in formats
+ ]
+ filter_extensions = ' '.join(f'*{extension}' for extension in extensions)
+ dialog_filter = (
+ f'Mesh files ({filter_extensions})'
+ if filter_extensions else
+ 'Mesh files (*)'
+ )
+
+ dialog = FileDialog.Dialog(mainwindow=mw)
+ filename, _ = dialog.save_filename(
+ filename=basename,
+ directory=directory,
+ title=title,
+ filter=dialog_filter,
+ )
+ if not filename:
+ return None
+
+ root, extension = os.path.splitext(filename)
+ if extension.lower() in {item.lower() for item in extensions}:
+ return root
+ return filename
+
+
+def report_io_error(action, filename, error, mainwindow=None):
+ mw = mainwindow or get_main_window()
+ logger.error('Failed to %s %s: %s', action, filename, error, exc_info=True)
+ message = f'Failed to {action}:\n{filename}\n\n{error}'
+ if hasattr(mw, 'slots'):
+ mw.slots.messageBox(message)
+ return None
+
+
+def write_contour(airfoil, filename, prefer_spline=True, mainwindow=None):
+ mw = mainwindow or get_main_window()
+ contour = airfoil.current_contour(prefer_spline=prefer_spline)
+ if contour is None:
+ if hasattr(mw, 'slots'):
+ mw.slots.messageBox('No airfoil contour available to save.')
+ return None
+
+ try:
+ import PyAero
+ except ImportError:
+ PyAero = None
+
+ x_values, y_values = contour
+ if prefer_spline and airfoil.has_spline:
+ metadata = getattr(airfoil.spline_data, 'metadata', {}) or {}
+ contour_type = metadata.get('label', 'prepared contour')
+ else:
+ contour_type = 'raw'
+ source_name = os.path.basename(
+ getattr(airfoil, 'source_path', '') or airfoil.name
+ ).strip()
+
+ try:
+ with open(filename, 'w', encoding='utf-8') as handle:
+ handle.write('#\n')
+ if PyAero is not None:
+ handle.write(f'# File created with {PyAero.__appname__}\n')
+ handle.write(f'# Version: {PyAero.__version__}\n')
+ handle.write(f'# Author: {PyAero.__author__}\n')
+ handle.write('#\n')
+ handle.write(f'# Derived from: {source_name}\n')
+ handle.write(f'# Contour type: {contour_type}\n')
+ handle.write(f'# Number of points: {len(x_values)}\n')
+ handle.write('#\n')
+ for x_value, y_value in zip(x_values, y_values):
+ handle.write(f'{x_value:10.6f} {y_value:10.6f}\n')
+ except OSError as error:
+ return report_io_error('save contour', filename, error, mainwindow=mw)
+
+ logger.info('Contour saved as %s', filename)
+ return filename
+
+
+def write_camber(airfoil, filename, mainwindow=None):
+ mw = mainwindow or get_main_window()
+ camber_data = getattr(airfoil, 'camber_data', None)
+ if camber_data is None:
+ if hasattr(mw, 'slots'):
+ mw.slots.messageBox('No camber data available to save.')
+ return None
+
+ try:
+ import PyAero
+ except ImportError:
+ PyAero = None
+
+ x_values, y_values = camber_data.polyline_coordinates(
+ start_at_le_tangency=True
+ )
+ source_name = os.path.basename(
+ getattr(airfoil, 'source_path', '') or airfoil.name
+ ).strip()
+
+ try:
+ with open(filename, 'w', encoding='utf-8') as handle:
+ handle.write('#\n')
+ if PyAero is not None:
+ handle.write(f'# File created with {PyAero.__appname__}\n')
+ handle.write(f'# Version: {PyAero.__version__}\n')
+ handle.write(f'# Author: {PyAero.__author__}\n')
+ handle.write('#\n')
+ handle.write(f'# Derived from: {source_name}\n')
+ handle.write(f'# Camber method: {camber_data.method}\n')
+ handle.write(f'# Number of points: {len(x_values)}\n')
+ handle.write('#\n')
+ for x_value, y_value in zip(x_values, y_values):
+ handle.write(f'{x_value:10.6f} {y_value:10.6f}\n')
+ except OSError as error:
+ return report_io_error('save camber', filename, error, mainwindow=mw)
+
+ logger.info('Camber saved as %s', filename)
+ return filename
+
+
+def write_cst_parameters(airfoil, filename, mainwindow=None):
+ mw = mainwindow or get_main_window()
+ spline_data = getattr(airfoil, 'spline_data', None)
+ if spline_data is None:
+ if hasattr(mw, 'slots'):
+ mw.slots.messageBox('No prepared contour available to export.')
+ return None
+
+ try:
+ parameter_data = cst_parameters_from_spline_data(spline_data)
+ except ValueError as error:
+ if hasattr(mw, 'slots'):
+ mw.slots.messageBox(str(error))
+ return None
+
+ source_name = os.path.basename(
+ getattr(airfoil, 'source_path', '') or airfoil.name
+ ).strip()
+ payload = {
+ 'derived_from': source_name,
+ 'leading_edge_parameter': parameter_data['leading_edge_parameter'],
+ 'upper_surface': parameter_data['upper_surface'],
+ 'lower_surface': parameter_data['lower_surface'],
+ }
+
+ extension = os.path.splitext(filename)[1].lower()
+ if extension == '.json':
+ try:
+ with open(filename, 'w', encoding='utf-8') as handle:
+ json.dump(payload, handle, indent=2)
+ handle.write('\n')
+ except OSError as error:
+ return report_io_error(
+ 'save CST parameters',
+ filename,
+ error,
+ mainwindow=mw,
+ )
+ logger.info('CST parameters saved as %s', filename)
+ return filename
+
+ if extension == '.csv':
+ rows = [
+ ('global', 'derived_from', '', source_name),
+ (
+ 'global',
+ 'leading_edge_parameter',
+ '',
+ payload['leading_edge_parameter'],
+ ),
+ ]
+ for surface_key in ('upper_surface', 'lower_surface'):
+ surface = payload[surface_key]
+ name = surface['name']
+ rows.append((name, 'order', '', surface['order']))
+ rows.append(
+ (name, 'nose_coefficient', '', surface['nose_coefficient'])
+ )
+ rows.append(
+ (name, 'trailing_edge_offset', '', surface['trailing_edge_offset'])
+ )
+ for index, value in enumerate(surface['coefficients']):
+ rows.append((name, 'coefficient', index, value))
+
+ try:
+ with open(filename, 'w', encoding='utf-8', newline='') as handle:
+ writer = csv.writer(handle)
+ writer.writerow(('section', 'name', 'index', 'value'))
+ writer.writerows(rows)
+ except OSError as error:
+ return report_io_error(
+ 'save CST parameters',
+ filename,
+ error,
+ mainwindow=mw,
+ )
+ logger.info('CST parameters saved as %s', filename)
+ return filename
+
+ if hasattr(mw, 'slots'):
+ mw.slots.messageBox('Unsupported CST export format. Use .json or .csv.')
+ return None
diff --git a/src/FileSystem.py b/src/FileSystem.py
index fd41522..6057951 100644
--- a/src/FileSystem.py
+++ b/src/FileSystem.py
@@ -1,31 +1,141 @@
-import os
-
-from PySide6 import QtGui, QtCore, QtWidgets
-
-from Settings import AIRFOILDATA, FILEFILTER, ICONS_L
-
-import logging
-logger = logging.getLogger(__name__)
-
-
-class FileSystemModel(QtWidgets.QFileSystemModel):
+import os
+from dataclasses import dataclass
+
+from PySide6 import QtGui, QtCore, QtWidgets
+
+import Icons
+
+from Utils import get_main_window
+import logging
+logger = logging.getLogger(__name__)
+
+
+AIRFOIL_LIBRARY_EXTENSIONS = ('.dat', '.txt')
+
+
+@dataclass(frozen=True)
+class AirfoilLibraryEntry:
+ name: str
+ path: str
+ relative_path: str
+ collection: str
+ source: str
+ source_label: str
+
+
+def library_root(mainwindow=None):
+ mw = mainwindow or get_main_window()
+ return os.path.abspath(mw.AIRFOILS)
+
+
+def local_library_root(mainwindow=None):
+ root = os.path.join(library_root(mainwindow), 'Local')
+ os.makedirs(root, exist_ok=True)
+ return root
+
+
+def _iter_library_files(root_path):
+ for current_root, dirnames, filenames in os.walk(root_path):
+ dirnames[:] = sorted(dirnames, key=str.lower)
+ for filename in sorted(filenames, key=str.lower):
+ _, extension = os.path.splitext(filename)
+ if extension.lower() not in AIRFOIL_LIBRARY_EXTENSIONS:
+ continue
+ yield current_root, filename
+
+
+def list_airfoil_library_entries(source='bundled', mainwindow=None):
+ bundled_root = library_root(mainwindow)
+ local_root = local_library_root(mainwindow)
+ entries = []
+
+ if source in ('bundled', 'all'):
+ for current_root, filename in _iter_library_files(bundled_root):
+ current_root_abs = os.path.abspath(current_root)
+ if (
+ current_root_abs == local_root or
+ current_root_abs.startswith(local_root + os.sep)
+ ):
+ continue
+ path = os.path.join(current_root, filename)
+ relative_path = os.path.relpath(path, bundled_root)
+ collection = os.path.dirname(relative_path) or 'Bundled'
+ entries.append(
+ AirfoilLibraryEntry(
+ name=filename,
+ path=path,
+ relative_path=relative_path,
+ collection=collection,
+ source='bundled',
+ source_label='Bundled',
+ )
+ )
+
+ if source in ('local', 'all'):
+ for current_root, filename in _iter_library_files(local_root):
+ path = os.path.join(current_root, filename)
+ relative_path = os.path.relpath(path, local_root)
+ collection = os.path.dirname(relative_path) or 'Local'
+ entries.append(
+ AirfoilLibraryEntry(
+ name=filename,
+ path=path,
+ relative_path=relative_path,
+ collection=collection,
+ source='local',
+ source_label='Local',
+ )
+ )
+
+ return sorted(
+ entries,
+ key=lambda entry: (
+ entry.source_label.lower(),
+ entry.name.lower(),
+ entry.relative_path.lower(),
+ ),
+ )
+
+
+def describe_airfoil_source(path, mainwindow=None):
+ if not path:
+ return ''
+
+ airfoil_path = os.path.abspath(path)
+ bundled_root = library_root(mainwindow)
+ local_root = local_library_root(mainwindow)
+
+ if airfoil_path.startswith(local_root + os.sep) or airfoil_path == local_root:
+ relative_path = os.path.relpath(airfoil_path, local_root)
+ return f'Local library: {relative_path}'
+
+ if airfoil_path.startswith(bundled_root + os.sep) or airfoil_path == bundled_root:
+ relative_path = os.path.relpath(airfoil_path, bundled_root)
+ return f'Bundled library: {relative_path}'
+
+ return f'External file: {os.path.basename(airfoil_path)}'
+
+
+class FileSystemModel(QtWidgets.QFileSystemModel):
def __init__(self):
super().__init__()
+ self.mw = get_main_window()
+
self.setFilter(QtCore.QDir.AllDirs |
QtCore.QDir.Files |
QtCore.QDir.NoDotAndDotDot)
- self.setNameFilters(FILEFILTER)
+ self.setNameFilters(self.mw.FILE_FILTER)
# if true, filtered files are shown, but grey
# if false they are not shown
self.setNameFilterDisables(False)
# get MainWindow instance (overcomes handling parents)
- self.mainwindow = QtCore.QCoreApplication.instance().mainwindow
+ self.mw = get_main_window()
# set path for FileSytemModel to location of airfoil data
- path = os.path.abspath(AIRFOILDATA)
+ path = os.path.abspath(self.mw.AIRFOILS)
self.setRootPath(path)
# inherited from QAbstractItemModel
@@ -36,15 +146,15 @@ def data(self, index, role):
"""
fileInfo = self.getFileInfo(index)[4]
-
- if role == QtCore.Qt.DecorationRole:
- if fileInfo.isDir():
- return QtGui.QPixmap(os.path.join(ICONS_L, 'Folder.png'))
- elif fileInfo.isFile():
- return QtGui.QPixmap(os.path.join(ICONS_L, 'airfoil.png'))
-
- # return QtWidgets.QFileSystemModel.data(self, index, role)
- return super().data(index, role)
+
+ if role == QtCore.Qt.DecorationRole:
+ if fileInfo.isDir():
+ return Icons.pixmap('folder', 20)
+ elif fileInfo.isFile():
+ return Icons.pixmap('airfoil', 20)
+
+ # return QtWidgets.QFileSystemModel.data(self, index, role)
+ return super().data(index, role)
# @QtCore.Slot(QtCore.QModelIndex)
def onFileSelected(self, index):
@@ -56,13 +166,13 @@ def onFileSelected(self, index):
logger.info('Airfoil {} selected'.format(name))
# @QtCore.Slot(QtCore.QModelIndex)
- def onFileLoad(self, index):
- fileInfo = self.getFileInfo(index)[4]
- if fileInfo.isDir():
- return
-
- fullname = self.getFileInfo(index)[2]
- self.mainwindow.slots.loadAirfoil(fullname, comment='#')
+ def onFileLoad(self, index):
+ fileInfo = self.getFileInfo(index)[4]
+ if fileInfo.isDir():
+ return
+
+ fullname = self.getFileInfo(index)[2]
+ self.mw.slots.openFile(fullname)
def getFileInfo(self, index):
fileInfo = self.fileInfo(index)
diff --git a/src/GraphicsItem.py b/src/GraphicsItem.py
index c057928..8a1951d 100644
--- a/src/GraphicsItem.py
+++ b/src/GraphicsItem.py
@@ -1,5 +1,6 @@
from PySide6 import QtGui, QtCore, QtWidgets
+from Utils import get_main_window
class GraphicsItem(QtWidgets.QGraphicsItem):
"""
@@ -18,10 +19,10 @@ def __init__(self, item):
"""
super().__init__()
- # get MainWindow instance (overcomes handling parents)
- self.mainwindow = QtCore.QCoreApplication.instance().mainwindow
-
- self.scene = self.mainwindow.scene
+ # get MainWindow instance (overcomes handling parents)
+ self.mw = get_main_window()
+
+ self.graphics_scene = self.mw.scene
self.setFlag(QtWidgets.QGraphicsItem.ItemIsMovable, False)
self.setFlag(QtWidgets.QGraphicsItem.ItemIsSelectable, False)
@@ -34,15 +35,15 @@ def __init__(self, item):
self.setAcceptHoverEvents(True)
- # method of QPainter
- self.method = item.method
- self.args = item.args
- self.pen = item.pen
- self.penwidth = item.pen.width()
- self.brush = item.brush
- self.rect = QtCore.QRectF(item.rect)
- self.setToolTip(item.tooltip)
- self.scale = item.scale
+ # method of QPainter
+ self.method = item.method
+ self.args = item.args
+ self.pen = item.pen
+ self.penwidth = item.pen.widthF()
+ self.brush = item.brush
+ self.rect = QtCore.QRectF(item.rect)
+ self.setToolTip(item.tooltip)
+ self.scale = item.scale
self.font = item.font
self.item_shape = item.shape
self.hoverstyle = QtCore.Qt.SolidLine
@@ -53,41 +54,13 @@ def __init__(self, item):
# initialize bounding rectangle (including penwidth)
self.setBoundingRect()
- def itemChange(self, change, value):
- if change == QtWidgets.QGraphicsItem.ItemSelectedHasChanged:
- # when selecting an airfoil item in the graphics view
- # select the respective item in Toolbox/MyListWidget
- # which contains a list of loaded airfoils
-
- if not hasattr(self, 'name'):
- # return if no name, e.g. when test items are loaded
- # from Graphicstest. These are not listed in MyListWidget
- return
-
- centralwidget = self.mainwindow.centralwidget
- itms = centralwidget.toolbox.listwidget. \
- findItems(self.name, QtCore.Qt.MatchExactly)
-
- if self.isSelected():
- for itm in itms:
- itm.setSelected(True)
- # centralwidget.toolbox.listwidget. \
- # setItemSelected(itm, True)
- else:
- for itm in itms:
- itm.setSelected(False)
- # centralwidget.toolbox.listwidget. \
- # setItemSelected(itm, False)
- # give focus to listwidget so that highlighting works
- # (at least for short period until mouse is moved)
- centralwidget.toolbox.listwidget.setFocus()
-
- return QtWidgets.QGraphicsItem.itemChange(self, change, value)
+ def itemChange(self, change, value):
+ return QtWidgets.QGraphicsItem.itemChange(self, change, value)
def mousePressEvent(self, event):
# set item as topmost in stack
- # zstack = [itm.zValue() for itm in self.scene.items()]
+ # zstack = [itm.zValue() for itm in self.graphics_scene.items()]
# zmax = max(zstack)
# self.setZValue(zmax + 1)
@@ -111,33 +84,83 @@ def shape(self):
# it gives more accurate results for collision detection, etc.
return self.item_shape
- def boundingRect(self):
- # this function must be overwritten when subclassing QGraphicsItem
- # bounding box rect shall be set to the bounds of the item. Due to the
- # line thickness this rect is bigger than the rect of the ellipse or
- # rect, etc.
- # rect + line thickness is size
- return self.boundingrect
-
- def setBoundingRect(self):
- # FIXME
- # FIXME self.penwidth is in pixels, most probably needs
- # FIXME to be transformed to scene coordinates
- # FIXME maybe updated when refactoring GraphicsitemCollection
- # FIXME and its usage
- # FIXME
-
- pw = 0.0
-
- self.boundingrect = QtCore.QRectF(self.rect.left()-pw/2,
- self.rect.top()-pw/2,
- self.rect.width()+pw,
- self.rect.height()+pw)
-
- def paint(self, painter, option, widget):
- # this function must be overwritten when subclassing QGraphicsItem
-
- painter.setBrush(self.brush)
+ def boundingRect(self):
+ # this function must be overwritten when subclassing QGraphicsItem
+ # bounding box rect shall be set to the bounds of the item. Due to the
+ # line thickness this rect is bigger than the rect of the ellipse or
+ # rect, etc.
+ # rect + line thickness is size
+ return self.boundingrect
+
+ def _boundingPadding(self, penwidth=None):
+ penwidth = self.pen.widthF() if penwidth is None else penwidth
+ if penwidth <= 0.0:
+ return 0.0, 0.0
+
+ if not self.pen.isCosmetic():
+ half_width = penwidth / 2.0
+ return half_width, half_width
+
+ view = getattr(self.mw, 'view', None)
+ if view is None:
+ half_width = penwidth / 2.0
+ return half_width, half_width
+
+ transform = view.transform()
+ sx = abs(transform.m11())
+ sy = abs(transform.m22())
+ half_x = penwidth / (2.0 * sx) if sx else penwidth / 2.0
+ half_y = penwidth / (2.0 * sy) if sy else penwidth / 2.0
+ return half_x, half_y
+
+ def _makeBoundingRect(self, rect):
+ pad_x, pad_y = self._boundingPadding()
+ return QtCore.QRectF(rect.left() - pad_x,
+ rect.top() - pad_y,
+ rect.width() + 2.0 * pad_x,
+ rect.height() + 2.0 * pad_y)
+
+ def setBoundingRect(self):
+ boundingrect = self._makeBoundingRect(self.rect)
+ if hasattr(self, 'boundingrect') and self.boundingrect != boundingrect:
+ self.prepareGeometryChange()
+ self.boundingrect = boundingrect
+
+ def refreshGeometry(self):
+ self.setBoundingRect()
+
+ def syncGeometryFromArgs(self):
+ if self.method not in ('drawEllipse', 'drawRect') or not self.args:
+ return
+
+ rect = self.args[0]
+ if not isinstance(rect, QtCore.QRectF):
+ return
+
+ rect = QtCore.QRectF(rect)
+ path = QtGui.QPainterPath()
+ if self.method == 'drawEllipse':
+ path.addEllipse(rect)
+ else:
+ path.addRect(rect)
+
+ boundingrect = self._makeBoundingRect(rect)
+ geometry_changed = (
+ rect != self.rect or
+ not hasattr(self, 'boundingrect') or
+ self.boundingrect != boundingrect
+ )
+ if geometry_changed:
+ self.prepareGeometryChange()
+
+ self.rect = rect
+ self.item_shape = path
+ self.boundingrect = boundingrect
+
+ def paint(self, painter, option, widget):
+ # this function must be overwritten when subclassing QGraphicsItem
+
+ painter.setBrush(self.brush)
painter.setPen(self.pen)
painter.setFont(self.font)
@@ -147,14 +170,14 @@ def paint(self, painter, option, widget):
else:
painter.setRenderHint(QtGui.QPainter.Antialiasing, True)
- # care for difference between objects and text
- # i.e. normally y-coordinates go top down
- # to make a normal coordinate system y-axis is swapped in PGraphicsview
- # since PyQT does this automatically for text in the original setup
- # the text here needs to be swapped back to be printed correctly
- # scale on text items therefore in PGraphicsitemsCollection
- # gets scale (1, -1), all other items get scale (1, 1)
- painter.scale(self.scale[0], self.scale[1])
+ # care for difference between objects and text
+ # i.e. normally y-coordinates go top down
+ # to make a normal coordinate system the y-axis is swapped in GraphicsView
+ # since Qt does this automatically for text in the original setup
+ # the text here needs to be swapped back to be printed correctly
+ # scale on text items therefore in GraphicsItemsCollection
+ # gets scale (1, -1), all other items get scale (1, 1)
+ painter.scale(self.scale[0], self.scale[1])
# call module painter with its method given by string in self.method
# args are arguments to method
@@ -189,13 +212,15 @@ def drawFocusRect(self, painter):
painter.drawRect(self.boundingRect())
- def hoverEnterEvent(self, event):
- if not self.isSelected():
- self.pen.setWidthF(self.penwidth + self.hoverwidth)
- # handle event
- super().hoverEnterEvent(event)
-
- def hoverLeaveEvent(self, event):
- self.pen.setWidthF(self.penwidth)
- # handle event
- super().hoverLeaveEvent(event)
+ def hoverEnterEvent(self, event):
+ if not self.isSelected():
+ self.pen.setWidthF(self.penwidth + self.hoverwidth)
+ self.setBoundingRect()
+ # handle event
+ super().hoverEnterEvent(event)
+
+ def hoverLeaveEvent(self, event):
+ self.pen.setWidthF(self.penwidth)
+ self.setBoundingRect()
+ # handle event
+ super().hoverLeaveEvent(event)
diff --git a/src/GraphicsTest.py b/src/GraphicsTest.py
deleted file mode 100644
index 9444246..0000000
--- a/src/GraphicsTest.py
+++ /dev/null
@@ -1,84 +0,0 @@
-from PySide6 import QtGui, QtCore
-
-import GraphicsItemsCollection as gic
-import GraphicsItem
-
-
-def addTestItems(scene):
-
- # add items to the scene
- circle1 = gic.GraphicsCollection()
- circle1.Circle(0., 0., 0.1)
- circle1.pen.setWidthF(0.2)
- circle1.pen.setColor(QtGui.QColor(0, 0, 0, 255))
- circle1.brush.setColor(QtGui.QColor(255, 255, 0, 255))
-
- circle2 = gic.GraphicsCollection()
- circle2.Circle(-0.3, -0.3, 0.3)
- circle2.pen.setWidthF(0.02)
- circle2.pen.setColor(QtGui.QColor(0, 0, 0, 255))
- circle2.brush.setColor(QtGui.QColor(255, 0, 0, 255))
-
- circle3 = gic.GraphicsCollection()
- circle3.Circle(0.5, 0.5, 0.2)
- circle3.pen.setWidthF(0.02)
- circle3.pen.setColor(QtGui.QColor(0, 255, 0, 255))
- circle3.brush.setColor(QtGui.QColor(30, 30, 255, 100))
-
- circle4 = gic.GraphicsCollection()
- circle4.Circle(-0.1, 0.4, 0.2)
- circle4.pen.setWidthF(0.02)
- circle4.pen.setColor(QtGui.QColor(0, 0, 255, 255))
- circle4.brush.setColor(QtGui.QColor(30, 30, 30, 255))
-
- rectangle1 = gic.GraphicsCollection()
- rectangle1.Rectangle(-0.20, 0.10, 0.70, 0.35)
- rectangle1.pen.setWidthF(0.02)
- rectangle1.pen.setColor(QtGui.QColor(0, 0, 255, 255))
- rectangle1.brush.setColor(QtGui.QColor(0, 255, 0, 180))
-
- text1 = gic.GraphicsCollection()
- font = QtGui.QFont('Arial', 20)
- font.setBold(True)
- text1.Text(0, 0.90, 'This is a text', font)
- text1.pen.setColor(QtGui.QColor(50, 30, 200, 255))
-
- point1 = gic.GraphicsCollection()
- point1.Point(0, 0)
- point1.pen.setColor(QtGui.QColor(255, 0, 0, 255))
- point1.pen.setWidthF(0.02)
-
- polygon1 = gic.GraphicsCollection()
- polygon = QtGui.QPolygonF()
- polygon.append(QtCore.QPointF(0.20, 0.10))
- polygon.append(QtCore.QPointF(0.45, 0.10))
- polygon.append(QtCore.QPointF(0.45, -0.40))
- polygon.append(QtCore.QPointF(0.15, -0.40))
- polygon.append(QtCore.QPointF(0.0, 0.0))
- polygon1.pen.setWidthF(0.02)
- polygon1.pen.setColor(QtGui.QColor(0, 0, 0, 255))
- polygon1.brush.setColor(QtGui.QColor(0, 0, 255, 150))
- polygon1.Polygon(polygon)
-
- # create on the fly scene attributes which can be accessed deleteTestItems
- scene.itemc1 = GraphicsItem.GraphicsItem(circle1)
- scene.itemc2 = GraphicsItem.GraphicsItem(circle2)
- scene.itemc3 = GraphicsItem.GraphicsItem(circle3)
- scene.itemc4 = GraphicsItem.GraphicsItem(circle4)
- scene.itemr1 = GraphicsItem.GraphicsItem(rectangle1)
-
- # add test items to the scene
- scene.addItem(scene.itemc1)
- scene.addItem(scene.itemc2)
- scene.addItem(scene.itemc3)
- scene.addItem(scene.itemc4)
- scene.addItem(scene.itemr1)
-
-
-def deleteTestItems(scene):
-
- scene.removeItem(scene.itemc1)
- scene.removeItem(scene.itemc2)
- scene.removeItem(scene.itemc3)
- scene.removeItem(scene.itemc4)
- scene.removeItem(scene.itemr1)
diff --git a/src/GraphicsView.py b/src/GraphicsView.py
index bdb5dab..1fc5c5a 100644
--- a/src/GraphicsView.py
+++ b/src/GraphicsView.py
@@ -1,500 +1,768 @@
-import os
-import math
-
-from PySide6 import QtGui, QtCore, QtWidgets
-
-from Settings import ZOOMANCHOR, SCALEINC, MINZOOM, MAXZOOM, \
- MARKERSIZE, RUBBERBANDSIZE, VIEWSTYLE, ZOOMDIRECTION
-import logging
-logger = logging.getLogger(__name__)
-
-# put constraints on rubberband zoom (relative rectangle wdith)
-RUBBERBANDSIZE = min(RUBBERBANDSIZE, 1.0)
-RUBBERBANDSIZE = max(RUBBERBANDSIZE, 0.05)
-
-
-class GraphicsView(QtWidgets.QGraphicsView):
- """The graphics view is the canvas where airfoils are drawn upon
- Its coordinates are in pixels or "physical" coordinates.
-
- Attributes:
- origin (QPoint): stores location of mouse press
- parent (QMainWindow): mainwindow instance
- rubberband (QRubberBand): an instance of the custom rubberband class
- used for zooming and selecting
- sceneview (QRectF): stores current view in scene coordinates
- """
- def __init__(self, parent=None, scene=None):
- """Default settings for graphicsview instance
-
- Args:
- parent (QMainWindow, optional): mainwindow instance
- """
-
- super().__init__(scene)
-
- self.parent = parent
-
- self._leftMousePressed = False
-
- # allow drops from drag and drop
- self.setAcceptDrops(True)
-
- # use custom rubberband
- self.rubberband = RubberBand(QtWidgets.QRubberBand.Rectangle, self)
-
- # needed for correct mouse wheel zoom
- # otherwise mouse anchor is wrong; it would use (0, 0)
- self.setInteractive(True)
-
- # set QGraphicsView attributes
- self.setRenderHints(QtGui.QPainter.Antialiasing |
- QtGui.QPainter.TextAntialiasing)
- self.setViewportUpdateMode(QtWidgets.QGraphicsView.FullViewportUpdate)
- self.setResizeAnchor(QtWidgets.QGraphicsView.AnchorViewCenter)
-
- # view behaviour when zooming
- if ZOOMANCHOR == 'mouse':
- # point under mouse pointer stays fixed during zoom
- self.setTransformationAnchor(
- QtWidgets.QGraphicsView.AnchorUnderMouse)
- else:
- # view center stays fixed during zoom
- self.setTransformationAnchor(
- QtWidgets.QGraphicsView.AnchorViewCenter)
-
- self.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff)
- self.setVerticalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff)
-
- # normally (0, 0) is upperleft corner of view
- # swap y-axis in order to make (0, 0) lower left
- # and y-axis pointing upwards
- self.scale(1, -1)
-
- # cache view to be able to keep it during resize
- self.getSceneFromView()
-
- # set background style and color for view
- self.setBackground(VIEWSTYLE)
-
- def setBackground(self, styletype):
- """Switches between gradient and simple background using style sheets.
- border-color (in HTML) works only if border-style is set.
- """
-
- if styletype == 'gradient':
- style = """
- border-style:solid; border-color: lightgrey;
- border-width: 1px; background-color: QLinearGradient(x1: 0.0, y1: 0.0,
- x2: 0.0, y2: 1.0, stop: 0.3 white, stop: 1.0 #263a5a);
- """
-
- # if more stops are needed
- # stop: 0.3 white, stop: 0.6 #4b73b4, stop: 1.0 #263a5a); } """)
- else:
- style = ("""
- border-style:solid; border-color: lightgrey; \
- border-width: 1px; background-color: white;""")
-
- self.setStyleSheet(style)
-
- def resizeEvent(self, event):
- """Re-implement QGraphicsView's resizeEvent handler"""
-
- # call corresponding base class method
- super().resizeEvent(event)
-
- # scrollbars need to be switched off when calling fitinview from
- # within resize event otherwise strange recursion can occur
- self.fitInView(self.sceneview,
- aspectRadioMode=QtCore.Qt.KeepAspectRatio)
-
- def mousePressEvent(self, event):
- """Re-implement QGraphicsView's mousePressEvent handler"""
-
- # status of CTRL key
- ctrl = event.modifiers() == QtCore.Qt.ControlModifier
-
- # if a mouse event happens in the graphics view
- # put the keyboard focus to the view as well
- self.setFocus()
-
- self.origin = event.pos()
-
- # do rubberband zoom only with left mouse button
- if event.button() == QtCore.Qt.LeftButton:
-
- self._leftMousePressed = True
- self._dragPos = event.pos()
-
- if ctrl:
- self.setCursor(QtCore.Qt.ClosedHandCursor)
- else:
- # initiate rubberband origin and size (zero at first)
- self.rubberband.setGeometry(QtCore.QRect(self.origin,
- QtCore.QSize()))
- # show, even at zero size
- # allows to check later using isVisible()
- self.rubberband.show()
-
- # call corresponding base class method
- super().mousePressEvent(event)
-
- def mouseMoveEvent(self, event):
- """Re-implement QGraphicsView's mouseMoveEvent handler"""
-
- # if a mouse event happens in the graphics view
- # put the keyboard focus to the view as well
- self.setFocus()
-
- # status of CTRL key
- ctrl = event.modifiers() == QtCore.Qt.ControlModifier
-
- # pan the view with the left mouse button and CRTL down
- if self._leftMousePressed and ctrl:
- self.setCursor(QtCore.Qt.ClosedHandCursor)
- newPos = event.pos()
- diff = newPos - self._dragPos
- self._dragPos = newPos
-
- # this actually does the pan
- # no matter if scroll bars are displayed or not
- self.horizontalScrollBar().setValue(
- self.horizontalScrollBar().value() - diff.x())
- self.verticalScrollBar().setValue(
- self.verticalScrollBar().value() - diff.y())
-
- if self.rubberband.isVisible() and not ctrl:
- self.setInteractive(False)
- self.rubberband.setGeometry(
- QtCore.QRect(self.origin, event.pos()).normalized())
-
- # call corresponding base class method
- super().mouseMoveEvent(event)
-
- def mouseReleaseEvent(self, event):
- """Re-implement QGraphicsView's mouseReleaseEvent handler"""
-
- self._leftMousePressed = False
- self.setCursor(QtCore.Qt.ArrowCursor)
-
- # do zoom wrt to rect of rubberband
- if self.rubberband.isVisible():
-
- self.rubberband.hide()
- rect = self.rubberband.geometry()
- rectf = self.mapToScene(rect).boundingRect()
-
- # zoom the selected rectangle (works on scene coordinates)
- # zoom rect must be at least 5% of view width to allow zoom
- if self.rubberband.allow_zoom:
- self.fitInView(rectf,
- aspectRadioMode=QtCore.Qt.KeepAspectRatio)
-
- # rescale markers during zoom
- # i.e. keep them constant size
- self.adjustMarkerSize()
-
- # reset to True, so that mouse wheel zoom anchor works
- self.setInteractive(True)
-
- # reset ScrollHandDrag if it was active
- if self.dragMode() == QtWidgets.QGraphicsView.ScrollHandDrag:
- self.setDragMode(QtWidgets.QGraphicsView.NoDrag)
-
- # call corresponding base class method
- super().mouseReleaseEvent(event)
-
- def wheelEvent(self, event):
- """Re-implement QGraphicsView's wheelEvent handler"""
-
- # detect if event comes from a touchpad or similar (e.g., Apple magic mouse)
- # then zoom based on pixel delta
- device = event.device().type().name
-
- if device == 'TouchPad':
- delta = event.pixelDelta().y()
- damping = 0.0
- else:
- delta = event.angleDelta().y()
- damping = 0.0
-
- # Determine the scale factor based on the wheel direction
- factor = SCALEINC - damping
- scale_factor = 1.0 / factor if delta * ZOOMDIRECTION > 0 else factor
-
- # Apply the scaling
- self.scaleView(scale_factor)
-
- # DO NOT CONTINUE HANDLING EVENTS HERE!!!
- # this would destroy the mouse anchor
- # call corresponding base class method
- # super().wheelEvent(event)
-
- def keyPressEvent(self, event):
- """Re-implement QGraphicsView's keyPressEvent handler"""
-
- key = event.key()
-
- if key == QtCore.Qt.Key_Plus or key == QtCore.Qt.Key_PageDown:
- f = SCALEINC
- # if scaling with the keys, the do not use mouse as zoom anchor
- anchor = self.transformationAnchor()
- self.setTransformationAnchor(QtWidgets.QGraphicsView.AnchorViewCenter)
- self.scaleView(f)
- self.setTransformationAnchor(anchor)
-
- if key == QtCore.Qt.Key_PageDown:
- # return here so that later base class is NOT called
- # because QAbstractScrollArea would otherwise handle
- # the event and do something we do not want
- return
-
- elif key == QtCore.Qt.Key_Minus or key == QtCore.Qt.Key_PageUp:
- f = 1.0 / SCALEINC
- # if scaling with the keys, the do not use mouse as zoom anchor
- anchor = self.transformationAnchor()
- self.setTransformationAnchor(QtWidgets.QGraphicsView.AnchorViewCenter)
- self.scaleView(f)
- self.setTransformationAnchor(anchor)
-
- if key == QtCore.Qt.Key_PageUp:
- # return here so that later base class is NOT called
- # because QAbstractScrollArea would otherwise handle
- # the event and do something we do not want
- return
-
- elif key == QtCore.Qt.Key_Home:
- self.parent.slots.onViewAll()
- elif key == QtCore.Qt.Key_Delete:
- # removes all selected airfoils
- self.parent.slots.removeAirfoil()
-
- # call corresponding base class method
- super().keyPressEvent(event)
-
- def keyReleaseEvent(self, event):
- """Re-implement QGraphicsView's keyReleaseEvent handler"""
-
- # call corresponding base class method
- super().keyReleaseEvent(event)
-
- def dragEnterEvent(self, event):
- if event.mimeData().hasUrls():
- event.accept()
- else:
- event.ignore()
-
- def dragLeaveEvent(self, event):
- pass
-
- def dragMoveEvent(self, event):
- if event.mimeData().hasUrls():
- if event.mimeData().hasText():
- event.setDropAction(QtCore.Qt.CopyAction)
- event.accept()
- else:
- event.ignore()
-
- def dropEvent(self, event):
- for url in event.mimeData().urls():
- path = url.toLocalFile()
- if os.path.isfile(path):
- self.parent.slots.loadAirfoil(path, comment='#')
-
- def scaleView(self, factor):
-
- # check if zoom limits are exceeded
- # m11 = x-scaling
- sx = self.transform().m11()
-
- too_big = sx > MAXZOOM and factor > 1.0
- too_small = sx < MINZOOM and factor < 1.0
-
- if too_big or too_small:
- return
-
- # do the actual zooming
- self.scale(factor, factor)
-
- # rescale markers during zoom, i.e., keep them constant size
- self.adjustMarkerSize()
-
- # cache view to be able to keep it during resize
- self.getSceneFromView()
-
- def adjustMarkerSize(self):
- """Adjust marker size during zoom. Marker items are circles
- which are otherwise affected by zoom. Using MARKERSIZE from
- Settings a fixed markersize (e.g. 3 pixels) can be kept.
- This method immitates the behaviour of pen.setCosmetic()
- """
-
- # FIXME
- # FIXME this fixes an accidential call of this method
- # FIXME should be fixed by checking when called
- # FIXME
- if not self.parent.airfoil:
- return
-
- #
- current_zoom = self.transform().m11()
- scale_marker = 1. + 3. * (current_zoom - MINZOOM) / (MAXZOOM - MINZOOM)
- # scale_marker = 100.
- # logger.info(f'Current zoom value {current_zoom}')
- # logger.info(f'Scale factor for markers {scale_marker}')
-
- # markers are drawn in GraphicsItem using scene coordinates
- # in order to keep them constant size, also when zooming
- # a fixed pixel size (MARKERSIZE from settings) is mapped to
- # scene coordinates
- # depending on the zoom, this leads to always different
- # scene coordinates
- # map a square with side length of MARKERSIZE to the scene coords
-
- mappedMarker = self.mapToScene(
- QtCore.QRect(0, 0, MARKERSIZE*scale_marker, MARKERSIZE*scale_marker))
- mappedMarkerWidth = mappedMarker.boundingRect().width()
-
- if self.parent.airfoil.contourPolygon:
- markers = self.parent.airfoil.polygonMarkers
- x, y = self.parent.airfoil.raw_coordinates
- for i, marker in enumerate(markers):
- # in case of circle, args is a QRectF
- marker.args = [QtCore.QRectF(x[i] - mappedMarkerWidth,
- y[i] - mappedMarkerWidth,
- 2. * mappedMarkerWidth,
- 2. * mappedMarkerWidth)]
-
- # if self.parent.airfoil.contourSpline:
- if hasattr(self.parent.airfoil, 'contourSpline'):
- markers = self.parent.airfoil.splineMarkers
- x, y = self.parent.airfoil.spline_data[0]
- for i, marker in enumerate(markers):
- # in case of circle, args is a QRectF
- marker.args = [QtCore.QRectF(x[i] - mappedMarkerWidth,
- y[i] - mappedMarkerWidth,
- 2. * mappedMarkerWidth,
- 2. * mappedMarkerWidth)]
-
- def getSceneFromView(self):
- """Cache view to be able to keep it during resize"""
-
- # map view rectangle to scene coordinates
- polygon = self.mapToScene(self.rect())
-
- # sceneview describes the rectangle which is currently
- # being viewed in scene coordinates
- # this is needed during resizing to be able to keep the view
- self.sceneview = QtCore.QRectF(polygon[0], polygon[2])
-
- def contextMenuEvent(self, event):
- """Creates context menu (popup menu) for the graphicsview.
-
- This has to be done by reimplementing the contextMenuEvent handler
- from The QWidget class.
- """
-
- menu = QtWidgets.QMenu(self)
- menu.setStyleSheet(
- """
- QMenu{
- background-color: #EFEFFF;
- }
- QMenu::hover{
- background-color: #B0B0FF;
- }
- """
- )
-
- fitairfoil = menu.addAction('Fit airfoil in view')
- fitairfoil.setShortcut('CTRL+f')
-
- fitall = menu.addAction('Fit all items in view')
- fitall.setShortcut('HOME, CTRL+SHIFT+f')
-
- menu.addSeparator()
-
- delitems = menu.addAction('Delete airfoil')
- delitems.setShortcut('Del')
-
- menu.addSeparator()
-
- togglebg = menu.addAction('Toggle background')
- togglebg.setShortcut('CTRL+b')
-
- action = menu.exec_(self.mapToGlobal(event.pos()))
-
- if action == togglebg:
- self.parent.slots.onBackground()
- elif action == fitairfoil:
- self.parent.slots.fitAirfoilInView()
- elif action == fitall:
- self.parent.slots.onViewAll()
- # remove all selected items from the scene
- elif action == delitems:
- self.parent.slots.removeAirfoil()
-
- # call corresponding base class method
- super().contextMenuEvent(event)
-
-
-class RubberBand(QtWidgets.QRubberBand):
- """Custom rubberband
- from: http://stackoverflow.com/questions/25642618
- """
-
- def __init__(self, *args, **kwargs):
-
- super().__init__(*args, **kwargs)
-
- self.view = args[1]
-
- # set pen and brush (filling)
- self.pen = QtGui.QPen()
- self.pen.setStyle(QtCore.Qt.DotLine)
- self.pen.setColor(QtGui.QColor(80, 80, 100))
- self.brush = QtGui.QBrush()
- color = QtGui.QColor(20, 20, 80, 30)
- self.brush.setColor(color)
- # self.brush.setStyle(QtCore.Qt.NoBrush)
- self.brush.setStyle(QtCore.Qt.SolidPattern)
-
- # set style selectively for the rubberband like that
- # see: http://stackoverflow.com/questions/25642618
- # required as opacity might not work
- # NOTE: opacity removed here
- self.setStyle(QtWidgets.QStyleFactory.create('windowsvista'))
-
- # set boolean for allowing zoom
- self.allow_zoom = False
-
- def paintEvent(self, QPaintEvent):
-
- painter = QtGui.QPainter(self)
-
- self.pen.setColor(QtGui.QColor(80, 80, 100))
- self.pen.setWidthF(1.5)
- self.pen.setStyle(QtCore.Qt.DotLine)
-
- # zoom rect must be at least RUBBERBANDSIZE % of view to allow zoom
- if (QPaintEvent.rect().width() < RUBBERBANDSIZE * self.view.width()) \
- or \
- (QPaintEvent.rect().height() < RUBBERBANDSIZE * self.view.height()):
-
- self.brush.setStyle(QtCore.Qt.NoBrush)
-
- # set boolean for allowing zoom
- self.allow_zoom = False
- else:
- # if rubberband rect is big enough indicate this by fill color
- color = QtGui.QColor(10, 30, 140, 45)
- self.brush.setColor(color)
- self.brush.setStyle(QtCore.Qt.SolidPattern)
-
- # set boolean for allowing zoom
- self.allow_zoom = True
-
- painter.setBrush(self.brush)
- painter.setPen(self.pen)
- painter.drawRect(QPaintEvent.rect())
+import os
+
+from PySide6 import QtGui, QtCore, QtWidgets
+import shiboken6
+
+import MagnifierLens
+from Utils import get_main_window
+import logging
+logger = logging.getLogger(__name__)
+
+
+
+class GraphicsView(QtWidgets.QGraphicsView):
+ """The graphics view is the canvas where airfoils are drawn upon
+ Its coordinates are in pixels or "physical" coordinates.
+
+ Attributes:
+ origin (QPoint): stores location of mouse press
+ rubberband (QRubberBand): an instance of the custom rubberband class
+ used for zooming and selecting
+ sceneview (QRectF): stores current view in scene coordinates
+ """
+ MAGNIFIER_SIZE_STEP = 24
+ MAGNIFIER_MIN_SIZE = 96
+ MAGNIFIER_MAX_SIZE = 700
+ MAGNIFIER_MIN_OUTLINE_WIDTH = 1.0
+ MAGNIFIER_MAX_OUTLINE_WIDTH = 8.0
+ MAGNIFIER_MAGNIFICATION_STEP = 0.25
+ MAGNIFIER_MIN_MAGNIFICATION = 1.25
+ MAGNIFIER_MAX_MAGNIFICATION = 6.0
+
+ def __init__(self, scene=None):
+ """Default settings for graphicsview instance"""
+
+ super().__init__(scene)
+
+ self.mw = get_main_window()
+
+ self._leftMousePressed = False
+ self._magnifier_active = False
+ self._magnifier_size = 180
+ self._magnifier_outline_width = 2.0
+ self._magnifier_magnification = 2.0
+ self._magnifier_last_pos = None
+ self._magnifier_wheel_accumulator = 0.0
+ self._magnifier_action_states = {}
+
+ # allow drops from drag and drop
+ self.setAcceptDrops(True)
+
+ # use custom rubberband
+ self.rubberband = RubberBand(QtWidgets.QRubberBand.Rectangle, self)
+
+ # needed for correct mouse wheel zoom
+ # otherwise mouse anchor is wrong; it would use (0, 0)
+ self.setInteractive(True)
+
+ # set QGraphicsView attributes
+ self.setRenderHints(QtGui.QPainter.Antialiasing |
+ QtGui.QPainter.TextAntialiasing)
+ self.setViewportUpdateMode(QtWidgets.QGraphicsView.FullViewportUpdate)
+ self.setResizeAnchor(QtWidgets.QGraphicsView.AnchorViewCenter)
+ self.setFrameShape(QtWidgets.QFrame.NoFrame)
+ self.setLineWidth(0)
+
+ self.applyViewSettings()
+
+ self.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff)
+ self.setVerticalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff)
+
+ # normally (0, 0) is upperleft corner of view
+ # swap y-axis in order to make (0, 0) lower left
+ # and y-axis pointing upwards
+ self.scale(1, -1)
+
+ # cache view to be able to keep it during resize
+ self.getSceneFromView()
+
+ self._magnifier_lens = MagnifierLens.MagnifierLensWidget(self)
+ self.applyMagnifierSettings()
+ self.setMouseTracking(False)
+ self.viewport().setMouseTracking(False)
+
+ def applyViewSettings(self):
+ # view behaviour when zooming
+ if self.mw.ZOOM_ANCHOR == 'mouse':
+ # point under mouse pointer stays fixed during zoom
+ self.setTransformationAnchor(
+ QtWidgets.QGraphicsView.AnchorUnderMouse)
+ else:
+ # view center stays fixed during zoom
+ self.setTransformationAnchor(
+ QtWidgets.QGraphicsView.AnchorViewCenter)
+
+ # set background style and color for view
+ self.viewstyle = self.mw.VIEW_STYLE
+ self.setBackground(self.mw.VIEW_STYLE)
+
+ # put constraints on rubberband zoom (relative rectangle width)
+ self.mw.RUBBERBAND_MIN = min(self.mw.RUBBERBAND_MIN, 1.0)
+ self.mw.RUBBERBAND_MIN = max(self.mw.RUBBERBAND_MIN, 0.05)
+
+ self.applyMagnifierSettings()
+
+ def applyMagnifierSettings(self):
+ size = getattr(self.mw, 'MAGNIFIER_SIZE', self._magnifier_size)
+ outline_width = getattr(
+ self.mw,
+ 'MAGNIFIER_OUTLINE_WIDTH',
+ self._magnifier_outline_width,
+ )
+ magnification = getattr(
+ self.mw,
+ 'MAGNIFIER_MAGNIFICATION',
+ self._magnifier_magnification,
+ )
+
+ self._magnifier_size = self._clampMagnifierSize(size)
+ self._magnifier_outline_width = self._clampMagnifierOutlineWidth(
+ outline_width
+ )
+ self._magnifier_magnification = self._clampMagnification(magnification)
+
+ if not hasattr(self, '_magnifier_lens'):
+ return
+
+ self._magnifier_lens.setLensSize(self._magnifier_size)
+ self._magnifier_lens.setOutlineWidth(self._magnifier_outline_width)
+ self._magnifier_lens.setMagnification(self._magnifier_magnification)
+ self._magnifier_lens.syncFromMainView()
+
+ if self._magnifier_active and self._magnifier_last_pos is not None:
+ self._updateMagnifier(self._magnifier_last_pos)
+
+ def setBackground(self, styletype):
+ """Switches between gradient and simple background using style sheets.
+ border-color (in HTML) works only if border-style is set.
+ """
+
+ if styletype == 'gradient':
+ style = """
+ background-color: QLinearGradient(x1: 0.0, y1: 0.0,
+ x2: 0.0, y2: 1.0, stop: 0.3 white, stop: 1.0 #263a5a);
+ """
+
+ # if more stops are needed
+ # stop: 0.3 white, stop: 0.6 #4b73b4, stop: 1.0 #263a5a); } """)
+ else:
+ style = ("""
+ background-color: white;""")
+
+ self.setStyleSheet(style)
+ if hasattr(self, '_magnifier_lens'):
+ self._magnifier_lens.syncFromMainView()
+
+ def fitInView(self, *args, **kwargs):
+ result = super().fitInView(*args, **kwargs)
+ self.refreshCustomItemGeometry()
+ if self._magnifier_active and self._magnifier_last_pos is not None:
+ self._updateMagnifier(self._magnifier_last_pos)
+ return result
+
+ def refreshCustomItemGeometry(self):
+ scene = self.scene()
+ if scene is None:
+ return
+
+ for item in scene.items():
+ refresh_geometry = getattr(item, 'refreshGeometry', None)
+ if callable(refresh_geometry):
+ refresh_geometry()
+
+ def resizeEvent(self, event):
+ """Re-implement QGraphicsView's resizeEvent handler"""
+
+ # call corresponding base class method
+ super().resizeEvent(event)
+
+ # scrollbars need to be switched off when calling fitinview from
+ # within resize event otherwise strange recursion can occur
+ self.fitInView(self.sceneview,
+ aspectRadioMode=QtCore.Qt.KeepAspectRatio)
+ self.adjustMarkerSize()
+
+ if self._magnifier_active and self._magnifier_last_pos is not None:
+ self._updateMagnifier(self._magnifier_last_pos)
+
+ def mousePressEvent(self, event):
+ """Re-implement QGraphicsView's mousePressEvent handler"""
+
+ if self._magnifier_active:
+ self.setFocus()
+ if event.button() == QtCore.Qt.RightButton:
+ event.ignore()
+ else:
+ event.accept()
+ return
+
+ # status of CTRL key
+ ctrl = event.modifiers() == QtCore.Qt.ControlModifier
+
+ # if a mouse event happens in the graphics view
+ # put the keyboard focus to the view as well
+ self.setFocus()
+
+ self.origin = event.pos()
+
+ # do rubberband zoom only with left mouse button
+ if event.button() == QtCore.Qt.LeftButton:
+
+ self._leftMousePressed = True
+ self._dragPos = event.pos()
+
+ if ctrl:
+ self.setCursor(QtCore.Qt.ClosedHandCursor)
+ else:
+ # initiate rubberband origin and size (zero at first)
+ self.rubberband.setGeometry(QtCore.QRect(self.origin,
+ QtCore.QSize()))
+ # show, even at zero size
+ # allows to check later using isVisible()
+ self.rubberband.show()
+
+ # call corresponding base class method
+ super().mousePressEvent(event)
+
+ def mouseMoveEvent(self, event):
+ """Re-implement QGraphicsView's mouseMoveEvent handler"""
+
+ if self._magnifier_active:
+ self.setFocus()
+ self._updateMagnifier(event.pos())
+ event.accept()
+ return
+
+ # if a mouse event happens in the graphics view
+ # put the keyboard focus to the view as well
+ self.setFocus()
+
+ # status of CTRL key
+ ctrl = event.modifiers() == QtCore.Qt.ControlModifier
+
+ # pan the view with the left mouse button and CRTL down
+ if self._leftMousePressed and ctrl:
+ self.setCursor(QtCore.Qt.ClosedHandCursor)
+ newPos = event.pos()
+ diff = newPos - self._dragPos
+ self._dragPos = newPos
+
+ # this actually does the pan
+ # no matter if scroll bars are displayed or not
+ self.horizontalScrollBar().setValue(
+ self.horizontalScrollBar().value() - diff.x())
+ self.verticalScrollBar().setValue(
+ self.verticalScrollBar().value() - diff.y())
+
+ if self.rubberband.isVisible() and not ctrl:
+ self.setInteractive(False)
+ self.rubberband.setGeometry(
+ QtCore.QRect(self.origin, event.pos()).normalized())
+
+ # call corresponding base class method
+ super().mouseMoveEvent(event)
+
+ def mouseReleaseEvent(self, event):
+ """Re-implement QGraphicsView's mouseReleaseEvent handler"""
+
+ if self._magnifier_active:
+ if event.button() == QtCore.Qt.RightButton:
+ event.ignore()
+ else:
+ event.accept()
+ return
+
+ self._leftMousePressed = False
+ self.setCursor(QtCore.Qt.ArrowCursor)
+
+ # do zoom wrt to rect of rubberband
+ if self.rubberband.isVisible():
+
+ self.rubberband.hide()
+ rect = self.rubberband.geometry()
+ rectf = self.mapToScene(rect).boundingRect()
+
+ # zoom the selected rectangle (works on scene coordinates)
+ # zoom rect must be at least 5% of view width to allow zoom
+ if self.rubberband.allow_zoom:
+ self.fitInView(rectf,
+ aspectRadioMode=QtCore.Qt.KeepAspectRatio)
+
+ # rescale markers during zoom
+ # i.e. keep them constant size
+ self.adjustMarkerSize()
+
+ # reset to True, so that mouse wheel zoom anchor works
+ self.setInteractive(True)
+
+ # reset ScrollHandDrag if it was active
+ if self.dragMode() == QtWidgets.QGraphicsView.ScrollHandDrag:
+ self.setDragMode(QtWidgets.QGraphicsView.NoDrag)
+
+ # call corresponding base class method
+ super().mouseReleaseEvent(event)
+
+ def wheelEvent(self, event):
+ """Re-implement QGraphicsView's wheelEvent handler"""
+
+ if self._magnifier_active:
+ steps = self._magnifierWheelSteps(event)
+ if steps:
+ self._adjustMagnifierMagnification(
+ steps * self.MAGNIFIER_MAGNIFICATION_STEP
+ )
+ if self._magnifier_last_pos is not None:
+ self._updateMagnifier(self._magnifier_last_pos)
+ event.accept()
+ return
+
+ # detect if event comes from a touchpad or similar (e.g., Apple magic mouse)
+ # then zoom based on pixel delta
+ device = event.device().type().name
+
+ if device == 'TouchPad':
+ delta = event.pixelDelta().y()
+ damping = 0.0
+ else:
+ delta = event.angleDelta().y()
+ damping = 0.0
+
+ # Determine the scale factor based on the wheel direction
+ factor = self.mw.SCALE_INCREMENT - damping
+ scale_factor = 1.0 / factor if delta * self.mw.ZOOM_DIRECTION > 0 else factor
+
+ # Apply the scaling
+ self.scaleView(scale_factor)
+
+ # DO NOT CONTINUE HANDLING EVENTS HERE!!!
+ # this would destroy the mouse anchor
+ # call corresponding base class method
+ # super().wheelEvent(event)
+
+ def _scaleFromKeyboard(self, factor):
+ anchor = self.transformationAnchor()
+ self.setTransformationAnchor(QtWidgets.QGraphicsView.AnchorViewCenter)
+ self.scaleView(factor)
+ self.setTransformationAnchor(anchor)
+
+ def zoomIn(self):
+ self._scaleFromKeyboard(self.mw.SCALE_INCREMENT)
+
+ def zoomOut(self):
+ self._scaleFromKeyboard(1.0 / self.mw.SCALE_INCREMENT)
+
+ def toggleMagnifier(self):
+ self.setMagnifierEnabled(not self._magnifier_active)
+
+ def setMagnifierEnabled(self, enabled):
+ enabled = bool(enabled)
+ if enabled == self._magnifier_active:
+ return
+
+ self._magnifier_active = enabled
+ self._magnifier_wheel_accumulator = 0.0
+ self._leftMousePressed = False
+ self.rubberband.hide()
+ self._setMagnifierActionStates(enabled)
+
+ self.setMouseTracking(enabled)
+ self.viewport().setMouseTracking(enabled)
+
+ if not enabled:
+ self._magnifier_lens.hide()
+ self._magnifier_last_pos = None
+ return
+
+ view_pos = self.viewport().mapFromGlobal(QtGui.QCursor.pos())
+ if self.viewport().rect().contains(view_pos):
+ self._updateMagnifier(view_pos)
+ else:
+ self._magnifier_lens.hide()
+
+ def magnifierZoomIn(self):
+ if not self._magnifier_active:
+ return
+ self._adjustMagnifierMagnification(self.MAGNIFIER_MAGNIFICATION_STEP)
+ if self._magnifier_last_pos is not None:
+ self._updateMagnifier(self._magnifier_last_pos)
+
+ def magnifierZoomOut(self):
+ if not self._magnifier_active:
+ return
+ self._adjustMagnifierMagnification(-self.MAGNIFIER_MAGNIFICATION_STEP)
+ if self._magnifier_last_pos is not None:
+ self._updateMagnifier(self._magnifier_last_pos)
+
+ def magnifierIncreaseSize(self):
+ if not self._magnifier_active:
+ return
+ self._adjustMagnifierSize(self.MAGNIFIER_SIZE_STEP)
+ if self._magnifier_last_pos is not None:
+ self._updateMagnifier(self._magnifier_last_pos)
+
+ def magnifierDecreaseSize(self):
+ if not self._magnifier_active:
+ return
+ self._adjustMagnifierSize(-self.MAGNIFIER_SIZE_STEP)
+ if self._magnifier_last_pos is not None:
+ self._updateMagnifier(self._magnifier_last_pos)
+
+ def keyPressEvent(self, event):
+ """Forward keypress events to Qt's action system."""
+ super().keyPressEvent(event)
+
+ def keyReleaseEvent(self, event):
+ """Re-implement QGraphicsView's keyReleaseEvent handler"""
+
+ # call corresponding base class method
+ super().keyReleaseEvent(event)
+
+ def dragEnterEvent(self, event):
+ if event.mimeData().hasUrls():
+ event.accept()
+ else:
+ event.ignore()
+
+ def dragLeaveEvent(self, event):
+ pass
+
+ def dragMoveEvent(self, event):
+ if event.mimeData().hasUrls():
+ if event.mimeData().hasText():
+ event.setDropAction(QtCore.Qt.CopyAction)
+ event.accept()
+ else:
+ event.ignore()
+
+ def dropEvent(self, event):
+ for url in event.mimeData().urls():
+ path = url.toLocalFile()
+ if os.path.isfile(path):
+ self.mw.slots.openFile(path)
+
+ def scaleView(self, factor):
+
+ # check if zoom limits are exceeded
+ # m11 = x-scaling
+ sx = self.transform().m11()
+
+ too_big = sx > self.mw.MAX_ZOOM and factor > 1.0
+ too_small = sx < self.mw.MIN_ZOOM and factor < 1.0
+
+ if too_big or too_small:
+ return
+
+ # do the actual zooming
+ self.scale(factor, factor)
+
+ # rescale markers during zoom, i.e., keep them constant size
+ self.adjustMarkerSize()
+
+ self.refreshCustomItemGeometry()
+
+ # cache view to be able to keep it during resize
+ self.getSceneFromView()
+
+ if self._magnifier_active and self._magnifier_last_pos is not None:
+ self._updateMagnifier(self._magnifier_last_pos)
+
+ def adjustMarkerSize(self):
+ """Adjust marker size during zoom. Marker items are circles
+ which are otherwise affected by zoom. Using MARKER_SIZE from
+ Settings a fixed markersize (e.g. 3 pixels) can be kept.
+ This method imitates the behaviour of pen.setCosmetic().
+ """
+
+ if getattr(self.mw, '_viewer_subject', 'airfoil') != 'airfoil':
+ return
+
+ airfoil = getattr(self.mw, 'airfoil', None)
+ if airfoil is None:
+ return
+
+ marker_radius = self._markerRadiusInScene()
+ for coordinates, markers, scale in airfoil.markerCollections():
+ self._resizeMarkers(markers, coordinates, marker_radius * scale)
+
+ def _markerRadiusInScene(self):
+ current_zoom = self.transform().m11()
+ zoom_span = self.mw.MAX_ZOOM - self.mw.MIN_ZOOM
+ if zoom_span == 0.0:
+ scale_marker = 1.0
+ else:
+ scale_marker = 1.0 + 3.0 * (current_zoom - self.mw.MIN_ZOOM) / zoom_span
+
+ # Markers are drawn in scene coordinates. Map the configured marker size
+ # from view pixels back to the scene so the apparent size stays stable.
+ mapped_marker = self.mapToScene(
+ QtCore.QRect(
+ 0,
+ 0,
+ self.mw.MARKER_SIZE * scale_marker,
+ self.mw.MARKER_SIZE * scale_marker,
+ )
+ )
+ return mapped_marker.boundingRect().width()
+
+ def _resizeMarkers(self, markers, coordinates, marker_radius):
+ x_values, y_values = coordinates
+ for marker, x_value, y_value in zip(markers, x_values, y_values):
+ if not shiboken6.isValid(marker):
+ continue
+ marker.args = [
+ QtCore.QRectF(
+ x_value - marker_radius,
+ y_value - marker_radius,
+ 2.0 * marker_radius,
+ 2.0 * marker_radius,
+ )
+ ]
+ sync_geometry = getattr(marker, 'syncGeometryFromArgs', None)
+ if callable(sync_geometry):
+ sync_geometry()
+
+ def getSceneFromView(self):
+ """Cache view to be able to keep it during resize"""
+
+ # map view rectangle to scene coordinates
+ polygon = self.mapToScene(self.rect())
+
+ # sceneview describes the rectangle which is currently
+ # being viewed in scene coordinates
+ # this is needed during resizing to be able to keep the view
+ self.sceneview = QtCore.QRectF(polygon[0], polygon[2])
+
+ def enterEvent(self, event):
+ super().enterEvent(event)
+ if not self._magnifier_active:
+ return
+
+ view_pos = self.viewport().mapFromGlobal(QtGui.QCursor.pos())
+ if self.viewport().rect().contains(view_pos):
+ self._updateMagnifier(view_pos)
+
+ def leaveEvent(self, event):
+ if self._magnifier_active:
+ self._magnifier_lens.hide()
+ self._magnifier_last_pos = None
+ super().leaveEvent(event)
+
+ def contextMenuEvent(self, event):
+ """Creates context menu (popup menu) for the graphicsview.
+
+ This has to be done by reimplementing the contextMenuEvent handler
+ from The QWidget class.
+ """
+
+ menu = QtWidgets.QMenu(self)
+ menu.setStyleSheet(
+ """
+ QMenu{
+ background-color: #EFEFFF;
+ }
+ QMenu::hover{
+ background-color: #B0B0FF;
+ }
+ """
+ )
+
+ if self._magnifier_active:
+ toggle_magnifier = self.mw.action_registry.action('view.toggle_magnifier')
+ magnifier_zoom_in = self.mw.action_registry.action('view.magnifier_zoom_in')
+ magnifier_zoom_out = self.mw.action_registry.action('view.magnifier_zoom_out')
+ magnifier_size_up = self.mw.action_registry.action('view.magnifier_size_up')
+ magnifier_size_down = self.mw.action_registry.action('view.magnifier_size_down')
+ settings_action = self.mw.action_registry.action('tools.settings')
+
+ if toggle_magnifier is not None:
+ menu.addAction(toggle_magnifier)
+ menu.addSeparator()
+ if magnifier_zoom_in is not None:
+ menu.addAction(magnifier_zoom_in)
+ if magnifier_zoom_out is not None:
+ menu.addAction(magnifier_zoom_out)
+ menu.addSeparator()
+ if magnifier_size_up is not None:
+ menu.addAction(magnifier_size_up)
+ if magnifier_size_down is not None:
+ menu.addAction(magnifier_size_down)
+ if settings_action is not None:
+ menu.addSeparator()
+ menu.addAction(settings_action)
+
+ menu.exec_(self.mapToGlobal(event.pos()))
+ event.accept()
+ return
+
+ fit_airfoil = self.mw.action_registry.action('view.fit_airfoil')
+ fit_all = self.mw.action_registry.action('view.fit_all')
+ delete_airfoil = self.mw.action_registry.action('airfoil.delete_active')
+ toggle_background = self.mw.action_registry.action('view.toggle_background')
+ toggle_magnifier = self.mw.action_registry.action('view.toggle_magnifier')
+
+ if fit_airfoil is not None:
+ menu.addAction(fit_airfoil)
+ if fit_all is not None:
+ menu.addAction(fit_all)
+ menu.addSeparator()
+ if toggle_magnifier is not None:
+ menu.addAction(toggle_magnifier)
+ menu.addSeparator()
+ if delete_airfoil is not None:
+ menu.addAction(delete_airfoil)
+ menu.addSeparator()
+ if toggle_background is not None:
+ menu.addAction(toggle_background)
+
+ menu.exec_(self.mapToGlobal(event.pos()))
+ event.accept()
+
+ def _updateMagnifier(self, view_pos):
+ if not self._magnifier_active:
+ return
+
+ viewport_rect = self.viewport().rect()
+ if not viewport_rect.contains(view_pos):
+ self._magnifier_lens.hide()
+ self._magnifier_last_pos = None
+ return
+
+ self._magnifier_last_pos = QtCore.QPoint(view_pos)
+ self._magnifier_lens.setMagnification(self._magnifier_magnification)
+ self._magnifier_lens.setLensSize(self._magnifier_size)
+ self._magnifier_lens.updateLens(view_pos)
+
+ def _adjustMagnifierSize(self, delta):
+ self._magnifier_size = self._clampMagnifierSize(self._magnifier_size + delta)
+ self._magnifier_lens.setLensSize(self._magnifier_size)
+
+ def _adjustMagnifierMagnification(self, delta):
+ self._magnifier_magnification = self._clampMagnification(
+ self._magnifier_magnification + delta
+ )
+ self._magnifier_lens.setMagnification(self._magnifier_magnification)
+
+ def _setMagnifierActionStates(self, magnifier_active):
+ action_registry = getattr(self.mw, 'action_registry', None)
+ if action_registry is None:
+ return
+
+ managed_action_ids = (
+ 'view.fit_airfoil',
+ 'view.fit_all',
+ 'view.toggle_background',
+ 'view.zoom_in',
+ 'view.zoom_out',
+ 'airfoil.delete_active',
+ )
+
+ if magnifier_active:
+ self._magnifier_action_states = {}
+ for action_id in managed_action_ids:
+ action = action_registry.action(action_id)
+ if action is None:
+ continue
+ self._magnifier_action_states[action_id] = action.isEnabled()
+ action.setEnabled(False)
+ return
+
+ for action_id, was_enabled in self._magnifier_action_states.items():
+ action = action_registry.action(action_id)
+ if action is not None:
+ action.setEnabled(was_enabled)
+ self._magnifier_action_states = {}
+
+ def _magnifierWheelSteps(self, event):
+ angle_delta = event.angleDelta().y()
+ pixel_delta = event.pixelDelta().y()
+
+ if angle_delta:
+ self._magnifier_wheel_accumulator += angle_delta / 120.0
+ elif pixel_delta:
+ self._magnifier_wheel_accumulator += pixel_delta / 40.0
+ else:
+ return 0
+
+ steps = int(self._magnifier_wheel_accumulator)
+ self._magnifier_wheel_accumulator -= steps
+ return steps
+
+ def _clampMagnifierSize(self, size):
+ return max(
+ self.MAGNIFIER_MIN_SIZE,
+ min(self.MAGNIFIER_MAX_SIZE, int(round(size))),
+ )
+
+ def _clampMagnifierOutlineWidth(self, width):
+ return max(
+ self.MAGNIFIER_MIN_OUTLINE_WIDTH,
+ min(self.MAGNIFIER_MAX_OUTLINE_WIDTH, float(width)),
+ )
+
+ def _clampMagnification(self, magnification):
+ return max(
+ self.MAGNIFIER_MIN_MAGNIFICATION,
+ min(self.MAGNIFIER_MAX_MAGNIFICATION, float(magnification)),
+ )
+
+
+class RubberBand(QtWidgets.QRubberBand):
+ """Custom rubberband
+ from: http://stackoverflow.com/questions/25642618
+ """
+
+ def __init__(self, *args, **kwargs):
+
+ super().__init__(*args, **kwargs)
+
+ self.view = args[1]
+ self.mw = getattr(self.view, 'mw', get_main_window())
+
+ # set pen and brush (filling)
+ self.pen = QtGui.QPen()
+ self.pen.setStyle(QtCore.Qt.DotLine)
+ self.pen.setColor(QtGui.QColor(80, 80, 100))
+ self.brush = QtGui.QBrush()
+ color = QtGui.QColor(20, 20, 80, 30)
+ self.brush.setColor(color)
+ # self.brush.setStyle(QtCore.Qt.NoBrush)
+ self.brush.setStyle(QtCore.Qt.SolidPattern)
+
+ # set style selectively for the rubberband like that
+ # see: http://stackoverflow.com/questions/25642618
+ # required as opacity might not work
+ # NOTE: opacity removed here
+ self.setStyle(QtWidgets.QStyleFactory.create('windowsvista'))
+
+ # set boolean for allowing zoom
+ self.allow_zoom = False
+
+ def paintEvent(self, QPaintEvent):
+ painter = QtGui.QPainter()
+ if not painter.begin(self):
+ return
+
+ try:
+ self.pen.setColor(QtGui.QColor(80, 80, 100))
+ self.pen.setWidthF(1.5)
+ self.pen.setStyle(QtCore.Qt.DotLine)
+
+ minimum_width = self.mw.RUBBERBAND_MIN * self.view.width()
+ minimum_height = self.mw.RUBBERBAND_MIN * self.view.height()
+ rect = QPaintEvent.rect()
+
+ # Zoom rect must be at least RUBBERBAND_MIN % of the view size.
+ if rect.width() < minimum_width or rect.height() < minimum_height:
+ self.brush.setStyle(QtCore.Qt.NoBrush)
+ self.allow_zoom = False
+ else:
+ color = QtGui.QColor(10, 30, 140, 45)
+ self.brush.setColor(color)
+ self.brush.setStyle(QtCore.Qt.SolidPattern)
+ self.allow_zoom = True
+
+ painter.setBrush(self.brush)
+ painter.setPen(self.pen)
+ painter.drawRect(rect)
+ finally:
+ painter.end()
diff --git a/src/GridElliptic.py b/src/GridElliptic.py
new file mode 100644
index 0000000..ccbcea1
--- /dev/null
+++ b/src/GridElliptic.py
@@ -0,0 +1,203 @@
+"""Elliptic grid solver for the Structured engine (phase 2).
+
+Winslow/TTM equations with Thomas–Middlecoff control functions, solved by
+an under-relaxed Jacobi sweep on a (nj, ni, 2) rows array (eta = axis 0,
+xi = axis 1). The control functions are computed from the boundary point
+distributions, which makes a grid whose spacing already matches its
+boundaries (e.g. the geometrically clustered TFI initial grid on a
+rectangle) a fixed point — pure Winslow would uniformize it and destroy
+the first-layer clustering.
+
+Periodic xi (O-grids) treats the seam column as interior: the seam
+relaxes, and both duplicate columns stay welded. Near-wall rows are
+anchored to the initial grid (releasing quadratically over
+``wall_anchor_rows``) because harmonic maps otherwise invert the first
+cell at convex wall corners (TE wedge, blunt base corners).
+
+Outer-boundary orthogonality is a simplified Sorenson-style forcing: the
+row next to the outer boundary is nudged toward the foot of the local
+boundary normal each sweep. The full P,Q source-term iteration is
+deliberately not implemented at this grid scale.
+
+``solve`` takes an initial grid and never builds one — the same entry
+point doubles as the elliptic smoother in phase 5.
+"""
+from __future__ import annotations
+
+import numpy as np
+
+_EPS = 1.0e-12
+_CONTROL_CLIP = 1.5
+
+
+def _phi_along_row(row: np.ndarray, periodic: bool) -> np.ndarray:
+ """Thomas–Middlecoff xi-control on one boundary row."""
+ if periodic:
+ forward = np.roll(row, -1, axis=0)
+ backward = np.roll(row, 1, axis=0)
+ tangent = 0.5 * (forward - backward)
+ second = forward - 2.0 * row + backward
+ else:
+ tangent = 0.5 * (row[2:] - row[:-2])
+ second = row[2:] - 2.0 * row[1:-1] + row[:-2]
+ numerator = np.sum(tangent * second, axis=-1)
+ denominator = np.sum(tangent * tangent, axis=-1) + _EPS
+ return np.clip(-numerator / denominator, -_CONTROL_CLIP, _CONTROL_CLIP)
+
+
+def _psi_along_column(column: np.ndarray) -> np.ndarray:
+ """Thomas–Middlecoff eta-control on one side column (interior rows)."""
+ tangent = 0.5 * (column[2:] - column[:-2])
+ second = column[2:] - 2.0 * column[1:-1] + column[:-2]
+ numerator = np.sum(tangent * second, axis=-1)
+ denominator = np.sum(tangent * tangent, axis=-1) + _EPS
+ return np.clip(-numerator / denominator, -_CONTROL_CLIP, _CONTROL_CLIP)
+
+
+def _outer_orthogonality_step(rows: np.ndarray, periodic: bool,
+ ortho_relaxation: float) -> None:
+ outer = rows[-1]
+ if periodic:
+ forward = np.roll(outer[:-1], -1, axis=0)
+ backward = np.roll(outer[:-1], 1, axis=0)
+ tangent = np.empty_like(outer)
+ tangent[:-1] = 0.5 * (forward - backward)
+ tangent[-1] = tangent[0]
+ else:
+ tangent = np.gradient(outer, axis=0)
+ lengths = np.linalg.norm(tangent, axis=1)
+ lengths[lengths == 0.0] = 1.0
+ normal = np.column_stack((tangent[:, 1], -tangent[:, 0])) / lengths[:, None]
+ inward = rows[-2] - outer
+ flip = np.sum(normal * inward, axis=1) < 0.0
+ normal[flip] = -normal[flip]
+ distance = np.linalg.norm(inward, axis=1)
+ target = outer + distance[:, None] * normal
+ rows[-2] = ((1.0 - ortho_relaxation) * rows[-2] +
+ ortho_relaxation * target)
+ if periodic:
+ rows[-2, -1] = rows[-2, 0]
+
+
+def solve(rows: np.ndarray, *, periodic: bool = False,
+ iterations: int = 100, relaxation: float = 0.8,
+ tolerance: float = 1.0e-8, control: str = 'thomas_middlecoff',
+ outer_orthogonal: bool = False,
+ ortho_relaxation: float = 0.3,
+ wall_anchor_rows: int = 4) -> tuple[np.ndarray, dict]:
+ if control not in ('thomas_middlecoff', 'none'):
+ raise ValueError(f'Unknown elliptic control: {control!r}.')
+ rows = np.array(rows, dtype=float, copy=True)
+ nj, ni = rows.shape[:2]
+ if nj < 3 or ni < 3 or iterations <= 0:
+ return rows, {'iterations': 0, 'residual': 0.0, 'converged': True}
+
+ eta_fraction = (np.arange(1, nj - 1) / (nj - 1))[:, None]
+
+ # Control functions are frozen from the initial grid (standard TTM
+ # practice): the boundary rows are Dirichlet anyway, and the periodic
+ # seam column must not feed back into its own control as it relaxes.
+ if control == 'thomas_middlecoff':
+ phi_wall = _phi_along_row(rows[0, :-1] if periodic
+ else rows[0], periodic)
+ phi_outer = _phi_along_row(rows[-1, :-1] if periodic
+ else rows[-1], periodic)
+ phi = ((1.0 - eta_fraction) * phi_wall[None, :] +
+ eta_fraction * phi_outer[None, :])
+ interior_columns = (ni - 1) if periodic else (ni - 2)
+ if periodic:
+ psi_profile = _psi_along_column(rows[:, 0])
+ psi = np.repeat(psi_profile[:, None], interior_columns, axis=1)
+ else:
+ xi_fraction = (np.arange(1, ni - 1) / (ni - 1))[None, :]
+ psi_start = _psi_along_column(rows[:, 0])
+ psi_end = _psi_along_column(rows[:, -1])
+ psi = ((1.0 - xi_fraction) * psi_start[:, None] +
+ xi_fraction * psi_end[:, None])
+ else:
+ phi = 0.0
+ psi = 0.0
+ phi3 = np.asarray(phi)[..., None] if np.ndim(phi) else 0.0
+ psi3 = np.asarray(psi)[..., None] if np.ndim(psi) else 0.0
+
+ # Harmonic maps squeeze the first cell at convex wall corners (sharp
+ # or reflexed TE wedge, blunt base corners) until it inverts. Anchor
+ # the near-wall rows to the initial grid — whose near-wall structure
+ # comes from the clustering/ortho construction and is already good —
+ # with a weight that releases smoothly away from the wall.
+ initial_interior = rows[1:-1].copy()
+ if wall_anchor_rows > 0:
+ release = np.minimum(
+ np.arange(1, nj - 1) / float(wall_anchor_rows),
+ 1.0)[:, None, None] ** 2
+ else:
+ release = None
+
+ residual = np.inf
+ iteration = 0
+ for iteration in range(1, iterations + 1):
+ if periodic:
+ u = rows[:, :-1]
+ east = np.roll(u, -1, axis=1)
+ west = np.roll(u, 1, axis=1)
+ e = east[1:-1]
+ w = west[1:-1]
+ n = u[2:]
+ s = u[:-2]
+ ne = east[2:]
+ se = east[:-2]
+ nw = west[2:]
+ sw = west[:-2]
+ center = u[1:-1]
+ else:
+ e = rows[1:-1, 2:]
+ w = rows[1:-1, :-2]
+ n = rows[2:, 1:-1]
+ s = rows[:-2, 1:-1]
+ ne = rows[2:, 2:]
+ se = rows[:-2, 2:]
+ nw = rows[2:, :-2]
+ sw = rows[:-2, :-2]
+ center = rows[1:-1, 1:-1]
+
+ x_xi = 0.5 * (e - w)
+ x_eta = 0.5 * (n - s)
+ alpha = np.sum(x_eta * x_eta, axis=-1)
+ gamma = np.sum(x_xi * x_xi, axis=-1)
+ beta = np.sum(x_xi * x_eta, axis=-1)
+ cross = ne - nw - se + sw
+
+ alpha3 = alpha[..., None]
+ gamma3 = gamma[..., None]
+ beta3 = beta[..., None]
+
+ candidate = (
+ alpha3 * ((1.0 + 0.5 * phi3) * e + (1.0 - 0.5 * phi3) * w) +
+ gamma3 * ((1.0 + 0.5 * psi3) * n + (1.0 - 0.5 * psi3) * s) -
+ 0.5 * beta3 * cross
+ ) / (2.0 * (alpha3 + gamma3) + _EPS)
+
+ updated = (1.0 - relaxation) * center + relaxation * candidate
+ if release is not None:
+ anchor = initial_interior[:, :-1] if periodic \
+ else initial_interior[:, 1:-1]
+ updated = (1.0 - release) * anchor + release * updated
+ residual = float(np.max(np.abs(updated - center)))
+ if periodic:
+ rows[1:-1, :-1] = updated
+ rows[:, -1] = rows[:, 0]
+ else:
+ rows[1:-1, 1:-1] = updated
+
+ if outer_orthogonal:
+ _outer_orthogonality_step(rows, periodic, ortho_relaxation)
+
+ if residual < tolerance:
+ break
+
+ info = {
+ 'iterations': iteration,
+ 'residual': residual,
+ 'converged': residual < tolerance,
+ }
+ return rows, info
diff --git a/src/GridHyperbolic.py b/src/GridHyperbolic.py
new file mode 100644
index 0000000..4a28eea
--- /dev/null
+++ b/src/GridHyperbolic.py
@@ -0,0 +1,233 @@
+"""Hyperbolic grid generation for the Structured engine (phase 3).
+
+Simplified Steger–Chan-style front marching with an adaptive hand-off:
+
+1. Layers advance along smoothed front normals by a per-column geometric
+ height schedule. An implicit variable-coefficient smoothing solve
+ ``(I - d/ds eps d/ds) r = r_predicted`` along the layer plays the role
+ of the scheme's dissipation; ``eps`` scales with (marching step /
+ local tangential spacing)^2, so crossings are dissolved exactly where
+ columns are fine relative to the step (trailing edges) without
+ disturbing coarsely spaced regions. Cyclic solve for periodic O-grids;
+ C-mesh front ends are pinned to the outlet plane every step.
+2. The march continues while every new cell band stays positively
+ oriented, up to ``fraction_cap`` of the wall-to-farfield height.
+ Hyperbolic marching cannot terminate on a prescribed boundary (the
+ design spec's acknowledged tension), so the remaining height is filled
+ by a TFI boundary-value solve from the marched front onto the exact
+ outer distribution — the same composition the ortho block uses.
+3. If the combined grid still contains inverted cells, the hand-off
+ point backs off geometrically; the final fallback is a pure TFI fill
+ (logged) — the algorithm degrades gracefully instead of returning a
+ folded grid.
+
+The full linearized Steger–Chan system (area source + orthogonality
+relation) is deliberately not implemented; the predictor/implicit-
+smoothing pair is its standard lightweight surrogate at this grid scale.
+"""
+from __future__ import annotations
+
+import logging
+
+import numpy as np
+from scipy import linalg
+
+import GridTFI
+from StructuredCore import GridFrame, cell_jacobians, geometric_distances
+from StructuredTopologies import side_segment
+
+logger = logging.getLogger(__name__)
+
+_NORMAL_SMOOTHING_PASSES = 3
+_EPS_MAX = 100.0
+
+
+def _rotate(tangents: np.ndarray) -> np.ndarray:
+ lengths = np.linalg.norm(tangents, axis=1)
+ lengths[lengths == 0.0] = 1.0
+ return np.column_stack(
+ (tangents[:, 1], -tangents[:, 0])) / lengths[:, None]
+
+
+def _front_normals(front: np.ndarray, reference: np.ndarray,
+ periodic: bool) -> np.ndarray:
+ if periodic:
+ unique = front[:-1]
+ tangents = np.roll(unique, -1, axis=0) - np.roll(unique, 1, axis=0)
+ tangents = np.vstack((tangents, tangents[:1]))
+ else:
+ tangents = np.gradient(front, axis=0)
+ normals = _rotate(tangents)
+ flip = np.sum(normals * reference, axis=1) < 0.0
+ normals[flip] = -normals[flip]
+ return normals
+
+
+def _smooth_normals(normals: np.ndarray, periodic: bool,
+ passes: int = _NORMAL_SMOOTHING_PASSES) -> np.ndarray:
+ smoothed = normals.copy()
+ for _ in range(passes):
+ if periodic:
+ unique = smoothed[:-1]
+ averaged = (0.25 * np.roll(unique, 1, axis=0) +
+ 0.5 * unique +
+ 0.25 * np.roll(unique, -1, axis=0))
+ smoothed = np.vstack((averaged, averaged[:1]))
+ else:
+ averaged = smoothed.copy()
+ averaged[1:-1] = (0.25 * smoothed[:-2] + 0.5 * smoothed[1:-1] +
+ 0.25 * smoothed[2:])
+ smoothed = averaged
+ lengths = np.linalg.norm(smoothed, axis=1)
+ lengths[lengths == 0.0] = 1.0
+ smoothed /= lengths[:, None]
+ return smoothed
+
+
+def _solve_banded(lower: np.ndarray, diagonal: np.ndarray,
+ upper: np.ndarray, rhs: np.ndarray) -> np.ndarray:
+ """Tridiagonal solve; lower[0] and upper[-1] are ignored."""
+ m = len(diagonal)
+ ab = np.zeros((3, m))
+ ab[0, 1:] = upper[:-1]
+ ab[1] = diagonal
+ ab[2, :-1] = lower[1:]
+ return linalg.solve_banded((1, 1), ab, rhs)
+
+
+def _cyclic_solve(lower: np.ndarray, diagonal: np.ndarray,
+ upper: np.ndarray, rhs: np.ndarray,
+ corner_upper: float, corner_lower: float) -> np.ndarray:
+ """Cyclic tridiagonal solve via Sherman–Morrison.
+
+ corner_upper is A[0, m-1], corner_lower is A[m-1, 0].
+ """
+ m = len(diagonal)
+ gamma = -diagonal[0]
+ modified = diagonal.copy()
+ modified[0] -= gamma
+ modified[-1] -= corner_upper * corner_lower / gamma
+ x = _solve_banded(lower, modified, upper, rhs)
+ u = np.zeros(m)
+ u[0] = gamma
+ u[-1] = corner_lower
+ z = _solve_banded(lower, modified, upper, u)
+ factor = (x[0] + corner_upper * x[-1] / gamma) / \
+ (1.0 + z[0] + corner_upper * z[-1] / gamma)
+ if x.ndim == 2:
+ return x - factor[None, :] * z[:, None]
+ return x - factor * z
+
+
+def _metric_smooth(values: np.ndarray, step: np.ndarray, strength: float,
+ periodic: bool) -> np.ndarray:
+ """Implicit metric-aware smoothing along the layer."""
+ if strength <= 0.0:
+ return values
+ if periodic:
+ unique = values[:-1]
+ step_unique = step[:-1]
+ spacing = np.linalg.norm(
+ np.roll(unique, -1, axis=0) - unique, axis=1)
+ eps_half = np.minimum(
+ strength * (0.5 * (step_unique + np.roll(step_unique, -1)) /
+ np.maximum(spacing, 1.0e-12)) ** 2,
+ _EPS_MAX)
+ eps_minus = np.roll(eps_half, 1)
+ lower = -eps_minus
+ upper = -eps_half
+ diagonal = 1.0 + eps_minus + eps_half
+ solution = _cyclic_solve(lower, diagonal, upper, unique,
+ corner_upper=-eps_half[-1],
+ corner_lower=-eps_half[-1])
+ return np.vstack((solution, solution[:1]))
+
+ spacing = np.linalg.norm(values[1:] - values[:-1], axis=1)
+ step_half = 0.5 * (step[1:] + step[:-1])
+ eps_half = np.minimum(
+ strength * (step_half / np.maximum(spacing, 1.0e-12)) ** 2,
+ _EPS_MAX)
+ m = len(values)
+ lower = np.zeros(m)
+ upper = np.zeros(m)
+ diagonal = np.ones(m)
+ lower[1:-1] = -eps_half[:-1]
+ upper[1:-1] = -eps_half[1:]
+ diagonal[1:-1] = 1.0 + eps_half[:-1] + eps_half[1:]
+ return _solve_banded(lower, diagonal, upper, values)
+
+
+def march(frame: GridFrame, *, normals: np.ndarray, first_spacing: float,
+ fraction_cap: float = 0.5,
+ smoothing: float = 1.0) -> np.ndarray:
+ wall = np.asarray(frame.wall, dtype=float)
+ outer = np.asarray(frame.outer, dtype=float)
+ nj = len(frame.side_start)
+ periodic = frame.periodic
+ x_outlet = frame.metadata.get('x_outlet')
+
+ lengths = np.linalg.norm(outer - wall, axis=1)
+ mean_length = float(np.mean(lengths))
+ fractions = geometric_distances(mean_length, first_spacing,
+ nj) / mean_length
+ heights = fractions[:, None] * lengths[None, :]
+ steps = np.diff(heights, axis=0)
+
+ marched = [wall.copy()]
+ front = wall.copy()
+ reference = np.asarray(normals, dtype=float)
+ target_sign = None
+
+ for layer in range(1, nj - 2):
+ if fractions[layer] > fraction_cap:
+ break
+ smoothed = _smooth_normals(reference, periodic)
+ step = steps[layer - 1]
+ predicted = front + step[:, None] * smoothed
+ predicted = _metric_smooth(predicted, step, smoothing, periodic)
+ if not periodic and x_outlet is not None:
+ predicted[0, 0] = x_outlet
+ predicted[-1, 0] = x_outlet
+ if periodic:
+ predicted[-1] = predicted[0]
+ band = cell_jacobians(np.stack((front, predicted)))
+ if target_sign is None:
+ target_sign = np.sign(np.median(band))
+ if np.any(np.sign(band) != target_sign):
+ break
+ marched.append(predicted)
+ reference = _front_normals(predicted, reference, periodic)
+ front = predicted
+
+ def assemble(marched_layers: int) -> np.ndarray:
+ hand_off = marched[marched_layers]
+ step_index = min(marched_layers, len(steps) - 1)
+ next_spacing = float(np.mean(steps[step_index]))
+ count = nj - marched_layers
+ start = side_segment(hand_off[0], outer[0], next_spacing, count)
+ end = start.copy() if periodic else side_segment(
+ hand_off[-1], outer[-1], next_spacing, count)
+ reduced = GridFrame(
+ wall=hand_off, outer=outer, side_start=start, side_end=end,
+ kind=frame.kind, te_type=frame.te_type, periodic=periodic,
+ metadata=dict(frame.metadata),
+ )
+ outer_rows = GridTFI.fill(reduced)
+ return np.vstack((np.array(marched[:marched_layers + 1]),
+ outer_rows[1:]))
+
+ marched_layers = len(marched) - 1
+ while marched_layers >= 2:
+ rows = assemble(marched_layers)
+ jacobians = cell_jacobians(rows)
+ if np.all(jacobians > 0.0) or np.all(jacobians < 0.0):
+ logger.info(
+ 'Hyperbolic march: %d of %d layers marched, remainder '
+ 'filled algebraically.', marched_layers, nj - 1)
+ return rows
+ marched_layers = int(0.75 * marched_layers)
+
+ logger.warning(
+ 'Hyperbolic march could not produce a valid grid for this frame; '
+ 'falling back to a pure TFI fill.')
+ return GridTFI.fill(frame)
diff --git a/src/GridSmoothers.py b/src/GridSmoothers.py
new file mode 100644
index 0000000..5359880
--- /dev/null
+++ b/src/GridSmoothers.py
@@ -0,0 +1,162 @@
+"""Post-smoothing stage for the Structured engine (phase 5).
+
+A smoother is an optional pass over an already-filled ``(nj, ni, 2)`` rows
+array, independent of which topology, tunnel shape, or volume algorithm
+produced it. It never touches the frozen rows (the wall plus any ortho
+layers, which carry the framework's exact-spacing and exact-normal
+invariants) or the outer row (the prescribed farfield boundary); side
+columns stay fixed too, unless the frame is periodic (an O-grid seam,
+which is interior and allowed to relax exactly like it does in
+``GridElliptic``).
+
+Three methods:
+
+- ``laplacian``: under-relaxed 4-neighbor averaging. Cheap relief for
+ local kinks (e.g. a TFI wedge at a sharp TE) without the cost of a full
+ elliptic solve.
+- ``elliptic``: delegates to ``GridElliptic.solve`` on the sub-grid from
+ the last frozen row (acting as its Dirichlet wall) to the outer row —
+ the same solver phase 2 uses to build a grid, reused here to relax an
+ existing one, exactly as the design spec anticipated.
+- ``angle_based``: Zhou-Shimada-style relaxation. For each unfrozen node
+ P with neighbors E, N, W, S, the locus of points from which a segment
+ between two given points subtends a right angle is the circle with
+ that segment as diameter (Thales' theorem). For each of the four
+ neighbor pairs around P — (E,N), (N,W), (W,S), (S,E) — the point on
+ that pair's Thales circle nearest to P is exactly the position that
+ would make P's edges meet those two neighbors at 90 degrees. P is
+ relaxed toward the average of the four projections.
+
+Quality is ``min(abs(cell_jacobian))`` over the whole grid. If a method
+would leave the grid worse than it started, ``smooth`` returns the
+original rows unchanged (logged) — smoothers are guaranteed to never
+degrade the mesh, not merely expected to.
+"""
+from __future__ import annotations
+
+import logging
+
+import numpy as np
+
+import GridElliptic
+from StructuredCore import cell_jacobians
+
+logger = logging.getLogger(__name__)
+
+_METHODS = ('laplacian', 'elliptic', 'angle_based')
+_THALES_EPS = 1.0e-12
+
+
+def _quality(rows: np.ndarray) -> float:
+ return float(np.min(np.abs(cell_jacobians(rows))))
+
+
+def _neighbors(rows: np.ndarray, periodic: bool, frozen_rows: int):
+ """North/south/east/west neighbor arrays and the point array itself,
+ aligned over the unfrozen interior (rows frozen_rows .. nj-2,
+ columns 1..ni-2, or all periodic columns for an O-grid seam)."""
+ interior = rows[frozen_rows:-1]
+ north = rows[frozen_rows + 1:]
+ south = rows[frozen_rows - 1:-2]
+ if periodic:
+ unique = interior[:, :-1]
+ point = unique
+ east = np.roll(unique, -1, axis=1)
+ west = np.roll(unique, 1, axis=1)
+ north = north[:, :-1]
+ south = south[:, :-1]
+ else:
+ point = interior[:, 1:-1]
+ east = interior[:, 2:]
+ west = interior[:, :-2]
+ north = north[:, 1:-1]
+ south = south[:, 1:-1]
+ return point, north, south, east, west
+
+
+def _write_back(rows: np.ndarray, updated: np.ndarray, periodic: bool,
+ frozen_rows: int) -> None:
+ if periodic:
+ rows[frozen_rows:-1, :-1] = updated
+ rows[:, -1] = rows[:, 0]
+ else:
+ rows[frozen_rows:-1, 1:-1] = updated
+
+
+def _laplacian_pass(rows: np.ndarray, periodic: bool, frozen_rows: int,
+ relaxation: float) -> None:
+ point, north, south, east, west = _neighbors(rows, periodic,
+ frozen_rows)
+ candidate = 0.25 * (north + south + east + west)
+ updated = (1.0 - relaxation) * point + relaxation * candidate
+ _write_back(rows, updated, periodic, frozen_rows)
+
+
+def _thales_projection(point: np.ndarray, a: np.ndarray,
+ b: np.ndarray) -> np.ndarray:
+ mid = 0.5 * (a + b)
+ radius = 0.5 * np.linalg.norm(a - b, axis=-1)
+ offset = point - mid
+ length = np.linalg.norm(offset, axis=-1)
+ safe = length > _THALES_EPS
+ unit = np.divide(offset, length[..., None], out=np.zeros_like(offset),
+ where=safe[..., None])
+ projected = mid + radius[..., None] * unit
+ return np.where(safe[..., None], projected, point)
+
+
+def _angle_based_pass(rows: np.ndarray, periodic: bool, frozen_rows: int,
+ relaxation: float) -> None:
+ point, north, south, east, west = _neighbors(rows, periodic,
+ frozen_rows)
+ targets = (
+ _thales_projection(point, east, north) +
+ _thales_projection(point, north, west) +
+ _thales_projection(point, west, south) +
+ _thales_projection(point, south, east)
+ ) / 4.0
+ updated = (1.0 - relaxation) * point + relaxation * targets
+ _write_back(rows, updated, periodic, frozen_rows)
+
+
+def smooth(rows: np.ndarray, *, method: str, iterations: int = 10,
+ periodic: bool = False, frozen_rows: int = 1,
+ relaxation: float = 0.5) -> tuple[np.ndarray, dict]:
+ if method not in _METHODS:
+ raise ValueError(f'Unknown smoother method: {method!r}.')
+ if frozen_rows < 1:
+ raise ValueError('frozen_rows must be at least 1 (the wall row).')
+
+ original = np.array(rows, dtype=float, copy=True)
+ quality_before = _quality(original)
+ nj = original.shape[0]
+
+ if frozen_rows >= nj - 1 or iterations <= 0:
+ return original, {'quality_before': quality_before,
+ 'quality_after': quality_before}
+
+ if method == 'elliptic':
+ sub_grid = original[frozen_rows - 1:]
+ solved, _info = GridElliptic.solve(sub_grid, periodic=periodic,
+ iterations=iterations,
+ relaxation=relaxation)
+ candidate = original.copy()
+ candidate[frozen_rows - 1:] = solved
+ else:
+ candidate = original.copy()
+ pass_fn = (_laplacian_pass if method == 'laplacian'
+ else _angle_based_pass)
+ for _ in range(iterations):
+ pass_fn(candidate, periodic, frozen_rows, relaxation)
+
+ quality_after = _quality(candidate)
+ if quality_after < quality_before:
+ logger.warning(
+ 'Smoother %r would degrade grid quality (%.3e -> %.3e); '
+ 'returning the unsmoothed grid.',
+ method, quality_before, quality_after)
+ return original, {'quality_before': quality_before,
+ 'quality_after': quality_before}
+
+ return candidate, {'quality_before': quality_before,
+ 'quality_after': quality_after}
diff --git a/src/GridTFI.py b/src/GridTFI.py
new file mode 100644
index 0000000..e3ba43c
--- /dev/null
+++ b/src/GridTFI.py
@@ -0,0 +1,115 @@
+"""Transfinite interpolation (standard + Hermite) on a GridFrame.
+
+Method
+------
+- Parameters: ``eta_j`` is the mean of the two sides' normalized cumulative
+ arclength (this carries the geometric first-layer clustering into the
+ interior); ``xi[j, i] = (1 - eta_j) * xi_wall[i] + eta_j * xi_outer[i]``
+ with ``xi_wall`` / ``xi_outer`` the normalized cumulative arclengths of
+ wall and outer.
+- Standard variant: Boolean-sum Coons patch over the four frame boundaries.
+- Hermite variant: transverse cubic Hermite blending with prescribed
+ end derivatives for tangent control at wall and outer boundary
+ (``TunnelBoundaryControl.angle_mode``: 'orthogonal' forces mesh lines
+ perpendicular onto the tunnel boundary, 'free' uses the wall->outer
+ secant), plus a linear side-conformity correction.
+- All four boundary rows/columns of the result are the frame arrays
+ verbatim (copied, never recomputed).
+"""
+from __future__ import annotations
+
+import numpy as np
+
+from StructuredCore import GridFrame, TunnelBoundaryControl
+
+
+def _normalized_arclength(points: np.ndarray) -> np.ndarray:
+ deltas = np.diff(points, axis=0)
+ cumulative = np.concatenate(
+ ([0.0], np.cumsum(np.hypot(deltas[:, 0], deltas[:, 1]))))
+ total = cumulative[-1]
+ if total <= 0.0:
+ return np.linspace(0.0, 1.0, len(points))
+ return cumulative / total
+
+
+def _unit_normals(points: np.ndarray) -> np.ndarray:
+ tangents = np.gradient(points, axis=0)
+ lengths = np.linalg.norm(tangents, axis=1)
+ lengths[lengths == 0.0] = 1.0
+ return np.column_stack((tangents[:, 1], -tangents[:, 0])) / lengths[:, None]
+
+
+def fill(frame: GridFrame, variant: str = 'standard',
+ boundary_control: TunnelBoundaryControl | None = None) -> np.ndarray:
+ if variant not in ('standard', 'hermite'):
+ raise ValueError(f'Unknown TFI variant: {variant!r}.')
+ control = boundary_control or TunnelBoundaryControl()
+
+ wall = np.asarray(frame.wall, dtype=float)
+ outer = np.asarray(frame.outer, dtype=float)
+ side_start = np.asarray(frame.side_start, dtype=float)
+ side_end = np.asarray(frame.side_end, dtype=float)
+
+ eta = 0.5 * (_normalized_arclength(side_start) +
+ _normalized_arclength(side_end)) # (nj,)
+ xi_wall = _normalized_arclength(wall) # (ni,)
+ xi_outer = _normalized_arclength(outer)
+ xi = ((1.0 - eta)[:, None] * xi_wall[None, :] +
+ eta[:, None] * xi_outer[None, :]) # (nj, ni)
+
+ eta_grid = eta[:, None, None]
+ xi_grid = xi[..., None]
+
+ if variant == 'standard':
+ rows = (
+ (1.0 - eta_grid) * wall[None, :, :] +
+ eta_grid * outer[None, :, :] +
+ (1.0 - xi_grid) * side_start[:, None, :] +
+ xi_grid * side_end[:, None, :] -
+ (
+ (1.0 - xi_grid) * (1.0 - eta_grid) * wall[0] +
+ (1.0 - xi_grid) * eta_grid * outer[0] +
+ xi_grid * (1.0 - eta_grid) * wall[-1] +
+ xi_grid * eta_grid * outer[-1]
+ )
+ )
+ else:
+ span = outer - wall # (ni, 2)
+ lengths = np.linalg.norm(span, axis=1)
+ lengths[lengths == 0.0] = 1.0
+
+ wall_normals = _unit_normals(wall)
+ flip = np.sum(wall_normals * span, axis=1) < 0.0
+ wall_normals[flip] = -wall_normals[flip]
+ derivative_wall = lengths[:, None] * wall_normals
+
+ if control.angle_mode == 'orthogonal':
+ outer_normals = _unit_normals(outer)
+ flip = np.sum(outer_normals * span, axis=1) < 0.0
+ outer_normals[flip] = -outer_normals[flip]
+ derivative_outer = lengths[:, None] * outer_normals
+ else:
+ derivative_outer = span
+
+ h00 = 2.0 * eta ** 3 - 3.0 * eta ** 2 + 1.0
+ h01 = -2.0 * eta ** 3 + 3.0 * eta ** 2
+ h10 = eta ** 3 - 2.0 * eta ** 2 + eta
+ h11 = eta ** 3 - eta ** 2
+ rows = (
+ h00[:, None, None] * wall[None, :, :] +
+ h01[:, None, None] * outer[None, :, :] +
+ h10[:, None, None] * derivative_wall[None, :, :] +
+ h11[:, None, None] * derivative_outer[None, :, :]
+ )
+ # linear side-conformity correction
+ rows += (1.0 - xi_wall)[None, :, None] * \
+ (side_start[:, None, :] - rows[:, :1, :])
+ rows += xi_wall[None, :, None] * \
+ (side_end[:, None, :] - rows[:, -1:, :])
+
+ rows[0] = wall
+ rows[-1] = outer
+ rows[:, 0] = side_start
+ rows[:, -1] = side_end
+ return rows
diff --git a/src/GuiSlots.py b/src/GuiSlots.py
index ed04306..ddeb585 100644
--- a/src/GuiSlots.py
+++ b/src/GuiSlots.py
@@ -1,19 +1,24 @@
-import sys
-import copy
-import webbrowser
-import numpy as np
-import scipy
+import sys
+import webbrowser
+import html
+from pathlib import Path
+import numpy as np
+import scipy
import PySide6
from PySide6 import QtGui, QtCore, QtWidgets, QtPrintSupport
-import PyAero
-import Airfoil
-import FileDialog
-import GraphicsTest
-from Settings import DIALOGFILTER, AIRFOILDATA, DEFAULT_CONTOUR
-import logging
-logger = logging.getLogger(__name__)
+import PyAero
+import Airfoil
+import FileDialog
+import FileOperations
+import Icons
+import Mesh as MeshModel
+import PrintLayout
+import UiExport
+from Utils import get_main_window
+import logging
+logger = logging.getLogger(__name__)
class Slots:
@@ -26,386 +31,800 @@ class Slots:
@QtCore.Slot() decorator.
"""
- def __init__(self, parent):
- """Constructor for Slots class
-
- Args:
- parent (QMainWindow object): MainWindow instance
- """
- self.parent = parent
-
- @QtCore.Slot()
- def onOpen(self):
- """Summary
-
- Returns:
- TYPE: Description
- """
- file_dialog = FileDialog.Dialog()
- file_dialog.setFilter(DIALOGFILTER)
- filename, _ = file_dialog.openFilename(directory=AIRFOILDATA)
-
- if not filename:
- logger.info('No file selected. Nothing saved.')
- return
-
- if 'su2' in filename:
- self.loadSU2(filename)
- return
-
- self.loadAirfoil(filename)
-
- @QtCore.Slot()
- def onOpenPredefined(self):
- self.loadAirfoil(DEFAULT_CONTOUR)
+ def __init__(self, mainwindow=None):
+ """Constructor for Slots class"""
+
+ # MainWindow instance
+ self.mw = mainwindow or get_main_window()
+ self._current_print_layout_options = None
+ self._current_print_airfoil = None
+
+ @QtCore.Slot()
+ def onOpen(self):
+ """Open an airfoil contour or a future mesh import target."""
+ file_dialog = FileDialog.Dialog()
+ filename, _ = file_dialog.open_filename(
+ title='Open File',
+ filter=self.openFileDialogFilter(),
+ )
+
+ if not filename:
+ logger.info('No file selected. Nothing opened.')
+ return
+
+ self.openFile(filename)
+
+ def openFileDialogFilter(self):
+ contour_filter = FileOperations.CONTOUR_FILTER
+ mesh_filter = MeshModel.MeshImportRegistry.qt_file_dialog_filter()
+ if not mesh_filter:
+ return contour_filter
+ return f'{contour_filter};;{mesh_filter}'
+
+ @QtCore.Slot(str)
+ def openFile(self, filename):
+ if MeshModel.MeshImportRegistry.can_import(filename):
+ return self.loadMesh(filename)
+ if Path(filename).suffix.lower() not in FileOperations.SUPPORTED_AIRFOIL_EXTENSIONS:
+ message = f'Unsupported file type: {Path(filename).name}'
+ logger.warning('%s (%s)', message, filename)
+ self.messageBox(message)
+ return None
+ return self.loadAirfoil(filename)
+
+ @QtCore.Slot()
+ def onOpenPredefined(self):
+ self.loadAirfoil(self.mw.DEFAULT_AIRFOIL)
@QtCore.Slot(str, str)
- def loadAirfoil(self, filename, comment='#'):
- fileinfo = QtCore.QFileInfo(filename)
- name = fileinfo.fileName()
-
- airfoil = Airfoil.Airfoil(name)
- loaded = airfoil.readContour(filename, comment)
-
- if not loaded:
- logger.error(f'Failed to load airfoil from {filename}')
- return
-
- self._clearScene()
- self._addAirfoilToScene(airfoil)
- self._updateAirfoilList(name)
- self.fitAirfoilInView()
- logger.info(f'Airfoil {name} loaded')
+ def loadAirfoil(self, filename, comment='#'):
+ airfoil = Airfoil.Airfoil.from_file(
+ filename,
+ comment=comment,
+ mainwindow=self.mw,
+ )
+ if airfoil is None:
+ logger.error(f'Failed to load airfoil from {filename}')
+ self.messageBox(f'Failed to load airfoil:\n{filename}')
+ return
+
+ self._registerAirfoil(airfoil)
+ self.activateAirfoil(airfoil)
+ logger.info(f'Airfoil {airfoil.name} loaded')
def _clearScene(self):
- self.parent.scene.clear()
+ self.mw.scene.clear()
def _addAirfoilToScene(self, airfoil):
airfoil.makeAirfoil()
- airfoil.addToScene(self.parent.scene)
- self.parent.airfoil = airfoil
- self.parent.airfoils.append(airfoil)
-
- def _updateAirfoilList(self, name):
- toolbox = self.parent.centralwidget.toolbox
- toolbox.header.setEnabled(True)
- toolbox.listwidget.setEnabled(True)
- toolbox.listwidget.addItem(name)
-
- @QtCore.Slot(str)
- def loadSU2(self, filename):
- comment = '%'
-
- try:
- with open(filename, mode='r') as f:
- lines = f.readlines()
- except IOError as error:
- # exc_info=True sends traceback to the logger
- logger.error('Failed to open file {} with error {}'.
- format(filename, error), exc_info=True)
- return False
-
- # FIXME
- # FIXME complete code to read SU2 mesh files
- # FIXME
- data = [line for line in lines if comment not in line]
+ airfoil.addToScene(self.mw.scene)
+ self.mw.airfoil = airfoil
+
+ def _registerAirfoil(self, airfoil):
+ self.mw.airfoils = [airfoil]
+ toolbox = self.mw.mainArea.toolbox
+ toolbox.refreshAirfoilLibrary()
+ toolbox.selectAirfoilLibraryPath(getattr(airfoil, 'source_path', None))
+ toolbox.refreshWorkflowState()
+
+ def _removeAirfoilListEntry(self, name):
+ toolbox = self.mw.mainArea.toolbox
+ toolbox.refreshAirfoilLibrary()
+ toolbox.selectAirfoilLibraryPath(None)
+ toolbox.refreshWorkflowState()
+
+ def _selectAirfoilInList(self, airfoil):
+ self.mw.mainArea.toolbox.selectAirfoilLibraryPath(
+ getattr(airfoil, 'source_path', None)
+ )
+
+ def activateAirfoil(self, airfoil):
+ if airfoil is None:
+ return
+
+ self.mw._viewer_subject = 'airfoil'
+ self._clearScene()
+ self._addAirfoilToScene(airfoil)
+ self._selectAirfoilInList(airfoil)
+ self.mw.mainArea.toolbox.refreshWorkflowState()
+ self.fitAirfoilInView()
+
+ def _expandedRect(self, rectf, padding_factor=1.0):
+ rect = QtCore.QRectF(rectf)
+ if rect.isNull() or padding_factor == 1.0:
+ return rect
+
+ center = rect.center()
+ rect.setWidth(rect.width() * padding_factor)
+ rect.setHeight(rect.height() * padding_factor)
+ rect.moveCenter(center)
+ return rect
+
+ def _fitViewToRect(self, rectf, padding_factor=1.0):
+ rect = self._expandedRect(rectf, padding_factor=padding_factor)
+ if rect.isNull():
+ return
+
+ self.mw.view.fitInView(rect, QtCore.Qt.KeepAspectRatio)
+ self.mw.view.adjustMarkerSize()
+ self.mw.view.getSceneFromView()
+
+ def _airfoilContourRect(self, airfoil):
+ contour = airfoil.current_contour(prefer_spline=True)
+ if contour is None:
+ return QtCore.QRectF()
+
+ x_values, y_values = contour
+ min_x = float(min(x_values))
+ max_x = float(max(x_values))
+ min_y = float(min(y_values))
+ max_y = float(max(y_values))
+ return QtCore.QRectF(min_x, min_y, max_x - min_x, max_y - min_y)
+
+ @QtCore.Slot(str)
+ def loadMesh(self, filename):
+ try:
+ return MeshModel.MeshData.from_file(filename)
+ except NotImplementedError as error:
+ logger.info(str(error))
+ self.messageBox(str(error))
+ return None
+ except (OSError, ValueError) as error:
+ logger.error(
+ 'Failed to load mesh file %s with error %s',
+ filename,
+ error,
+ exc_info=True,
+ )
+ self.messageBox(f'Failed to load mesh file:\n{filename}\n\n{error}')
+ return None
+
+ @QtCore.Slot(str)
+ def loadSU2(self, filename):
+ return self.loadMesh(filename)
+
+ @QtCore.Slot()
+ def fitAirfoilInView(self):
+
+ airfoil = getattr(self.mw, 'airfoil', None)
+ if airfoil is None:
+ return
+
+ rect = self._airfoilContourRect(airfoil)
+ if rect.isNull():
+ return
+ self._fitViewToRect(rect, padding_factor=1.04)
@QtCore.Slot()
- def fitAirfoilInView(self):
-
- if len(self.parent.airfoils) == 0:
+ def onViewAll(self):
+ """Zoom view in order to fit all items of the scene"""
+
+ # take all items except markers (as they are adjusted in size for view)
+ self._fitViewToRect(self.mw.scene.itemsBoundingRect())
+
+
+ @QtCore.Slot()
+ def onSave(self):
+ self.saveCurrentAirfoilContour(title='Save Contour')
+
+ @QtCore.Slot()
+ def onSaveAs(self):
+ self.saveCurrentAirfoilContour(title='Save Contour As')
+
+ def saveCurrentAirfoilContour(self, title='Save Contour As'):
+ airfoil = getattr(self.mw, 'airfoil', None)
+ if airfoil is None:
+ self.messageBox('No airfoil loaded.')
+ return None
+
+ filename = FileOperations.choose_contour_save_filename(
+ airfoil,
+ title=title,
+ mainwindow=self.mw,
+ )
+ if not filename:
+ logger.info('No file selected. Nothing saved.')
+ return None
+
+ return FileOperations.write_contour(
+ airfoil,
+ filename,
+ prefer_spline=True,
+ mainwindow=self.mw,
+ )
+
+ @QtCore.Slot()
+ def onPrint(self):
+ airfoil = self._activeAirfoilForPrint()
+ if airfoil is None:
+ return
+
+ options = self._promptPrintLayout(airfoil)
+ if options is None:
+ return
+
+ printer = QtPrintSupport.QPrinter(QtPrintSupport.QPrinter.HighResolution)
+ PrintLayout.configure_printer_for_layout(printer, options)
+
+ dialog = QtPrintSupport.QPrintDialog(printer, self.mw)
+ dialog.setWindowTitle('Print Airfoil Drawing')
+ if dialog.exec() != QtWidgets.QDialog.Accepted:
+ return
+
+ self._current_print_airfoil = airfoil
+ self._current_print_layout_options = options
+ self.handlePaintRequest(printer)
+
+ @QtCore.Slot()
+ def onPreview(self):
+ airfoil = self._activeAirfoilForPrint()
+ if airfoil is None:
+ return
+
+ options = self._promptPrintLayout(airfoil)
+ if options is None:
+ return
+
+ printer = QtPrintSupport.QPrinter(QtPrintSupport.QPrinter.HighResolution)
+ PrintLayout.configure_printer_for_layout(printer, options)
+
+ self._current_print_airfoil = airfoil
+ self._current_print_layout_options = options
+ preview = QtPrintSupport.QPrintPreviewDialog(printer, self.mw)
+ preview.paintRequested.connect(self.handlePaintRequest)
+ preview.exec()
+
+ @QtCore.Slot()
+ def handlePaintRequest(self, printer):
+ airfoil = self._current_print_airfoil or getattr(self.mw, 'airfoil', None)
+ if airfoil is None:
+ return
+
+ options = PrintLayout.default_print_layout_options(
+ airfoil,
+ previous=self._current_print_layout_options,
+ )
+ renderer = PrintLayout.AirfoilPrintRenderer()
+
+ painter = QtGui.QPainter(printer)
+ try:
+ renderer.render(painter, printer, airfoil, self.mw.view, options)
+ except ValueError as error:
+ logger.error(
+ 'Failed to render print layout for %s: %s',
+ getattr(airfoil, 'name', 'airfoil'),
+ error,
+ exc_info=True,
+ )
+ self.messageBox(str(error))
+ finally:
+ painter.end()
+
+ def _activeAirfoilForPrint(self):
+ airfoil = getattr(self.mw, 'airfoil', None)
+ if airfoil is None:
+ self.messageBox('No airfoil loaded.')
+ return airfoil
+
+ def _promptPrintLayout(self, airfoil):
+ options = self._current_print_layout_options
+ if options is not None:
+ options = PrintLayout.clone_print_layout_options(options)
+
+ dialog = PrintLayout.PrintLayoutDialog(
+ airfoil,
+ options=options,
+ parent=self.mw,
+ )
+ if dialog.exec() != QtWidgets.QDialog.Accepted:
+ return None
+ return dialog.selected_options()
+
+ def toggleLogDock(self, _sender=None):
+ """Switch message log window on/off"""
+
+ # check if self.mw.messagedock exists
+ if not hasattr(self.mw, 'messagedock'):
return
-
- # get bounding rect in scene coordinates
- item = self.parent.airfoil.contourPolygon
- rectf = item.boundingRect()
- rf = copy.deepcopy(rectf)
-
- center = rf.center()
- # make 4% smaller than width of graphicsview
- w = 1.04 * rf.width()
- h = 1.04 * rf.height()
- # do not use setWidhtF and setHeightF here!!!
- rf.setWidth(w)
- rf.setHeight(h)
-
- # shift center of rectf
- cx = center.x()
- cy = center.y()
- rf.moveCenter(QtCore.QPointF(cx, cy))
-
- self.parent.view.fitInView(rf,
- aspectRadioMode=QtCore.Qt.KeepAspectRatio)
-
- # it is IMPORTANT that this is called after fitInView
- # adjust airfoil marker size to MARKERSIZE setting
- self.parent.view.adjustMarkerSize()
-
- # cache view to be able to keep it during resize
- self.parent.view.getSceneFromView()
-
- @QtCore.Slot()
- def onViewAll(self):
- """Zoom view in order to fit all items of the scene"""
-
- # take all items except markers (as they are adjusted in size for view)
-
- rectf = self.parent.scene.itemsBoundingRect()
- self.parent.view.fitInView(rectf,
- aspectRadioMode=QtCore.Qt.KeepAspectRatio)
-
- # it is IMPORTANT that this is called after fitInView
- # adjust airfoil marker size to MARKERSIZE setting
- self.parent.view.adjustMarkerSize()
-
- # cache view to be able to keep it during resize
- self.parent.view.getSceneFromView()
-
- @QtCore.Slot()
- def toggleTestObjects(self):
- if self.parent.testitems:
- GraphicsTest.deleteTestItems(self.parent.scene)
- logger.info('Test items for GraphicsView loaded')
- else:
- GraphicsTest.addTestItems(self.parent.scene)
- logger.info('Test items for GraphicsView removed')
- self.parent.testitems = not self.parent.testitems
-
- @QtCore.Slot()
- def onSave(self):
- (fname, thefilter) = QtWidgets.QFileDialog. \
- getSaveFileNameAndFilter(self.parent,
- 'Save file', '.', filter=DIALOGFILTER)
- if not fname:
- return
-
- with open(fname, 'w') as f:
- f.write('This test worked for me ...')
-
- @QtCore.Slot()
- def onSaveAs(self):
- (fname, thefilter) = QtGui. \
- QFileDialog.getSaveFileNameAndFilter(
- self.parent, 'Save file as ...', '.',
- filter=DIALOGFILTER)
- if not fname:
- return
- with open(fname, 'w') as f:
- f.write('This test worked for me ...')
-
- @QtCore.Slot()
- def onPrint(self):
- dialog = QtWidgets.QPrintDialog()
- if dialog.exec_() == QtGui.QDialog.Accepted:
- self.parent.editor.document().print_(dialog.printer())
-
- @QtCore.Slot()
- def onPreview(self):
- printer = QtPrintSupport.QPrinter(QtPrintSupport.QPrinter.HighResolution)
- layout = QtGui.QPageLayout()
- layout.setOrientation(QtGui.QPageLayout.Landscape)
- layout.setPageSize(QtGui.QPageSize.A3)
- printer.setPageLayout(layout)
-
- preview = QtPrintSupport.QPrintPreviewDialog(printer, self.parent)
- preview.paintRequested.connect(self.handlePaintRequest)
- preview.exec()
-
- @QtCore.Slot()
- def handlePaintRequest(self, printer):
- # render QGraphicsView
- self.parent.view.render(QtGui.QPainter(printer))
-
- @QtCore.Slot(str)
- def toggleLogDock(self, _sender):
- """Switch message log window on/off"""
-
- visible = self.parent.messagedock.isVisible()
- self.parent.messagedock.setVisible(not visible)
-
- # update the checkbox if toggling is done via keyboard shortcut
- if _sender == 'shortcut':
- checkbox = self.parent.centralwidget.message_window_checkbox
- checkbox.setChecked(not checkbox.isChecked())
-
- @QtCore.Slot()
- def onBlockMesh(self):
- pass
+ visible = self.mw.messagedock.isVisible()
+ if hasattr(self.mw.mainArea, 'setMessagePanelVisible'):
+ self.mw.mainArea.setMessagePanelVisible(not visible)
+ else:
+ self.mw.messagedock.setVisible(not visible)
+
+ checkbox = getattr(self.mw.mainArea, 'message_window_checkbox', None)
+ if checkbox is not None:
+ blocker = QtCore.QSignalBlocker(checkbox)
+ checkbox.setChecked(not visible)
+ del blocker
@QtCore.Slot(str)
def getAirfoilByName(self, name):
- for airfoil in self.parent.airfoils:
+ for airfoil in self.mw.airfoils:
if airfoil.name == name:
return airfoil
return None
@QtCore.Slot()
def removeAirfoil(self, name=None):
- """Remove all selected airfoils from the scene"""
-
- # look also at toolbox listwidget
- centralwidget = self.parent.centralwidget
- listwidget = centralwidget.toolbox.listwidget
+ """Remove the current working airfoil."""
- # the name parameter is only set when coming from listwidget
- # and the deleting is done via DEL key
if name:
airfoil = self.getAirfoilByName(name)
- # FIXME:
- # FIXME: this does not work
- # FIXME: needs to delete the selected airfoil from the listwidget
- # FIXME:
- elif len(listwidget.selectedItems()) > 0:
- name = listwidget.selectedItems()[0].text()
- airfoil = self.getAirfoilByName(name)
- elif self.parent.airfoil:
- airfoil = self.parent.airfoil
+ elif self.mw.airfoil:
+ airfoil = self.mw.airfoil
else:
- print('No airfoil selected for deletion')
+ logger.info('No airfoil selected for deletion')
return
- # remove airfoil from the list in the list widget
- self.parent.airfoils.remove(airfoil)
+ if airfoil is None:
+ logger.info('No matching airfoil found for deletion')
+ return
- # remove from scene only if active airfoil was chosen
- if airfoil.name == self.parent.airfoil.name:
- # removes all items from the scene (polygon, chord, mesh, etc.)
- self.parent.scene.clear()
+ is_active_airfoil = airfoil == self.mw.airfoil
- # remove also listwidget entry
- itms = listwidget.findItems(
- self.parent.airfoil.name, QtCore.Qt.MatchExactly)
- for itm in itms:
- row = listwidget.row(itm)
- listwidget.takeItem(row)
+ if airfoil in self.mw.airfoils:
+ self.mw.airfoils.remove(airfoil)
- # fit all remaining scene items into the view
- self.onViewAll()
+ if is_active_airfoil:
+ self.mw.scene.clear()
+ self.mw.airfoil = None
+ self.mw.mainArea.resetAirfoilViewControls()
+ self._removeAirfoilListEntry(airfoil.name)
+
+ if is_active_airfoil:
+ return
+
+ # keep the current scene and view unchanged when removing
+ # a non-active airfoil entry
+ self.mw.mainArea.toolbox.refreshWorkflowState()
@QtCore.Slot(str)
def onMessage(self, msg):
- # move cursor to the end before writing new message
+ # Move cursor to the end before writing the new message
# so in case text inside the log window was selected before
- # the new text is pastes correct
- self.parent.messages.moveCursor(QtGui.QTextCursor.End)
- self.parent.messages.append(msg)
+ # the new text is pasted correctly
+ self.mw.messages.moveCursor(QtGui.QTextCursor.End)
+ self.mw.messages.append(msg)
@QtCore.Slot()
def onExit(self):
sys.exit(QtWidgets.QApplication.exit())
- @QtCore.Slot()
- def onCalculator(self):
- pass
-
- @QtCore.Slot()
- def onBackground(self):
- if self.parent.view.viewstyle == 'gradient':
- self.parent.view.viewstyle = 'solid'
- else:
- self.parent.view.viewstyle = 'gradient'
-
- self.parent.view.setBackground(self.parent.view.viewstyle)
+ @QtCore.Slot()
+ def onCalculator(self):
+ pass
+
+ @QtCore.Slot()
+ def onSettings(self):
+ dialog = self._buildSettingsDialog()
+ dialog.exec()
@QtCore.Slot()
- def onLevelChanged(self):
- """Change size of message window when floating """
- if self.parent.messagedock.isFloating():
- self.parent.messagedock.resize(600, 300)
+ def onBackground(self):
+ if self.mw.view.viewstyle == 'gradient':
+ self.mw.view.viewstyle = 'solid'
+ else:
+ self.mw.view.viewstyle = 'gradient'
+
+ self.mw.view.setBackground(self.mw.view.viewstyle)
+
+ @QtCore.Slot()
+ def onCycleWindowSize(self):
+ self.mw.cycleWindowSizePreset()
+
+ @QtCore.Slot()
+ def onWindowPreset1(self):
+ self.mw.applyWindowSizePreset(1)
+
+ @QtCore.Slot()
+ def onWindowPreset2(self):
+ self.mw.applyWindowSizePreset(2)
+
+ @QtCore.Slot()
+ def onWindowPreset3(self):
+ self.mw.applyWindowSizePreset(3)
+
+ @QtCore.Slot()
+ def onExportUiCaptureSet(self):
+ dialog = FileDialog.Dialog(self.mw)
+ directory = dialog.choose_directory(
+ title='Select Folder For Complete UI Export'
+ )
+ if not directory:
+ logger.info('No folder selected. Complete UI export canceled.')
+ return
+
+ output_dir = Path(directory)
+ output_dir.mkdir(parents=True, exist_ok=True)
+
+ exported = []
+ skipped = []
+ self._exportUiCaptureSet(output_dir, exported, skipped)
+
+ summary = f'Exported {len(exported)} UI capture(s) to:\n{output_dir}'
+ if skipped:
+ summary += '\n\nSkipped:\n' + '\n'.join(skipped)
+ self.messageBox(summary)
+
+ @QtCore.Slot()
+ def onExportCurrentWorkflowPanel(self):
+ toolbox = getattr(self.mw.mainArea, 'toolbox', None)
+ if toolbox is None or toolbox.currentIndex() < 0:
+ self.messageBox('No workflow panel is available yet.')
+ return
+
+ UiExport.export_widget_as_png(
+ mainwindow=self.mw,
+ widget=toolbox.page_card,
+ default_name=self._workflowPanelExportFilename(toolbox),
+ dialog_title='Export Workflow Panel As',
+ rounded_radius=12.0,
+ success_label='Workflow panel',
+ )
+
+ @QtCore.Slot()
+ def onExportActiveAirfoilCard(self):
+ toolbox = getattr(self.mw.mainArea, 'toolbox', None)
+ if toolbox is None:
+ self.messageBox('No active airfoil card is available yet.')
+ return
+
+ UiExport.export_widget_as_png(
+ mainwindow=self.mw,
+ widget=toolbox.summary_card,
+ default_name='active_airfoil_card.png',
+ dialog_title='Export Active Airfoil Card As',
+ rounded_radius=12.0,
+ success_label='Active airfoil card',
+ )
+
+ @QtCore.Slot()
+ def onExportWorkflowNavigation(self):
+ toolbox = getattr(self.mw.mainArea, 'toolbox', None)
+ if toolbox is None:
+ self.messageBox('No workflow navigation card is available yet.')
+ return
+
+ UiExport.export_widget_as_png(
+ mainwindow=self.mw,
+ widget=toolbox.workflow_card,
+ default_name='workflow_navigation.png',
+ dialog_title='Export Workflow Navigation As',
+ rounded_radius=12.0,
+ success_label='Workflow navigation',
+ )
+
+ @QtCore.Slot()
+ def onExportMessagePanel(self):
+ panel = getattr(self.mw.mainArea, 'message_panel', None)
+ if panel is None:
+ self.messageBox('No message panel is available yet.')
+ return
+
+ UiExport.export_widget_as_png(
+ mainwindow=self.mw,
+ widget=panel,
+ default_name='message_panel.png',
+ dialog_title='Export Message Panel As',
+ rounded_radius=8.0,
+ success_label='Message panel',
+ )
+
+ @QtCore.Slot()
+ def onExportViewerControlsPanel(self):
+ panel = getattr(self.mw.mainArea, 'viewer_controls_panel', None)
+ if panel is None:
+ self.messageBox('No viewer controls panel is available yet.')
+ return
+
+ UiExport.export_widget_as_png(
+ mainwindow=self.mw,
+ widget=panel,
+ default_name='viewer_controls_panel.png',
+ dialog_title='Export Viewer Controls Panel As',
+ rounded_radius=8.0,
+ success_label='Viewer controls panel',
+ )
+
+ @QtCore.Slot()
+ def onExportCanvasScreenshot(self):
+ widget, default_name, label = self._currentCanvasExportTarget()
+ if widget is None:
+ self.messageBox('No canvas is available yet.')
+ return
+
+ UiExport.export_widget_as_png(
+ mainwindow=self.mw,
+ widget=widget,
+ default_name=default_name,
+ dialog_title='Export Canvas Screenshot As',
+ success_label=label,
+ )
+
+ def _workflowPanelExportFilename(self, toolbox):
+ title = toolbox.currentPageTitle() or 'workflow-panel'
+ slug = UiExport.slugify_text(title, fallback='workflow_panel')
+ return f'{slug}_panel.png'
+
+ def _currentCanvasExportTarget(self):
+ workspace_index = self.mw.mainArea.tabs.currentIndex()
+ if workspace_index == self.mw.mainArea.WORKSPACE_ANALYSIS_INDEX:
+ return (
+ self.mw.contourview.chart_view,
+ 'contour_analysis_canvas.png',
+ 'Contour analysis canvas',
+ )
+ return (
+ self.mw.view,
+ 'viewer_canvas.png',
+ 'Viewer canvas',
+ )
+
+ def _exportUiCaptureSet(self, output_dir, exported, skipped):
+ toolbox = getattr(self.mw.mainArea, 'toolbox', None)
+ if toolbox is None:
+ skipped.append('Toolbox panels (toolbox unavailable)')
+ return
+
+ original_page_index = toolbox.currentIndex()
+ original_workspace_index = self.mw.mainArea.tabs.currentIndex()
+
+ try:
+ self._saveCapture(
+ self.mw,
+ output_dir / 'ui_overview_main.png',
+ 'Main window overview',
+ exported,
+ skipped,
+ )
+ self._saveCapture(
+ toolbox.summary_card,
+ output_dir / 'active_airfoil_card.png',
+ 'Active airfoil card',
+ exported,
+ skipped,
+ rounded_radius=12.0,
+ )
+ self._saveCapture(
+ toolbox.workflow_card,
+ output_dir / 'workflow_navigation.png',
+ 'Workflow navigation',
+ exported,
+ skipped,
+ rounded_radius=12.0,
+ )
+ self._saveCapture(
+ self.mw.mainArea.message_panel,
+ output_dir / 'message_panel.png',
+ 'Message panel',
+ exported,
+ skipped,
+ rounded_radius=8.0,
+ )
+ self._saveCapture(
+ self.mw.mainArea.viewer_controls_panel,
+ output_dir / 'viewer_controls_panel.png',
+ 'Viewer controls panel',
+ exported,
+ skipped,
+ rounded_radius=8.0,
+ )
+
+ for index in range(toolbox.pageCount()):
+ toolbox.setCurrentIndex(index)
+ QtWidgets.QApplication.processEvents()
+ title = toolbox.pageTitle(index)
+ filename = (
+ f'{UiExport.slugify_text(title, fallback="workflow")}_panel.png'
+ )
+ self._saveCapture(
+ toolbox.page_card,
+ output_dir / filename,
+ f'{title} panel',
+ exported,
+ skipped,
+ rounded_radius=12.0,
+ )
+
+ self.mw.mainArea.tabs.setCurrentIndex(
+ self.mw.mainArea.WORKSPACE_VIEWER_INDEX
+ )
+ QtWidgets.QApplication.processEvents()
+ self._saveCapture(
+ self.mw.view,
+ output_dir / 'viewer_canvas.png',
+ 'Viewer canvas',
+ exported,
+ skipped,
+ )
+
+ self.mw.mainArea.tabs.setCurrentIndex(
+ self.mw.mainArea.WORKSPACE_ANALYSIS_INDEX
+ )
+ QtWidgets.QApplication.processEvents()
+ self._saveCapture(
+ self.mw.contourview.chart_view,
+ output_dir / 'contour_analysis_canvas.png',
+ 'Contour analysis canvas',
+ exported,
+ skipped,
+ )
+
+ self._exportDialogCapture(
+ output_dir / 'settings_dialog.png',
+ 'Settings dialog',
+ exported,
+ skipped,
+ self._buildSettingsDialog,
+ )
+ self._exportDialogCapture(
+ output_dir / 'keyboard_shortcuts_dialog.png',
+ 'Keyboard shortcuts dialog',
+ exported,
+ skipped,
+ self._buildShortcutDialog,
+ )
+ self._exportDialogCapture(
+ output_dir / 'icon_preview_dialog.png',
+ 'Icon preview dialog',
+ exported,
+ skipped,
+ self._buildIconPreviewDialog,
+ )
+ self._exportDialogCapture(
+ output_dir / 'about_dialog.png',
+ 'About dialog',
+ exported,
+ skipped,
+ self._buildAboutDialog,
+ )
+
+ cst_dialog, cst_error = toolbox.createCstParametersDialog()
+ if cst_dialog is None:
+ skipped.append(f'CST parameters dialog ({cst_error})')
+ else:
+ self._exportDialogCapture(
+ output_dir / 'cst_parameters_dialog.png',
+ 'CST parameters dialog',
+ exported,
+ skipped,
+ lambda dialog=cst_dialog: dialog,
+ )
+ finally:
+ if original_page_index >= 0:
+ toolbox.setCurrentIndex(original_page_index)
+ self.mw.mainArea.tabs.setCurrentIndex(original_workspace_index)
+ QtWidgets.QApplication.processEvents()
+
+ def _exportDialogCapture(
+ self,
+ output_path,
+ label,
+ exported,
+ skipped,
+ factory,
+ rounded_radius=18.0,
+ ):
+ dialog = None
+ try:
+ dialog = factory()
+ self._saveCapture(
+ dialog,
+ output_path,
+ label,
+ exported,
+ skipped,
+ rounded_radius=rounded_radius,
+ )
+ finally:
+ if dialog is not None:
+ dialog.close()
+ dialog.deleteLater()
+
+ def _saveCapture(
+ self,
+ widget,
+ output_path,
+ label,
+ exported,
+ skipped,
+ rounded_radius=None,
+ ):
+ try:
+ UiExport.save_widget_png(
+ widget=widget,
+ filename=str(output_path),
+ rounded_radius=rounded_radius,
+ fallback_widget=self.mw,
+ )
+ except OSError as error:
+ logger.warning(
+ 'Skipping %s during UI export: %s',
+ label,
+ error,
+ )
+ skipped.append(f'{label} ({error})')
+ return False
+
+ exported.append(str(output_path))
+ return True
+
+ def _buildSettingsDialog(self):
+ import SettingsEditor
+
+ return SettingsEditor.SettingsEditorDialog(self.mw)
+
+ def _buildShortcutDialog(self):
+ import ShortcutEditor
+
+ return ShortcutEditor.ShortcutEditorDialog(self.mw)
+
+ def _buildIconPreviewDialog(self):
+ import IconPreview
+
+ return IconPreview.IconPreviewDialog(self.mw)
+
+ @QtCore.Slot()
+ def onLevelChanged(self):
+ """Change size of message window when floating """
+ if hasattr(self.mw.messagedock, 'isFloating') and self.mw.messagedock.isFloating():
+ self.mw.messagedock.resize(600, 300)
@QtCore.Slot()
def onTextChanged(self):
"""Move the scrollbar in the message log-window to the bottom.
So latest messages are always in the view.
"""
- vbar = self.parent.messages.verticalScrollBar()
+ vbar = self.mw.messages.verticalScrollBar()
if vbar:
vbar.triggerAction(QtWidgets.QAbstractSlider.SliderToMaximum)
- @QtCore.Slot()
- def onTabChanged(self):
+ @QtCore.Slot(int)
+ def onTabChanged(self, _index=None):
"""Sync tabs and toolboxes """
- tabs = self.parent.centralwidget.tabs
- tab_text = self.parent.centralwidget.tabs.tabText(tabs.currentIndex())
- toolbox = self.parent.centralwidget.toolbox
+ tabs = self.mw.mainArea.tabs
+ self.mw.mainArea.updateWorkspaceChrome(tabs.currentIndex())
+ toolbox = self.mw.mainArea.toolbox
- if tab_text == 'Airfoil Viewer':
- toolbox.setCurrentIndex(toolbox.tb1)
- if tab_text == 'Contour Analysis':
+ if tabs.currentIndex() == self.mw.mainArea.WORKSPACE_ANALYSIS_INDEX:
toolbox.setCurrentIndex(toolbox.tb3)
+ elif toolbox.currentIndex() == toolbox.tb3:
+ toolbox.setCurrentIndex(toolbox.lastWorkflowIndex())
@QtCore.Slot(str)
def messageBox(self, message):
QtWidgets.QMessageBox. \
- information(self.parent, 'Information',
+ information(self.mw, 'Information',
message, QtWidgets.QMessageBox.Ok)
- @QtCore.Slot()
- def onKeyBd(self):
- # automatically populate shortcuts from PMenu.xml
- text = ' \
- '
- for eachMenu in self.parent.menudata:
- for pulldown in eachMenu[1]:
- if pulldown[2]:
- if self.parent.platform == 'Darwin':
- shortcut = pulldown[2].replace('CTRL', 'CMD')
- # print(pulldown[2], '...', shortcut)
- else:
- shortcut = pulldown[2]
- text += f' \
- \
- {shortcut} \
- \
- \
- {pulldown[1]} \
- \
- \
- '
- text += '
'
-
- textedit = QtWidgets.QTextEdit()
- textedit.setReadOnly(True)
- # textedit.setStyleSheet('font-family: Courier; font-size: 14px; ')
- textedit.setHtml(text)
-
- # buttons = QtWidgets.QDialogButtonBox.Ok | QtWidgets.QDialogButtonBox.Cancel
- buttons = QtWidgets.QDialogButtonBox.Ok
- buttonBox = QtWidgets.QDialogButtonBox(buttons)
-
- # make a dialog to carry the textedit and button widget
- dlg = QtWidgets.QDialog(self.parent)
- dlg.setWindowTitle('Keyboard shortcuts')
- dlg.setFixedSize(800, 900)
- buttonBox.accepted.connect(dlg.accept)
- layout = QtWidgets.QVBoxLayout()
- layout.addWidget(textedit)
- layout.addWidget(buttonBox)
- dlg.setLayout(layout)
- dlg.exec_()
+ @QtCore.Slot()
+ def onKeyBd(self):
+ dialog = self._buildShortcutDialog()
+ dialog.exec()
@QtCore.Slot()
def runCommands(self):
'''Automate different actions by simulation of button clicks
Call directly a function or
using click or animateClick on the respective widget
- # self.parent.centralwidget.toolbox.splineButton.click
- # self.parent.centralwidget.toolbox.splineButton.animateClick
+ # self.mw.mainArea.toolbox.splineButton.click
+ # self.mw.mainArea.toolbox.splineButton.animateClick
This feature is mainly used during tesing, as it runs the whole workflow
automatically.
-
- '''
+
+ '''
# load the predefined airfoil
self.onOpenPredefined()
# spline and refine the contour with defaults
- self.parent.centralwidget.toolbox.spline_and_refine()
+ self.mw.mainArea.toolbox.spline_and_refine()
# add a blunt trailing edge with defaults
- self.parent.centralwidget.toolbox.makeTrailingEdge()
+ self.mw.mainArea.toolbox.makeTrailingEdge()
# generate a mesh using defaults
- self.parent.centralwidget.toolbox.generateMesh()
+ self.mw.mainArea.toolbox.generateMesh()
# export the mesh
- # self.parent.centralwidget.toolbox.exportMesh()
+ # self.mw.mainArea.toolbox.exportMesh()
@QtCore.Slot()
def onHelpOnline(self):
@@ -416,35 +835,318 @@ def onHelpPDF(self):
webbrowser.open('https://pyaero.readthedocs.io/_/downloads/en/latest/pdf/')
@QtCore.Slot()
- def onAboutQt(self):
- QtWidgets.QApplication.aboutQt()
+ def onAboutQt(self):
+ QtWidgets.QApplication.aboutQt()
+
+ @QtCore.Slot()
+ def onIconPreview(self):
+ dialog = self._buildIconPreviewDialog()
+ dialog.exec()
+
+ def _readBundledText(self, relative_path, fallback=''):
+ root = Path(__file__).resolve().parent.parent
+ try:
+ return (root / relative_path).read_text(encoding='utf-8').strip()
+ except OSError:
+ return fallback
+
+ def _addAboutStat(self, layout, row, label, value):
+ key = QtWidgets.QLabel(label)
+ key.setObjectName('aboutStatKey')
+ layout.addWidget(key, row, 0)
+
+ val = QtWidgets.QLabel(value)
+ val.setObjectName('aboutStatValue')
+ val.setTextInteractionFlags(QtCore.Qt.TextSelectableByMouse)
+ layout.addWidget(val, row, 1)
+
+ def _aboutDetailsHtml(self):
+ mit_license = self._readBundledText(
+ 'LICENSE',
+ 'PyAero is distributed under the MIT License.',
+ )
+ lucide_license = self._readBundledText(
+ 'resources/Icons/lucide/LICENSE',
+ 'Lucide icons are distributed under the ISC License.',
+ )
+
+ return f"""
+
+
+
+
+
+ What PyAero Does
+ {html.escape(PyAero.__appname__)} is used for 2D CFD mesh generation and contour analysis for airfoils.
+ Questions or feedback: {html.escape(PyAero.__email__)}
+
+
+
Licenses
+
The following bundled license texts apply to this build.
+
+
+
+
PyAero
+
MIT License
+
{html.escape(mit_license)}
+
+
+
+
Lucide Icons
+
ISC License and Bundled Notices
+
{html.escape(lucide_license)}
+
+
+
+ """
- @QtCore.Slot()
- def onAbout(self):
- QtWidgets.QMessageBox. \
- about(self.parent, "About " + PyAero.__appname__,
- "" + PyAero.__appname__ +
- " is used for "
- "2D airfoil contour analysis and CFD mesh generation.\
- "
- "" + PyAero.__appname__ + " code under " +
- PyAero.__license__ +
- " license. (c) " +
- PyAero.__copyright__ + " "
- "email to: " + PyAero.__email__ + " "
- "Embedded Aeropython code under MIT license. \
- (c) 2014 Lorena A. Barba, Olivier Mesnard "
- "Link to " +
- " \
- Aeropython (iPython notebook)." + " "
- + "VERSIONS: " + " "
- + PyAero.__appname__ + ": " + PyAero.__version__ +
- " "
- + "Python: %s" % (sys.version.split()[0]) + " "
- + "Numpy: %s" % (np.__version__) + " "
- + "Scipy: %s" % (scipy.__version__) + " "
- + "Qt for Python: %s" % (PySide6.__version__) + " "
- + "Qt: %s" % (PySide6.QtCore.__version__)
- )
+ @QtCore.Slot()
+ def onAbout(self):
+ dialog = self._buildAboutDialog()
+ dialog.exec()
+
+ def _buildAboutDialog(self):
+ dialog = QtWidgets.QDialog(self.mw)
+ dialog.setWindowTitle('About ' + PyAero.__appname__)
+ dialog.setWindowIcon(self.mw.windowIcon())
+ dialog.setModal(True)
+ dialog.resize(760, 720)
+ dialog.setMinimumSize(700, 620)
+ dialog.setWindowFlag(QtCore.Qt.WindowContextHelpButtonHint, False)
+ dialog.setStyleSheet(
+ """
+ QDialog {
+ background: #eef3f8;
+ }
+ QFrame#aboutHero {
+ background: qlineargradient(
+ x1: 0, y1: 0, x2: 1, y2: 1,
+ stop: 0 #ffffff,
+ stop: 1 #edf5fb
+ );
+ border: 1px solid #d6e3ef;
+ border-radius: 18px;
+ }
+ QLabel#aboutEyebrow {
+ color: #6b7f92;
+ font-size: 11px;
+ font-weight: 700;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+ }
+ QLabel#aboutTitle {
+ color: #182c41;
+ font-size: 30px;
+ font-weight: 700;
+ }
+ QLabel#aboutSubtitle {
+ color: #4d6072;
+ font-size: 14px;
+ line-height: 1.4em;
+ }
+ QLabel#aboutBadge {
+ background: #ffffff;
+ border: 1px solid #d6e3ef;
+ border-radius: 999px;
+ color: #24415e;
+ font-size: 11px;
+ font-weight: 700;
+ padding: 4px 10px;
+ }
+ QFrame#aboutCard {
+ background: #ffffff;
+ border: 1px solid #d6e0ea;
+ border-radius: 16px;
+ }
+ QLabel#aboutSectionTitle {
+ color: #5d7287;
+ font-size: 11px;
+ font-weight: 700;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+ }
+ QLabel#aboutStatKey {
+ color: #627789;
+ font-size: 12px;
+ font-weight: 600;
+ }
+ QLabel#aboutStatValue {
+ color: #1d3148;
+ font-size: 12px;
+ font-weight: 600;
+ }
+ QTextBrowser#aboutDetails {
+ background: #ffffff;
+ border: 1px solid #d6e0ea;
+ border-radius: 16px;
+ padding: 10px;
+ }
+ QDialogButtonBox QPushButton {
+ background: #ffffff;
+ border: 1px solid #c7d5e3;
+ border-radius: 10px;
+ color: #1e334b;
+ font-weight: 600;
+ min-width: 96px;
+ padding: 8px 14px;
+ }
+ QDialogButtonBox QPushButton:hover {
+ background: #f4f8fb;
+ border-color: #9eb8d4;
+ }
+ """
+ )
+
+ layout = QtWidgets.QVBoxLayout(dialog)
+ layout.setContentsMargins(18, 18, 18, 18)
+ layout.setSpacing(14)
+
+ hero = QtWidgets.QFrame()
+ hero.setObjectName('aboutHero')
+ hero_layout = QtWidgets.QHBoxLayout(hero)
+ hero_layout.setContentsMargins(20, 20, 20, 20)
+ hero_layout.setSpacing(18)
+
+ logo = QtWidgets.QLabel()
+ logo.setFixedSize(84, 84)
+ logo.setAlignment(QtCore.Qt.AlignCenter)
+ logo_pixmap = Icons.app_icon().pixmap(72, 72)
+ if not logo_pixmap.isNull():
+ logo.setPixmap(
+ logo_pixmap.scaled(
+ 72,
+ 72,
+ QtCore.Qt.KeepAspectRatio,
+ QtCore.Qt.SmoothTransformation,
+ )
+ )
+ hero_layout.addWidget(logo, 0, QtCore.Qt.AlignTop)
+
+ hero_text = QtWidgets.QVBoxLayout()
+ hero_text.setSpacing(8)
+
+ eyebrow = QtWidgets.QLabel('2D Airfoil CFD Meshes')
+ eyebrow.setObjectName('aboutEyebrow')
+ hero_text.addWidget(eyebrow)
+
+ title = QtWidgets.QLabel(PyAero.__appname__)
+ title.setObjectName('aboutTitle')
+ hero_text.addWidget(title)
+
+ subtitle = QtWidgets.QLabel(
+ 'Modern airfoil contour analysis and 2D CFD meshing in a focused desktop workspace.'
+ )
+ subtitle.setObjectName('aboutSubtitle')
+ subtitle.setWordWrap(True)
+ hero_text.addWidget(subtitle)
+
+ badge_row = QtWidgets.QHBoxLayout()
+ badge_row.setSpacing(8)
+ for text in (
+ f'Version {PyAero.__version__}',
+ 'PyAero MIT',
+ 'Lucide ISC',
+ ):
+ badge = QtWidgets.QLabel(text)
+ badge.setObjectName('aboutBadge')
+ badge_row.addWidget(badge)
+ badge_row.addStretch(1)
+ hero_text.addLayout(badge_row)
+
+ hero_layout.addLayout(hero_text, 1)
+ layout.addWidget(hero)
+
+ stats_card = QtWidgets.QFrame()
+ stats_card.setObjectName('aboutCard')
+ stats_layout = QtWidgets.QVBoxLayout(stats_card)
+ stats_layout.setContentsMargins(18, 18, 18, 18)
+ stats_layout.setSpacing(12)
+
+ stats_title = QtWidgets.QLabel('Runtime')
+ stats_title.setObjectName('aboutSectionTitle')
+ stats_layout.addWidget(stats_title)
+
+ stats_grid = QtWidgets.QGridLayout()
+ stats_grid.setHorizontalSpacing(20)
+ stats_grid.setVerticalSpacing(10)
+ self._addAboutStat(stats_grid, 0, 'Contact', PyAero.__email__)
+ self._addAboutStat(stats_grid, 1, PyAero.__appname__, PyAero.__version__)
+ self._addAboutStat(stats_grid, 2, 'Python', sys.version.split()[0])
+ self._addAboutStat(stats_grid, 3, 'NumPy', np.__version__)
+ self._addAboutStat(stats_grid, 4, 'SciPy', scipy.__version__)
+ self._addAboutStat(stats_grid, 5, 'Qt for Python', PySide6.__version__)
+ self._addAboutStat(stats_grid, 6, 'Qt', PySide6.QtCore.__version__)
+ stats_grid.setColumnStretch(1, 1)
+ stats_layout.addLayout(stats_grid)
+ layout.addWidget(stats_card)
+
+ details = QtWidgets.QTextBrowser()
+ details.setObjectName('aboutDetails')
+ details.setOpenExternalLinks(True)
+ details.setReadOnly(True)
+ details.setHtml(self._aboutDetailsHtml())
+ layout.addWidget(details, 1)
+
+ buttons = QtWidgets.QDialogButtonBox(QtWidgets.QDialogButtonBox.Close)
+ UiExport.install_dialog_export_button(
+ buttons,
+ mainwindow=self.mw,
+ widget=dialog,
+ default_name='about_dialog.png',
+ dialog_title='Export About Dialog As',
+ success_label='About dialog',
+ )
+ buttons.rejected.connect(dialog.reject)
+ buttons.accepted.connect(dialog.accept)
+ layout.addWidget(buttons)
+ return dialog
diff --git a/src/HtmlView.py b/src/HtmlView.py
deleted file mode 100644
index c1c4b41..0000000
--- a/src/HtmlView.py
+++ /dev/null
@@ -1,8 +0,0 @@
-from PySide6 import QtGui, QtCore
-
-
-class HtmlView(QtGui.QWebView):
- """docstring for HtmLvIEW"""
- def __init__(self, parent):
- super().__init__(parent)
- self.parent = parent
diff --git a/src/IconPreview.py b/src/IconPreview.py
new file mode 100644
index 0000000..cdcf700
--- /dev/null
+++ b/src/IconPreview.py
@@ -0,0 +1,382 @@
+from pathlib import Path
+
+from PySide6 import QtCore, QtGui, QtWidgets
+
+import Icons
+import UiExport
+from Utils import get_main_window
+
+
+APP_ICON_ASSETS = (
+ ('Primary SVG', Icons.APP_ICON_FILE),
+)
+
+
+class IconPreviewDialog(QtWidgets.QDialog):
+ def __init__(self, parent=None):
+ super().__init__(parent)
+ self.mw = parent or get_main_window()
+
+ self.setWindowTitle('Icon Preview')
+ if parent is not None:
+ self.setWindowIcon(parent.windowIcon())
+ self.setModal(True)
+ self.resize(980, 780)
+ self.setMinimumSize(860, 680)
+ self.setWindowFlag(QtCore.Qt.WindowContextHelpButtonHint, False)
+
+ self._build_ui()
+
+ def _build_ui(self):
+ self.setStyleSheet(
+ """
+ QDialog {
+ background: #edf3f8;
+ }
+ QFrame#previewHero {
+ background: qlineargradient(
+ x1: 0, y1: 0, x2: 1, y2: 1,
+ stop: 0 #ffffff,
+ stop: 1 #eef5fb
+ );
+ border: 1px solid #d6e1ec;
+ border-radius: 18px;
+ }
+ QFrame#previewSection,
+ QFrame#previewRow {
+ background: #ffffff;
+ border: 1px solid #d6e1ec;
+ border-radius: 16px;
+ }
+ QLabel#previewEyebrow {
+ color: #687d91;
+ font-size: 11px;
+ font-weight: 700;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+ }
+ QLabel#previewTitle {
+ color: #172d43;
+ font-size: 30px;
+ font-weight: 700;
+ }
+ QLabel#previewSubtitle,
+ QLabel#previewSectionBody,
+ QLabel#previewMeta {
+ color: #536679;
+ font-size: 13px;
+ line-height: 1.45em;
+ }
+ QLabel#previewBadge {
+ background: #ffffff;
+ border: 1px solid #d6e1ec;
+ border-radius: 999px;
+ color: #264562;
+ font-size: 11px;
+ font-weight: 700;
+ padding: 4px 10px;
+ }
+ QLabel#previewSectionTitle {
+ color: #1b3047;
+ font-size: 18px;
+ font-weight: 700;
+ }
+ QLabel#previewRowTitle {
+ color: #1f3348;
+ font-size: 14px;
+ font-weight: 700;
+ }
+ QLabel#previewRowPath {
+ color: #6a7d90;
+ font-size: 12px;
+ font-family: "SF Mono", "Menlo", "Monaco", monospace;
+ }
+ QFrame#swatchCard,
+ QFrame#assetCard {
+ background: #f8fbfd;
+ border: 1px solid #d9e3ed;
+ border-radius: 12px;
+ }
+ QLabel#swatchSize,
+ QLabel#assetLabel {
+ color: #5c7185;
+ font-size: 11px;
+ font-weight: 700;
+ }
+ QDialogButtonBox QPushButton {
+ background: #ffffff;
+ border: 1px solid #c7d5e3;
+ border-radius: 10px;
+ color: #1d3249;
+ font-weight: 600;
+ min-width: 96px;
+ padding: 8px 14px;
+ }
+ QDialogButtonBox QPushButton:hover {
+ background: #f4f8fb;
+ border-color: #9db7d4;
+ }
+ """
+ )
+
+ layout = QtWidgets.QVBoxLayout(self)
+ layout.setContentsMargins(18, 18, 18, 18)
+ layout.setSpacing(14)
+
+ layout.addWidget(self._build_header())
+
+ scroll = QtWidgets.QScrollArea()
+ scroll.setWidgetResizable(True)
+ scroll.setFrameShape(QtWidgets.QFrame.NoFrame)
+ scroll.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff)
+
+ content = QtWidgets.QWidget()
+ content_layout = QtWidgets.QVBoxLayout(content)
+ content_layout.setContentsMargins(0, 0, 0, 0)
+ content_layout.setSpacing(14)
+ content_layout.addWidget(self._build_app_assets_section())
+ content_layout.addWidget(
+ self._build_icon_section(
+ title='Custom Domain Icons',
+ description=(
+ 'These are the icons we should keep iterating. '
+ 'They use the Lucide stroke language but cover PyAero-specific concepts.'
+ ),
+ icon_names=Icons.names_for_family('custom'),
+ )
+ )
+ content_layout.addWidget(
+ self._build_icon_section(
+ title='Lucide System Icons',
+ description=(
+ 'These are the semantic action icons wired through the icon registry. '
+ 'Use them as the visual baseline when refining custom SVGs.'
+ ),
+ icon_names=Icons.names_for_family('lucide'),
+ )
+ )
+ content_layout.addStretch(1)
+ scroll.setWidget(content)
+ layout.addWidget(scroll, 1)
+
+ buttons = QtWidgets.QDialogButtonBox(QtWidgets.QDialogButtonBox.Close)
+ UiExport.install_dialog_export_button(
+ buttons,
+ mainwindow=self.mw,
+ widget=self,
+ default_name='icon_preview_dialog.png',
+ dialog_title='Export Icon Preview Dialog As',
+ success_label='Icon preview dialog',
+ )
+ buttons.rejected.connect(self.reject)
+ buttons.accepted.connect(self.accept)
+ layout.addWidget(buttons)
+
+ def _build_header(self):
+ frame = QtWidgets.QFrame()
+ frame.setObjectName('previewHero')
+
+ layout = QtWidgets.QHBoxLayout(frame)
+ layout.setContentsMargins(20, 20, 20, 20)
+ layout.setSpacing(18)
+
+ icon_label = QtWidgets.QLabel()
+ icon_label.setFixedSize(84, 84)
+ icon_label.setAlignment(QtCore.Qt.AlignCenter)
+ if self.windowIcon().isNull():
+ pixmap = Icons.app_icon().pixmap(72, 72)
+ else:
+ pixmap = self.windowIcon().pixmap(72, 72)
+ if not pixmap.isNull():
+ icon_label.setPixmap(
+ pixmap.scaled(
+ 72,
+ 72,
+ QtCore.Qt.KeepAspectRatio,
+ QtCore.Qt.SmoothTransformation,
+ )
+ )
+ layout.addWidget(icon_label, 0, QtCore.Qt.AlignTop)
+
+ text_layout = QtWidgets.QVBoxLayout()
+ text_layout.setSpacing(8)
+
+ eyebrow = QtWidgets.QLabel('Design Tooling')
+ eyebrow.setObjectName('previewEyebrow')
+ text_layout.addWidget(eyebrow)
+
+ title = QtWidgets.QLabel('Icon Preview')
+ title.setObjectName('previewTitle')
+ text_layout.addWidget(title)
+
+ subtitle = QtWidgets.QLabel(
+ 'Inspect every semantic icon at the sizes PyAero actually uses, '
+ 'and compare the custom domain icons against the Lucide baseline.'
+ )
+ subtitle.setObjectName('previewSubtitle')
+ subtitle.setWordWrap(True)
+ text_layout.addWidget(subtitle)
+
+ badge_row = QtWidgets.QHBoxLayout()
+ badge_row.setSpacing(8)
+ for text in (
+ f'{len(Icons.names_for_family("custom"))} custom icons',
+ f'{len(Icons.names_for_family("lucide"))} Lucide icons',
+ 'Target sizes 16 / 20 / 24 / 32',
+ ):
+ badge = QtWidgets.QLabel(text)
+ badge.setObjectName('previewBadge')
+ badge_row.addWidget(badge)
+ badge_row.addStretch(1)
+ text_layout.addLayout(badge_row)
+
+ layout.addLayout(text_layout, 1)
+ return frame
+
+ def _build_app_assets_section(self):
+ frame = QtWidgets.QFrame()
+ frame.setObjectName('previewSection')
+
+ layout = QtWidgets.QVBoxLayout(frame)
+ layout.setContentsMargins(18, 18, 18, 18)
+ layout.setSpacing(12)
+
+ title = QtWidgets.QLabel('App Icon Assets')
+ title.setObjectName('previewSectionTitle')
+ layout.addWidget(title)
+
+ body = QtWidgets.QLabel(
+ 'This SVG is the canonical application icon source. '
+ 'It is rendered directly in the UI instead of relying on hard-coded PNG-only paths.'
+ )
+ body.setObjectName('previewSectionBody')
+ body.setWordWrap(True)
+ layout.addWidget(body)
+
+ row = QtWidgets.QHBoxLayout()
+ row.setSpacing(10)
+ for label, relative_path in APP_ICON_ASSETS:
+ row.addWidget(self._build_asset_card(label, relative_path))
+ row.addStretch(1)
+ layout.addLayout(row)
+ return frame
+
+ def _build_asset_card(self, label, relative_path):
+ card = QtWidgets.QFrame()
+ card.setObjectName('assetCard')
+ card.setFixedWidth(170)
+
+ layout = QtWidgets.QVBoxLayout(card)
+ layout.setContentsMargins(12, 12, 12, 12)
+ layout.setSpacing(10)
+
+ asset_label = QtWidgets.QLabel(label)
+ asset_label.setObjectName('assetLabel')
+ layout.addWidget(asset_label)
+
+ relative = QtWidgets.QLabel(relative_path)
+ relative.setObjectName('previewMeta')
+ relative.setWordWrap(True)
+ layout.addWidget(relative)
+
+ preview = QtWidgets.QLabel()
+ preview.setAlignment(QtCore.Qt.AlignCenter)
+ preview.setMinimumHeight(92)
+ pixmap = QtGui.QIcon(str(Icons.ICON_ROOT / relative_path)).pixmap(72, 72)
+ if not pixmap.isNull():
+ preview.setPixmap(
+ pixmap.scaled(
+ 72,
+ 72,
+ QtCore.Qt.KeepAspectRatio,
+ QtCore.Qt.SmoothTransformation,
+ )
+ )
+ layout.addWidget(preview, 1)
+
+ return card
+
+ def _build_icon_section(self, title, description, icon_names):
+ frame = QtWidgets.QFrame()
+ frame.setObjectName('previewSection')
+
+ layout = QtWidgets.QVBoxLayout(frame)
+ layout.setContentsMargins(18, 18, 18, 18)
+ layout.setSpacing(12)
+
+ heading = QtWidgets.QLabel(title)
+ heading.setObjectName('previewSectionTitle')
+ layout.addWidget(heading)
+
+ body = QtWidgets.QLabel(description)
+ body.setObjectName('previewSectionBody')
+ body.setWordWrap(True)
+ layout.addWidget(body)
+
+ for icon_name in icon_names:
+ layout.addWidget(self._build_icon_row(icon_name))
+
+ return frame
+
+ def _build_icon_row(self, icon_name):
+ frame = QtWidgets.QFrame()
+ frame.setObjectName('previewRow')
+
+ layout = QtWidgets.QHBoxLayout(frame)
+ layout.setContentsMargins(16, 16, 16, 16)
+ layout.setSpacing(16)
+
+ meta_layout = QtWidgets.QVBoxLayout()
+ meta_layout.setSpacing(4)
+ meta_layout.setAlignment(QtCore.Qt.AlignTop)
+
+ title = QtWidgets.QLabel(icon_name)
+ title.setObjectName('previewRowTitle')
+ meta_layout.addWidget(title)
+
+ relative_path = Icons.relative_path(icon_name)
+ path_label = QtWidgets.QLabel(relative_path)
+ path_label.setObjectName('previewRowPath')
+ path_label.setTextInteractionFlags(QtCore.Qt.TextSelectableByMouse)
+ meta_layout.addWidget(path_label)
+
+ family = Icons.family(icon_name).title()
+ meta = QtWidgets.QLabel(f'{family} icon')
+ meta.setObjectName('previewMeta')
+ meta_layout.addWidget(meta)
+ meta_layout.addStretch(1)
+
+ layout.addLayout(meta_layout, 0)
+
+ swatches = QtWidgets.QHBoxLayout()
+ swatches.setSpacing(10)
+ for size in Icons.PREVIEW_SIZES:
+ swatches.addWidget(self._build_swatch(icon_name, size))
+ swatches.addStretch(1)
+ layout.addLayout(swatches, 1)
+
+ return frame
+
+ def _build_swatch(self, icon_name, size):
+ frame = QtWidgets.QFrame()
+ frame.setObjectName('swatchCard')
+ frame.setFixedWidth(88)
+
+ layout = QtWidgets.QVBoxLayout(frame)
+ layout.setContentsMargins(10, 10, 10, 10)
+ layout.setSpacing(8)
+
+ preview = QtWidgets.QLabel()
+ preview.setAlignment(QtCore.Qt.AlignCenter)
+ preview.setMinimumHeight(48)
+ pixmap = Icons.pixmap(icon_name, size)
+ if not pixmap.isNull():
+ preview.setPixmap(pixmap)
+ layout.addWidget(preview)
+
+ label = QtWidgets.QLabel(f'{size}px')
+ label.setObjectName('swatchSize')
+ label.setAlignment(QtCore.Qt.AlignCenter)
+ layout.addWidget(label)
+
+ return frame
diff --git a/src/IconProvider.py b/src/IconProvider.py
index 7a0395c..299c4c8 100644
--- a/src/IconProvider.py
+++ b/src/IconProvider.py
@@ -1,31 +1,30 @@
-"""
-The PIconProvider class overwrites QFileIconProvider.
-
-This allows to use custom icons in different
-places of the application (e.g. file dialogs)
-"""
-from PySide6 import QtGui, QtCore, QtWidgets
-
-from Settings import *
-
-
-class IconProvider(QtWidgets.QFileIconProvider):
- # call constructor of IconProvider
+"""
+The PIconProvider class overwrites QFileIconProvider.
+
+This allows to use custom icons in different
+places of the application (e.g. file dialogs)
+"""
+from PySide6 import QtCore, QtWidgets
+
+import Icons
+
+
+class IconProvider(QtWidgets.QFileIconProvider):
def __init__(self):
- # call constructor of QFileIconProvider
+ # constructor of QFileIconProvider
super().__init__()
# overwrite icon method of QFileIconProvider
- def icon(self, icontype):
-
- if isinstance(icontype, QtCore.QFileInfo):
- if icontype.isDir():
- return QtGui.QIcon(os.path.join(ICONS_L, 'Folder.png'))
- if icontype.isFile():
- return QtGui.QIcon(os.path.join(ICONS_L, 'Fast delivery.png'))
- if icontype == QtGui.QFileIconProvider.Folder:
- return QtGui.QIcon(os.path.join(ICONS_L, 'Folder.png'))
- if icontype == QtGui.QFileIconProvider.File:
- return QtGui.QIcon(os.path.join(ICONS_L, 'Fast delivery.png'))
-
- return super().icon(icontype)
+ def icon(self, icontype):
+
+ if isinstance(icontype, QtCore.QFileInfo):
+ if icontype.isDir():
+ return Icons.icon('folder')
+ if icontype.isFile():
+ return Icons.icon('airfoil')
+ if icontype == QtWidgets.QFileIconProvider.Folder:
+ return Icons.icon('folder')
+ if icontype == QtWidgets.QFileIconProvider.File:
+ return Icons.icon('airfoil')
+
+ return super().icon(icontype)
diff --git a/src/Icons.py b/src/Icons.py
new file mode 100644
index 0000000..8fee661
--- /dev/null
+++ b/src/Icons.py
@@ -0,0 +1,105 @@
+from pathlib import Path
+
+from PySide6 import QtCore, QtGui
+
+
+ICON_ROOT = Path(__file__).resolve().parent.parent / 'resources' / 'Icons'
+APP_ICON_FILE = 'app_image.svg'
+PREVIEW_SIZES = (16, 20, 24, 32)
+
+
+ICON_FILES = {
+ 'about': 'lucide/info.svg',
+ 'aerodynamics': 'lucide/wind.svg',
+ 'airfoil': 'custom/airfoil.svg',
+ 'airfoil-library': 'custom/airfoil-library.svg',
+ 'autorun': 'lucide/workflow.svg',
+ 'calculator': 'lucide/calculator.svg',
+ 'cfd-inputs': 'lucide/gauge.svg',
+ 'contour-analysis': 'lucide/chart-line.svg',
+ 'delete': 'lucide/trash-2.svg',
+ 'exit': 'lucide/log-out.svg',
+ 'fit-airfoil': 'lucide/scan-search.svg',
+ 'fit-all': 'lucide/maximize.svg',
+ 'folder': 'lucide/folder.svg',
+ 'geometry-prep': 'lucide/pen-tool.svg',
+ 'keyboard-shortcuts': 'lucide/keyboard.svg',
+ 'manual': 'lucide/book-open-text.svg',
+ 'mesh': 'custom/mesh.svg',
+ 'open': 'lucide/folder-open.svg',
+ 'print': 'lucide/printer.svg',
+ 'print-preview': 'lucide/file-search.svg',
+ 'save': 'lucide/save.svg',
+ 'save-as': 'lucide/file-pen-line.svg',
+ 'settings': 'lucide/settings.svg',
+}
+
+
+def path(name):
+ if not name:
+ return ''
+
+ key = name.strip()
+ if not key:
+ return ''
+
+ mapped = ICON_FILES.get(key)
+ if mapped:
+ candidate = ICON_ROOT / mapped
+ if candidate.exists():
+ return str(candidate)
+
+ direct_path = Path(key)
+ if direct_path.exists():
+ return str(direct_path.resolve())
+
+ legacy_path = ICON_ROOT / key
+ if legacy_path.exists():
+ return str(legacy_path.resolve())
+
+ return ''
+
+
+def relative_path(name):
+ return ICON_FILES.get(name, '')
+
+
+def family(name):
+ rel_path = relative_path(name)
+ if '/' in rel_path:
+ return rel_path.split('/', 1)[0]
+ return 'legacy'
+
+
+def names():
+ return tuple(
+ sorted(
+ ICON_FILES,
+ key=lambda icon_name: (family(icon_name) != 'custom', icon_name),
+ )
+ )
+
+
+def names_for_family(icon_family):
+ return tuple(name for name in names() if family(name) == icon_family)
+
+
+def icon(name):
+ icon_path = path(name)
+ if not icon_path:
+ return QtGui.QIcon()
+ return QtGui.QIcon(icon_path)
+
+
+def app_icon_path():
+ return path(APP_ICON_FILE)
+
+
+def app_icon():
+ return QtGui.QIcon(app_icon_path())
+
+
+def pixmap(name, size):
+ if isinstance(size, int):
+ size = QtCore.QSize(size, size)
+ return icon(name).pixmap(size)
diff --git a/src/Logger.py b/src/Logger.py
index 3f12f0c..32fcfc0 100644
--- a/src/Logger.py
+++ b/src/Logger.py
@@ -1,8 +1,7 @@
-import os
-import logging
-import datetime
-
-from Settings import LOGDATA
+import os
+import logging
+import datetime
+import configparser
class GuiHandler(logging.Handler):
@@ -14,37 +13,34 @@ class GuiHandler(logging.Handler):
logger_instance = logging.getLogger('')
def __init__(self, parent=None, *args):
- # Initialize the Handler
- super().__init__(*args)
-
- self.parent = parent
+ # Initialize the Handler
+ super().__init__(*args)
+ self.parent = parent
- # make the logger send data to this class
- self.logger_instance.addHandler(self)
+ # Make the logger send data to this class
+ self.logger_instance.addHandler(self)
def emit(self, record):
""" Overload of logging.Handler method """
-
- record = self.format(record)
-
- self.parent.slots.onMessage(record)
-
-
-def log(mainwindow):
-
- useGUI = True
-
- if mainwindow == 'file_only':
- useGUI = False
-
- logging.basicConfig(level=logging.INFO)
- # logging.getLogger('') gets the 'root' logger
- # stdout is the only handler initially
- # see https://stackoverflow.com/a/6459613/2264936
- stdout_handler = logging.getLogger('').handlers[0]
-
- format = 'PyAero_%Y-%m-%d____h%H-m%M-s%S.log'
- logfile = os.path.join(LOGDATA, datetime.datetime.now().strftime(format))
+ formatted_record = self.format(record)
+ self.parent.slots.onMessage(formatted_record)
+
+
+def log(main_window):
+ useGUI = main_window != 'console'
+ root_logger = logging.getLogger('')
+ root_logger.setLevel(logging.INFO)
+
+ for handler in list(root_logger.handlers):
+ root_logger.removeHandler(handler)
+
+ log_format = 'PyAero_%Y-%m-%d____h%H-m%M-s%S.log'
+ log_dir = _resolve_log_directory(main_window)
+ os.makedirs(log_dir, mode=0o777, exist_ok=True)
+ logfile = os.path.join(
+ log_dir,
+ datetime.datetime.now().strftime(log_format),
+ )
# create a file handler
file_handler = logging.FileHandler(logfile)
@@ -56,7 +52,7 @@ def log(mainwindow):
# create a gui handler (for writing to the message dock window)
if useGUI:
- gui_handler = GuiHandler(parent=mainwindow)
+ gui_handler = GuiHandler(parent=main_window)
gui_handler.setLevel(logging.INFO)
# create specific logging formats
@@ -72,18 +68,24 @@ def log(mainwindow):
gui_handler.setFormatter(gui_formatter)
# add the handlers to the root logger
- logging.getLogger('').addHandler(file_handler)
- logging.getLogger('').addHandler(console_handler)
- if useGUI:
- logging.getLogger('').addHandler(gui_handler)
-
- # remove the standard handler from the root logger
- # it would log everything to the console automatically
- # see https://stackoverflow.com/a/6459613/2264936
- logging.getLogger('').removeHandler(stdout_handler)
+ root_logger.addHandler(file_handler)
+ root_logger.addHandler(console_handler)
+ if useGUI:
+ root_logger.addHandler(gui_handler)
# getLogger with __name__ retruns a logger for the current module (here Logger)
# example log message of level INFO preceeded by module name
# 2018-09-30 18:18:47,559 - Logger - INFO - Starting to log
- logger = logging.getLogger(__name__)
- logger.info('Starting to log')
+ logger = logging.getLogger(__name__)
+ logger.info('Starting to log')
+
+
+def _resolve_log_directory(main_window):
+ if main_window != 'console':
+ return main_window.LOGS
+
+ parser = configparser.ConfigParser(
+ interpolation=configparser.ExtendedInterpolation()
+ )
+ parser.read(os.path.join(os.getcwd(), 'config/config.ini'))
+ return parser.get('Paths', 'LOGS', fallback='data/LOGS')
diff --git a/src/MagnifierLens.py b/src/MagnifierLens.py
new file mode 100644
index 0000000..8fb13f3
--- /dev/null
+++ b/src/MagnifierLens.py
@@ -0,0 +1,146 @@
+from __future__ import annotations
+
+import math
+
+from PySide6 import QtCore, QtGui, QtWidgets
+
+
+class _MagnifierRingOverlay(QtWidgets.QWidget):
+ def __init__(self, parent, outline_color):
+ super().__init__(parent)
+ self._outline_color = QtGui.QColor(outline_color)
+ self._outline_width = 2.0
+
+ self.setAttribute(QtCore.Qt.WA_TransparentForMouseEvents, True)
+ self.setAttribute(QtCore.Qt.WA_NoSystemBackground, True)
+ self.setAttribute(QtCore.Qt.WA_TranslucentBackground, True)
+
+ def setOutlineWidth(self, width):
+ self._outline_width = max(1.0, min(8.0, float(width)))
+ self.update()
+
+ def paintEvent(self, event):
+ super().paintEvent(event)
+
+ painter = QtGui.QPainter(self)
+ painter.setRenderHint(QtGui.QPainter.Antialiasing, True)
+ painter.setBrush(QtCore.Qt.NoBrush)
+
+ margin = self._outline_width / 2.0 + 1.5
+ ring_rect = QtCore.QRectF(self.rect()).adjusted(
+ margin,
+ margin,
+ -margin,
+ -margin,
+ )
+
+ pen = QtGui.QPen(self._outline_color, self._outline_width)
+ pen.setCapStyle(QtCore.Qt.RoundCap)
+ pen.setJoinStyle(QtCore.Qt.RoundJoin)
+ painter.setPen(pen)
+ painter.drawEllipse(ring_rect)
+
+
+class MagnifierLensWidget(QtWidgets.QWidget):
+ OUTLINE_COLOR = QtGui.QColor(38, 58, 90, 230)
+
+ def __init__(self, main_view):
+ super().__init__(main_view.viewport())
+ self._main_view = main_view
+ self._magnification = 2.0
+ self._outline_width = 2.0
+
+ self.setAttribute(QtCore.Qt.WA_TransparentForMouseEvents, True)
+ self.setAttribute(QtCore.Qt.WA_NoSystemBackground, True)
+ self.setAttribute(QtCore.Qt.WA_TranslucentBackground, True)
+
+ self._lens_view = QtWidgets.QGraphicsView(main_view.scene(), self)
+ self._lens_view.setFrameShape(QtWidgets.QFrame.NoFrame)
+ self._lens_view.setLineWidth(0)
+ self._lens_view.setInteractive(False)
+ self._lens_view.setFocusPolicy(QtCore.Qt.NoFocus)
+ self._lens_view.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff)
+ self._lens_view.setVerticalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff)
+ self._lens_view.setTransformationAnchor(QtWidgets.QGraphicsView.AnchorViewCenter)
+ self._lens_view.setResizeAnchor(QtWidgets.QGraphicsView.AnchorViewCenter)
+ self._lens_view.setViewportUpdateMode(main_view.viewportUpdateMode())
+ self._lens_view.setRenderHints(main_view.renderHints())
+ self._lens_view.setAttribute(QtCore.Qt.WA_TransparentForMouseEvents, True)
+ self._lens_view.viewport().setAttribute(
+ QtCore.Qt.WA_TransparentForMouseEvents,
+ True,
+ )
+ self._ring_overlay = _MagnifierRingOverlay(self, self.OUTLINE_COLOR)
+
+ self.syncFromMainView()
+ self.hide()
+
+ def setLensSize(self, diameter):
+ diameter = max(64, int(round(diameter)))
+ self.resize(diameter, diameter)
+ self._updateLensGeometry()
+ self.update()
+
+ def setMagnification(self, magnification):
+ self._magnification = max(1.0, float(magnification))
+
+ def setOutlineWidth(self, width):
+ self._outline_width = max(1.0, min(8.0, float(width)))
+ self._ring_overlay.setOutlineWidth(self._outline_width)
+ self._updateLensGeometry()
+ self.update()
+
+ def syncFromMainView(self):
+ if self._lens_view.scene() is not self._main_view.scene():
+ self._lens_view.setScene(self._main_view.scene())
+
+ self._lens_view.setRenderHints(self._main_view.renderHints())
+ self._lens_view.setViewportUpdateMode(self._main_view.viewportUpdateMode())
+ self._lens_view.setStyleSheet(self._main_view.styleSheet())
+
+ def updateLens(self, view_pos):
+ self.syncFromMainView()
+
+ scene_pos = self._main_view.mapToScene(view_pos)
+ transform = QtGui.QTransform(self._main_view.transform())
+ transform.scale(self._magnification, self._magnification)
+
+ self._lens_view.setTransform(transform)
+ self._lens_view.centerOn(scene_pos)
+ self._moveLens(view_pos)
+ self.show()
+ self.raise_()
+
+ def resizeEvent(self, event):
+ super().resizeEvent(event)
+ self._updateLensGeometry()
+
+ def paintEvent(self, event):
+ super().paintEvent(event)
+
+ def _updateLensGeometry(self):
+ self._ring_overlay.setGeometry(self.rect())
+ self._ring_overlay.raise_()
+
+ margin = max(6, int(math.ceil(self._outline_width)) + 4)
+ inner_rect = self.rect().adjusted(
+ margin,
+ margin,
+ -margin,
+ -margin,
+ )
+ self._lens_view.setGeometry(inner_rect)
+ self._lens_view.setMask(QtGui.QRegion(inner_rect, QtGui.QRegion.Ellipse))
+
+ def _moveLens(self, view_pos):
+ diameter = self.width()
+ radius = diameter // 2
+ parent_rect = self.parentWidget().rect()
+
+ x_pos = view_pos.x() - radius
+ y_pos = view_pos.y() - radius
+
+ x_pos = max(0, min(x_pos, max(0, parent_rect.width() - diameter)))
+ y_pos = max(0, min(y_pos, max(0, parent_rect.height() - diameter)))
+
+ self.move(x_pos, y_pos)
diff --git a/src/MathUtils.py b/src/MathUtils.py
new file mode 100644
index 0000000..9edbd8e
--- /dev/null
+++ b/src/MathUtils.py
@@ -0,0 +1,209 @@
+# -*- coding: utf-8 -*-
+
+import numpy as np
+
+
+class Transformations:
+ """Collection of static methods for geometric transformations
+
+ Dependencies: numpy (np)
+
+ NOTE: Homogenous coordinates are used,
+ i.e. 4-th dimension is 1: P(x/y/z/1)
+ This is necessary so that also translation
+ can be handled using matrix operations
+
+ Several geometric operations can be combined into one transformation matrix
+ by multiplying all individual transformations.
+ Multiplication via numpy.dot() --> A*B: A.dot(B)
+ Order is from right (1st transformation) to left (last transformation)
+
+ Example: Rotate (R) then translate (T1) then scale (S) then mirror (M)
+ then translate (T2)
+ Setup individual transformation matrices
+ >>> R = rotate3D(...)
+ >>> T1 = translate3D(...)
+ >>> S = scale3D(...)
+ >>> M = mirror3D(...)
+ >>> T2 = translate3D(...)
+ Compile combined transformation matrix
+ >>> MATRIX = T2.dot(M.dot(S.dot(T1.dot(R))))
+ Apply combined transformation to points(s)
+ >>> P = (1., 2., 6., 1.)
+ >>> P_new = MATRIX.dot(P)
+ """
+
+ @staticmethod
+ def rotate3D(axis='x', phi=0.0, degree=True):
+ """Calculate 3D transformation matrix for rotation
+ around one of the coordinate system axis
+
+ Args:
+ axis (str, optional): Rotation axis
+ phi (float, optional): Rotation angle
+ degree (bool, optional): Specifies if input is in degree or radians
+
+ EXAMPLE: Rotate point P around x-axis by 90° to get P_rot
+ >>> P = (1., 2., 6., 1.)
+ >>> rotmat = rotate3D(axis='x', phi=90.0)
+ >>> P_rot = rotmat.dot(P)
+ """
+
+ if degree:
+ phi = phi / 180.0 * np.pi
+
+ if axis == 'x':
+ # 3D rotation x-axis
+ ROT = np.array([[1.0, 0.0, 0.0, 0.0],
+ [0.0, np.cos(phi), -np.sin(phi), 0.0],
+ [0.0, np.sin(phi), np.cos(phi), 0.0],
+ [0.0, 0.0, 0.0, 1.0]])
+ elif axis == 'y':
+ # 3D rotation y-axis
+ ROT = np.array([[np.cos(phi), 0.0, np.sin(phi), 0.0],
+ [0.0, 1.0, 0.0, 0.0],
+ [-np.sin(phi), 0.0, np.cos(phi), 0.0],
+ [0.0, 0.0, 0.0, 1.0]])
+ elif axis == 'z':
+ # 3D rotation z-axis
+ ROT = np.array([[np.cos(phi), -np.sin(phi), 0.0, 0.0],
+ [np.sin(phi), np.cos(phi), 0.0, 0.0],
+ [0.0, 0.0, 1.0, 0.0],
+ [0.0, 0.0, 0.0, 1.0]])
+
+ return ROT
+
+ @staticmethod
+ def translate3D(vector):
+ """Calculate 3D transformation matrix for translation
+ along a given vector
+
+ Args:
+ vector (tuple): x, y, z coordinates of translation vector
+
+ EXAMPLE: Translate point P along vector to get P_trans
+ >>> P = (1., 2., 6., 1.)
+ >>> vector = (10., 0., 0., 1.)
+ >>> transmat = translate3D(vector)
+ >>> P_trans = transmat.dot(P)
+ """
+ TRANS = np.array([[1.0, 0.0, 0.0, vector[0]],
+ [0.0, 1.0, 0.0, vector[1]],
+ [0.0, 0.0, 1.0, vector[2]],
+ [0.0, 0.0, 0.0, 1.0]])
+ return TRANS
+
+ @staticmethod
+ def scale3D(scale):
+ """Calculate 3D transformation matrix for scaling
+
+ Args:
+ scale (tuple): Scaling factors for each axis
+
+ EXAMPLE: Scale point P by sx, sy, sz to get P_scale
+ >>> P = (1., 2., 6., 1.)
+ >>> scale = (2., 2., 2.)
+ >>> scalemat = scale3D(scale)
+ >>> P_scale = scalemat.dot(P)
+ """
+ SCALE = np.array([[scale[0], 0.0, 0.0, 0.0],
+ [0.0, scale[1], 0.0, 0.0],
+ [0.0, 0.0, scale[2], 0.0],
+ [0.0, 0.0, 0.0, 1.0]])
+ return SCALE
+
+ @staticmethod
+ def mirror3D(plane='xy'):
+ """Calculate 3D transformation matrix for mirroring wrt to xy, xz, yz planes
+
+ EXAMPLE: Mirror point P wrt xy-plane to get P_mirror
+ >>> P = (1., 2., 6., 1.)
+ >>> mirmat = mirror3D(plane='xy')
+ >>> P_rot = mirmat.dot(P)
+ """
+ if plane == 'xy':
+ # 3D mirroring wrt xy-plane
+ MIRROR = np.array([[1.0, 0.0, 0.0, 0.0],
+ [0.0, 1.0, 0.0, 0.0],
+ [0.0, 0.0, -1.0, 0.0],
+ [0.0, 0.0, 0.0, 1.0]])
+ elif plane == 'xz':
+ # 3D mirroring wrt xz-plane
+ MIRROR = np.array([[1.0, 0.0, 0.0, 0.0],
+ [0.0, -1.0, 0.0, 0.0],
+ [0.0, 0.0, 1.0, 0.0],
+ [0.0, 0.0, 0.0, 1.0]])
+ elif plane == 'yz':
+ # 3D mirroring wrt yz-plane
+ MIRROR = np.array([[-1.0, 0.0, 0.0, 0.0],
+ [0.0, 1.0, 0.0, 0.0],
+ [0.0, 0.0, 1.0, 0.0],
+ [0.0, 0.0, 0.0, 1.0]])
+ return MIRROR
+
+
+class VectorUtils:
+ """Collection of utility functions (static methods).
+ """
+ def __init__(self):
+ pass
+
+ @staticmethod
+ def vector(p1, p2):
+ """Returns a vector made of two points
+
+ Args:
+ p1 (tuple, list or np.array): Point, e.g. (1, 2) or [7., 4.3]
+ p2 (tuple, list or np.array): Point, e.g. (1, 2) or [7., 4.3]
+
+ Returns:
+ np.array: vector in numpy format
+ """
+
+ p1 = np.array(p1)
+ p2 = np.array(p2)
+
+ return p2 - p1
+
+ @staticmethod
+ def vector_length(vector):
+ """ Returns the length of the vector. """
+ return np.linalg.norm(np.asarray(vector, dtype=float), axis=-1)
+
+ @staticmethod
+ def unit_vector(vector):
+ """Return a normalized vector.
+
+ Zero-length vectors are mapped to zeros instead of returning NaNs.
+ Supports both single vectors and batched vectors along the last axis.
+ """
+ vector = np.asarray(vector, dtype=float)
+ norms = np.linalg.norm(vector, axis=-1, keepdims=True)
+ return np.divide(
+ vector,
+ norms,
+ out=np.zeros_like(vector, dtype=float),
+ where=norms > 0.0,
+ )
+
+ @staticmethod
+ def angle_between(a, b, degree=False):
+ """Returns the angle between
+ vectors 'a' and 'b'
+ """
+ a = np.asarray(a, dtype=float)
+ b = np.asarray(b, dtype=float)
+ a, b = np.broadcast_arrays(a, b)
+
+ dot_product = np.sum(a * b, axis=-1)
+ norms = VectorUtils.vector_length(a) * VectorUtils.vector_length(b)
+ cosine = np.divide(
+ dot_product,
+ norms,
+ out=np.ones_like(dot_product, dtype=float),
+ where=norms > 0.0,
+ )
+ angle = np.arccos(np.clip(cosine, -1.0, 1.0))
+ if degree:
+ angle *= 180.0 / np.pi
+ return angle
diff --git a/src/MenusTools.py b/src/MenusTools.py
index c15d6cf..2928bc4 100644
--- a/src/MenusTools.py
+++ b/src/MenusTools.py
@@ -1,174 +1,75 @@
-
-
-
-import os
-import platform
-import xml.etree.ElementTree as etree
-
-from PySide6 import QtGui, QtCore, QtWidgets
-
-from Settings import ICONS_S, ICONS_L, MENUDATA
-
-import logging
-logger = logging.getLogger(__name__)
-
-
-class MenusTools:
- # call constructor of MenusTools
-
- def __init__(self, parent=None):
-
- self.parent = parent
-
- def getMenuData(self):
- """get all menus and pulldowns from the external XML file"""
-
- menudata = list()
-
- xml_file = os.path.join(MENUDATA, 'PMenu.xml')
- xml = etree.parse(xml_file)
- menu_structure = xml.getroot()
-
- for menu in menu_structure.findall('Menubar'):
- mname = menu.attrib['name']
- items = menu.findall('Submenu')
- pulldowns = self.getPullDownData(items)
- menudata.append((mname, [s for s in pulldowns]))
-
- # attach available pulldowns to the mainwindow
- # so it can be used elsewhere (e.g. Guislots)
- self.parent.menudata = menudata
- return tuple(menudata)
-
- def getPullDownData(self, items):
-
- pulldowns = list()
- for sub in items:
- sname = sub.attrib['name']
- if sname == 'Separator':
- pulldowns.append(('', '', '', '', self.onPass))
- continue
- tip = sub.attrib['tip']
- icon = sub.attrib['icon']
- shortcut = sub.attrib['short']
- handler = sub.attrib['handler']
- pulldowns.append((sname, tip, shortcut, icon, handler))
-
-
- return pulldowns
-
- def createMenus(self):
- """create the menubar and populate it automatically"""
- # create a menu bar
- # self.menubar = QtWidgets.QMenuBar()
- self.menubar = self.parent.menuBar()
-
- # for MacOS in order that the menu stays with the window
- pltf = platform.system()
- if 'Darwin' in pltf:
- self.menubar.setNativeMenuBar(False)
-
- for eachMenu in self.getMenuData():
- name = eachMenu[0]
- menu = self.menubar.addMenu(name)
-
- pulldown = eachMenu[1]
- self.createPullDown(menu, pulldown)
- return
-
- def createPullDown(self, menu, eachPullDown):
- """create the submenu structure to method createMenus"""
- for name, tip, short, icon, handler in eachPullDown:
-
- if len(name) == 0:
- menu.addSeparator()
- continue
-
- icon = QtGui.QIcon(os.path.join(ICONS_S, icon))
-
- logger.debug('HANDLER: {}'.format(handler))
-
- handler = 'self.parent.slots.' + handler
-
- action = QtGui.QAction(icon, name, self.parent,
- shortcut=short, statusTip=tip,
- triggered=eval(handler))
-
- action.setStatusTip(tip)
- action.setShortcut(short)
- # action.triggered.connect(eval(handler))
- menu.addAction(action)
-
- def getToolbarData(self):
- """get all menus and submenus from the external XML file"""
-
- xml_file = os.path.join(MENUDATA, 'PToolBar.xml')
- xml = etree.parse(xml_file)
- tool_structure = xml.getroot()
-
- tooldata = list()
-
- for toolbar in tool_structure.findall('Toolbar'):
- for tool in toolbar.findall('Tool'):
- if tool.attrib['handler'] == 'self.onPass':
- tooldata.append(('', '', '', '', self.onPass))
- continue
- tip = tool.attrib['tip']
- icon = tool.attrib['icon']
- handler = tool.attrib['handler']
- tooldata.append((tip, icon, handler))
-
- return tuple(tooldata)
-
- def createTools(self):
- """create the toolbar and populate it automatically
- from method toolData
- """
- # create a toolbar
- self.toolbar = QtWidgets.QToolBar('Toolbar')
- self.parent.addToolBar(self.toolbar)
-
- for tip, icon, handler in self.getToolbarData():
- if len(tip) == 0:
- self.toolbar.addSeparator()
- continue
- icon = QtGui.QIcon(os.path.join(ICONS_L, icon))
-
- # guislot converts to:
- # self.parent.slots.slotMethod()
- guislot = getattr(self.parent.slots, handler)
-
- action = QtGui.QAction(icon, tip, parent=self.parent)
- # action.setIcon(icon)
- # action.setToolTip(tip)
- action.triggered.connect(guislot)
-
- self.toolbar.addAction(action)
-
- def createDocks(self):
- self.parent.messagedock = QtWidgets.QDockWidget(self.parent)
- self.parent.messagedock. \
- setFeatures(QtWidgets.QDockWidget.DockWidgetMovable |
- QtWidgets.QDockWidget.DockWidgetFloatable)
- self.parent.messagedock.setWindowTitle('Messages')
- self.parent.messagedock.setMinimumSize(100, 50)
- # connect messagedock to slot
- self.parent.messagedock.topLevelChanged.connect(
- self.parent.slots.onLevelChanged)
-
- self.parent.messages = QtWidgets.QTextEdit(self.parent)
- self.parent.messages. \
- setTextInteractionFlags(QtCore.Qt.TextSelectableByMouse |
- QtCore.Qt.TextSelectableByKeyboard)
- # connect messages to scrollhandler
- self.parent.messages.textChanged.connect(
- self.parent.slots.onTextChanged)
-
- self.parent.messagedock.setWidget(self.parent.messages)
-
- place = QtCore.Qt.BottomDockWidgetArea
- self.parent.addDockWidget(
- QtCore.Qt.DockWidgetArea(place), self.parent.messagedock)
-
- def onPass(self):
- pass
+import platform
+
+from PySide6 import QtCore, QtWidgets
+
+from ActionRegistry import SEPARATOR_TOKEN
+from Utils import get_main_window
+
+
+class MenusTools:
+ def __init__(self, mainwindow=None):
+ self.mw = mainwindow or get_main_window()
+
+ def createMenus(self):
+ self.menubar = self.mw.menuBar()
+
+ if platform.system() == 'Darwin':
+ self.menubar.setNativeMenuBar(False)
+
+ self.mw.menudata = self.mw.action_registry.menu_layout()
+ for menu_definition in self.mw.menudata:
+ menu = self.menubar.addMenu(menu_definition['name'])
+ self._populate_action_container(menu, menu_definition['items'])
+
+ def createTools(self):
+ self.mw.tooldata = self.mw.action_registry.toolbar_layout()
+ self.toolbars = []
+ for toolbar_definition in self.mw.tooldata:
+ toolbar = QtWidgets.QToolBar(toolbar_definition.get('name', 'Toolbar'))
+ toolbar.setIconSize(QtCore.QSize(20, 20))
+ self.mw.addToolBar(toolbar)
+ self._populate_action_container(toolbar, toolbar_definition['items'])
+ self.toolbars.append(toolbar)
+
+ self.toolbar = self.toolbars[0] if self.toolbars else None
+
+ def _populate_action_container(self, container, items):
+ for item in items:
+ if item == SEPARATOR_TOKEN:
+ container.addSeparator()
+ continue
+
+ if isinstance(item, dict):
+ submenu_name = item.get('name', '').strip()
+ submenu_items = item.get('items', [])
+ if submenu_name and isinstance(submenu_items, list):
+ submenu = container.addMenu(submenu_name)
+ self._populate_action_container(submenu, submenu_items)
+ continue
+
+ action = self.mw.action_registry.action(item)
+ if action is not None:
+ container.addAction(action)
+
+ def createDocks(self):
+ if hasattr(self.mw, 'messagedock') and hasattr(self.mw, 'messages'):
+ return
+
+ messagedock = QtWidgets.QDockWidget(self.mw)
+ messagedock.setFeatures(QtWidgets.QDockWidget.DockWidgetMovable |
+ QtWidgets.QDockWidget.DockWidgetFloatable)
+ messagedock.setWindowTitle('Messages')
+ messagedock.setMinimumSize(100, 50)
+ messagedock.topLevelChanged.connect(self.mw.slots.onLevelChanged)
+
+ self.mw.messages = QtWidgets.QTextEdit(self.mw)
+ self.mw.messages.setTextInteractionFlags(
+ QtCore.Qt.TextSelectableByMouse | QtCore.Qt.TextSelectableByKeyboard
+ )
+ self.mw.messages.setAcceptRichText(True)
+ self.mw.messages.textChanged.connect(self.mw.slots.onTextChanged)
+
+ messagedock.setWidget(self.mw.messages)
+ self.mw.addDockWidget(QtCore.Qt.BottomDockWidgetArea, messagedock)
+
+ self.mw.messagedock = messagedock
diff --git a/src/Mesh.py b/src/Mesh.py
new file mode 100644
index 0000000..b48141e
--- /dev/null
+++ b/src/Mesh.py
@@ -0,0 +1,1168 @@
+from __future__ import annotations
+
+import logging
+import os
+from abc import ABC, abstractmethod
+from collections import Counter, defaultdict
+from dataclasses import dataclass, field
+from typing import Any, Iterable, Mapping, Sequence
+
+import numpy as np
+
+from Shape import Polygon, Polyline, Point2D
+
+logger = logging.getLogger(__name__)
+
+
+Edge = tuple[int, int]
+
+
+def _as_vertices(vertices: Sequence[Sequence[float]]) -> np.ndarray:
+ array = np.asarray(vertices, dtype=float)
+ if array.ndim != 2 or array.shape[1] != 2:
+ raise ValueError('Vertices must be a 2D array with shape (n, 2).')
+ return array
+
+
+def _as_connectivity(connectivity: Sequence[Sequence[int]]) -> np.ndarray:
+ array = np.asarray(connectivity, dtype=int)
+ if array.ndim != 2:
+ raise ValueError('Connectivity must be a 2D integer array.')
+ return array
+
+
+def _normalized_edge(edge: Sequence[int]) -> Edge:
+ if len(edge) != 2:
+ raise ValueError('Edges must contain exactly two vertex ids.')
+ a, b = int(edge[0]), int(edge[1])
+ return a, b
+
+
+def _sorted_edge(edge: Sequence[int]) -> Edge:
+ a, b = _normalized_edge(edge)
+ return (a, b) if a <= b else (b, a)
+
+
+def _trace_edge_path(edges: Sequence[Sequence[int]]) -> tuple[list[int], bool]:
+ if not edges:
+ return [], False
+
+ adjacency: dict[int, list[tuple[int, int]]] = defaultdict(list)
+ normalized_edges = [_normalized_edge(edge) for edge in edges]
+
+ for index, (start, end) in enumerate(normalized_edges):
+ adjacency[start].append((index, end))
+ adjacency[end].append((index, start))
+
+ endpoints = [node for node, neighbours in adjacency.items()
+ if len(neighbours) == 1]
+ is_closed = not endpoints
+ start = min(endpoints) if endpoints else min(adjacency)
+
+ ordered_nodes = [start]
+ used_edges: set[int] = set()
+ previous = None
+ current = start
+
+ while len(used_edges) < len(normalized_edges):
+ candidates = [
+ (edge_index, neighbour)
+ for edge_index, neighbour in adjacency[current]
+ if edge_index not in used_edges
+ ]
+
+ if not candidates:
+ break
+
+ if previous is None:
+ edge_index, next_node = candidates[0]
+ else:
+ edge_index, next_node = next(
+ (
+ candidate for candidate in candidates
+ if candidate[1] != previous
+ ),
+ candidates[0],
+ )
+
+ used_edges.add(edge_index)
+ ordered_nodes.append(next_node)
+ previous, current = current, next_node
+
+ if len(used_edges) != len(normalized_edges):
+ raise ValueError('Boundary edges do not form a single connected path.')
+
+ if is_closed and ordered_nodes[0] != ordered_nodes[-1]:
+ ordered_nodes.append(ordered_nodes[0])
+
+ return ordered_nodes, is_closed
+
+
+@dataclass(slots=True)
+class MeshStatistics:
+ vertex_count: int
+ cell_count: int
+ edge_count: int
+ block_count: int = 0
+
+
+@dataclass(slots=True)
+class MeshBlock:
+ name: str
+ u_lines: list[list[Point2D]]
+
+ @classmethod
+ def from_legacy(cls, block) -> 'MeshBlock':
+ lines = [
+ [(float(x), float(y)) for x, y in line]
+ for line in block.getULines()
+ ]
+ return cls(name=getattr(block, 'name', 'block'), u_lines=lines)
+
+ @property
+ def is_empty(self) -> bool:
+ return not self.u_lines
+
+ @property
+ def v_lines(self) -> list[list[Point2D]]:
+ if not self.u_lines:
+ return []
+ line_length = len(self.u_lines[0])
+ return [
+ [u_line[index] for u_line in self.u_lines]
+ for index in range(line_length)
+ ]
+
+ @property
+ def divisions(self) -> tuple[int, int]:
+ if self.is_empty:
+ return 0, 0
+ return len(self.u_lines[0]) - 1, len(self.u_lines) - 1
+
+ def iter_boundary_lines(self) -> Iterable[list[Point2D]]:
+ if self.is_empty:
+ return
+ yield self.u_lines[0]
+ yield self.u_lines[-1]
+ v_lines = self.v_lines
+ yield v_lines[0]
+ yield v_lines[-1]
+
+
+@dataclass(slots=True)
+class MeshData:
+ vertices: np.ndarray | Sequence[Sequence[float]]
+ connectivity: np.ndarray | Sequence[Sequence[int]]
+ boundary_tags: dict[str, list[Edge]] = field(default_factory=dict)
+ cell_to_vertices: np.ndarray | Sequence[Sequence[int]] | None = None
+ cell_to_edges: Mapping[int, Sequence[Sequence[int]]] | None = None
+ quality: np.ndarray | Sequence[float] | None = None
+ metadata: dict[str, Any] = field(default_factory=dict)
+
+ def __post_init__(self):
+ self.vertices = _as_vertices(self.vertices)
+ self.connectivity = _as_connectivity(self.connectivity)
+
+ if self.cell_to_vertices is None:
+ self.cell_to_vertices = self.connectivity.copy()
+ else:
+ self.cell_to_vertices = _as_connectivity(self.cell_to_vertices)
+
+ normalized_tags: dict[str, list[Edge]] = {}
+ for tag, edges in self.boundary_tags.items():
+ normalized_tags[tag] = [_normalized_edge(edge) for edge in edges]
+ self.boundary_tags = normalized_tags
+
+ if self.cell_to_edges is None:
+ self.cell_to_edges = self._build_cell_to_edges()
+ else:
+ self.cell_to_edges = {
+ int(cell_id): [_normalized_edge(edge) for edge in edges]
+ for cell_id, edges in self.cell_to_edges.items()
+ }
+
+ if self.quality is not None:
+ self.quality = np.asarray(self.quality, dtype=float)
+
+ @property
+ def vertex_count(self) -> int:
+ return int(len(self.vertices))
+
+ @property
+ def cell_count(self) -> int:
+ return int(len(self.connectivity))
+
+ def _build_cell_to_edges(self) -> dict[int, list[Edge]]:
+ mapping: dict[int, list[Edge]] = {}
+ for cell_id, cell in enumerate(self.connectivity):
+ edges = []
+ for index in range(len(cell)):
+ edge = (int(cell[index]), int(cell[(index + 1) % len(cell)]))
+ edges.append(edge)
+ mapping[cell_id] = edges
+ return mapping
+
+ def unique_edges(self) -> list[Edge]:
+ unique = {_sorted_edge(edge)
+ for edges in self.cell_to_edges.values()
+ for edge in edges}
+ return sorted(unique)
+
+ def statistics(self, block_count: int = 0) -> MeshStatistics:
+ return MeshStatistics(
+ vertex_count=self.vertex_count,
+ cell_count=self.cell_count,
+ edge_count=len(self.unique_edges()),
+ block_count=block_count,
+ )
+
+ def boundary_vertices(self, tag: str) -> list[Point2D]:
+ edges = self.boundary_tags.get(tag, [])
+ if not edges:
+ return []
+ ordered_ids, _ = _trace_edge_path(edges)
+ return [
+ (
+ float(self.vertices[vertex_id][0]),
+ float(self.vertices[vertex_id][1]),
+ )
+ for vertex_id in ordered_ids
+ ]
+
+ def boundary_vertices_from_edges(self, edges: Sequence[Sequence[int]]) -> list[Point2D]:
+ ordered_ids, _ = _trace_edge_path(edges)
+ return [
+ (
+ float(self.vertices[vertex_id][0]),
+ float(self.vertices[vertex_id][1]),
+ )
+ for vertex_id in ordered_ids
+ ]
+
+ def outer_boundary_vertices(self, excluded_tags: Sequence[str] = ('airfoil',)
+ ) -> list[Point2D]:
+ excluded = set(excluded_tags)
+ edges = []
+ for tag, tagged_edges in self.boundary_tags.items():
+ if tag in excluded:
+ continue
+ edges.extend(tagged_edges)
+
+ if not edges:
+ return []
+ return self.boundary_vertices_from_edges(edges)
+
+ def boundary_shape(self, tag: str, closed: bool | None = None,
+ name: str | None = None):
+ edges = self.boundary_tags.get(tag, [])
+ if not edges:
+ return None
+
+ points = self.boundary_vertices(tag)
+ _, inferred_closed = _trace_edge_path(edges)
+ is_closed = inferred_closed if closed is None else bool(closed)
+
+ if is_closed and points and points[0] == points[-1]:
+ points = points[:-1]
+
+ label = name or tag
+ if is_closed:
+ return Polygon(points, name=label)
+ return Polyline(points, closed=False, name=label)
+
+ @classmethod
+ def from_file(cls, filename: str, mesh_format: str | None = None,
+ **kwargs) -> 'MeshData':
+ """Read mesh data from disk via the mesh import registry."""
+ return MeshImportRegistry.import_file(
+ filename,
+ mesh_format=mesh_format,
+ **kwargs,
+ )
+
+
+class Mesh(ABC):
+ """Abstract mesh model used by the new domain layer."""
+
+ def __init__(self, name: str = '', data: MeshData | None = None,
+ metadata: Mapping[str, Any] | None = None):
+ self.name = name or self.__class__.__name__
+ self.data = data
+ self.metadata = dict(metadata or {})
+
+ @abstractmethod
+ def generate_mesh(self) -> MeshData | None:
+ """Generate or return the mesh data."""
+
+ def mesh_quality(self):
+ if self.data is None:
+ return None
+ return self.data.quality
+
+ def mesh_statistics(self) -> MeshStatistics:
+ if self.data is None:
+ return MeshStatistics(0, 0, 0, 0)
+ return self.data.statistics()
+
+ def display_mesh(self):
+ return self.data
+
+
+class BlockStructuredMesh(Mesh):
+ """Model wrapper around a block-structured mesh and its topology."""
+
+ def __init__(self, name: str = '', blocks: Iterable[MeshBlock] | None = None,
+ data: MeshData | None = None,
+ boundary_conditions: Mapping[str, str] | None = None,
+ metadata: Mapping[str, Any] | None = None):
+ super().__init__(name=name, data=data, metadata=metadata)
+ self.blocks = list(blocks or [])
+ self.boundary_conditions = dict(boundary_conditions or {})
+
+ def add_block(self, block: MeshBlock):
+ self.blocks.append(block)
+
+ def generate_mesh(self) -> MeshData | None:
+ return self.data
+
+ def mesh_statistics(self) -> MeshStatistics:
+ if self.data is None:
+ return MeshStatistics(0, 0, 0, len(self.blocks))
+ return self.data.statistics(block_count=len(self.blocks))
+
+ @classmethod
+ def from_windtunnel(cls, wind_tunnel, name: str = '',
+ boundary_conditions: Mapping[str, str] | None = None):
+ blocks = [MeshBlock.from_legacy(block) for block in wind_tunnel.blocks]
+
+ vertices, connectivity = wind_tunnel.mesh
+ topology = getattr(wind_tunnel, 'topology', None)
+ if topology is None:
+ topology = MeshTopology.from_mesh(vertices, connectivity)
+ mesh_data = MeshData(
+ vertices=vertices,
+ connectivity=connectivity,
+ boundary_tags=topology.boundary_tags,
+ cell_to_vertices=topology.cell_to_vertices,
+ cell_to_edges=topology.cell_to_edges,
+ quality=getattr(wind_tunnel, 'quality', None),
+ metadata={
+ 'source': wind_tunnel.__class__.__name__,
+ 'block_names': [block.name for block in blocks],
+ 'boundary_edges': list(topology.boundary_edges),
+ },
+ )
+
+ return cls(
+ name=name or getattr(wind_tunnel, 'name', 'BlockStructuredMesh'),
+ blocks=blocks,
+ data=mesh_data,
+ boundary_conditions=boundary_conditions,
+ metadata={'legacy_engine': wind_tunnel.__class__.__name__},
+ )
+
+
+class BlockMesh(BlockStructuredMesh):
+ """Backward-compatible block mesh name for the new model layer."""
+
+
+class UnstructuredMesh(Mesh):
+ def __init__(self, name: str = '', data: MeshData | None = None,
+ element_type: str = 'mixed',
+ metadata: Mapping[str, Any] | None = None):
+ super().__init__(name=name, data=data, metadata=metadata)
+ self.element_type = element_type
+
+ def generate_mesh(self) -> MeshData | None:
+ return self.data
+
+
+class TriangularMesh(UnstructuredMesh):
+ def __init__(self, name: str = '', data: MeshData | None = None,
+ use_quads_near_airfoil: bool = True,
+ metadata: Mapping[str, Any] | None = None):
+ super().__init__(
+ name=name or 'TriangularMesh',
+ data=data,
+ element_type='triangle',
+ metadata=metadata,
+ )
+ self.use_quads_near_airfoil = use_quads_near_airfoil
+
+
+@dataclass(slots=True)
+class BoundaryDefinitions:
+ airfoil: str = 'airfoil'
+ inlet: str = 'inlet'
+ outlet: str = 'outlet'
+ top: str = 'top'
+ bottom: str = 'bottom'
+
+ @classmethod
+ def from_mapping(cls, mapping: Mapping[str, str] | None = None):
+ defaults = cls()
+ if not mapping:
+ return defaults
+
+ def _coerce_value(key: str, default: str) -> str:
+ if key not in mapping or mapping.get(key) is None:
+ return default
+
+ value = str(mapping[key]).strip()
+ if not value:
+ raise ValueError(
+ f'Boundary name for "{key}" cannot be empty.'
+ )
+ return value
+
+ definitions = cls(
+ airfoil=_coerce_value('airfoil', defaults.airfoil),
+ inlet=_coerce_value('inlet', defaults.inlet),
+ outlet=_coerce_value('outlet', defaults.outlet),
+ top=_coerce_value('top', defaults.top),
+ bottom=_coerce_value('bottom', defaults.bottom),
+ )
+ definitions.validate()
+ return definitions
+
+ def as_dict(self) -> dict[str, str]:
+ return {
+ 'airfoil': self.airfoil,
+ 'inlet': self.inlet,
+ 'outlet': self.outlet,
+ 'top': self.top,
+ 'bottom': self.bottom,
+ }
+
+ def items(self):
+ return self.as_dict().items()
+
+ def validate(self):
+ labels = list(self.as_dict().values())
+ duplicates = sorted(
+ label for label, count in Counter(labels).items() if count > 1
+ )
+ if duplicates:
+ labels_text = ', '.join(duplicates)
+ raise ValueError(
+ f'Boundary names must be unique. Duplicate names: {labels_text}'
+ )
+ return self
+
+
+class BoundaryClassifier:
+ default_tags = ('airfoil', 'inlet', 'outlet', 'top', 'bottom')
+
+ @staticmethod
+ def find_boundary_edges(edges: Sequence[Sequence[int]]) -> list[Edge]:
+ normalized = [_sorted_edge(edge) for edge in edges]
+ counts: dict[Edge, int] = defaultdict(int)
+ for edge in normalized:
+ counts[edge] += 1
+ return [edge for edge, count in counts.items() if count == 1]
+
+ @staticmethod
+ def _boundary_components(boundary_edges: Sequence[Sequence[int]]) -> list[list[Edge]]:
+ if not boundary_edges:
+ return []
+
+ adjacency: dict[int, list[Edge]] = defaultdict(list)
+ normalized_edges = [_sorted_edge(edge) for edge in boundary_edges]
+ for edge in normalized_edges:
+ adjacency[edge[0]].append(edge)
+ adjacency[edge[1]].append(edge)
+
+ components = []
+ seen_edges: set[Edge] = set()
+ for edge in normalized_edges:
+ if edge in seen_edges:
+ continue
+
+ stack = [edge]
+ component = []
+ while stack:
+ current = stack.pop()
+ if current in seen_edges:
+ continue
+ seen_edges.add(current)
+ component.append(current)
+ for node in current:
+ for neighbour in adjacency[node]:
+ if neighbour not in seen_edges:
+ stack.append(neighbour)
+
+ components.append(component)
+
+ return components
+
+ @classmethod
+ def classify(cls, vertices: Sequence[Sequence[float]],
+ edges: Sequence[Sequence[int]],
+ tolerance: float = 1.0e-6) -> tuple[list[Edge], dict[str, list[Edge]]]:
+ vertices_array = _as_vertices(vertices)
+ boundary_edges = cls.find_boundary_edges(edges)
+ boundary_tags = {tag: [] for tag in cls.default_tags}
+
+ if not boundary_edges:
+ return boundary_edges, boundary_tags
+
+ xmin = float(np.min(vertices_array[:, 0]))
+ xmax = float(np.max(vertices_array[:, 0]))
+ ymax = float(np.max(vertices_array[:, 1]))
+ ymin = float(np.min(vertices_array[:, 1]))
+ x_span = max(xmax - xmin, tolerance)
+ y_span = max(ymax - ymin, tolerance)
+ components = cls._boundary_components(boundary_edges)
+
+ def _component_priority(component):
+ vertex_ids = sorted({vertex_id for edge in component for vertex_id in edge})
+ points = vertices_array[vertex_ids]
+ x_values = points[:, 0]
+ y_values = points[:, 1]
+ touches = int(np.any(np.abs(x_values - xmin) < tolerance))
+ touches += int(np.any(np.abs(x_values - xmax) < tolerance))
+ touches += int(np.any(np.abs(y_values - ymin) < tolerance))
+ touches += int(np.any(np.abs(y_values - ymax) < tolerance))
+ span_area = float(np.ptp(x_values) * np.ptp(y_values))
+ perimeter = float(sum(
+ np.linalg.norm(vertices_array[edge[1]] - vertices_array[edge[0]])
+ for edge in component
+ ))
+ return touches, span_area, perimeter
+
+ outer_component = max(components, key=_component_priority)
+
+ for component in components:
+ if component is outer_component:
+ continue
+ boundary_tags['airfoil'].extend(component)
+
+ for edge in outer_component:
+ x1 = vertices_array[edge[0]][0]
+ y1 = vertices_array[edge[0]][1]
+ x2 = vertices_array[edge[1]][0]
+ y2 = vertices_array[edge[1]][1]
+ x_mid = 0.5 * (x1 + x2)
+ y_mid = 0.5 * (y1 + y2)
+ dx = abs(x2 - x1)
+ dy = abs(y2 - y1)
+
+ if abs(x1 - xmax) < tolerance and abs(x2 - xmax) < tolerance:
+ boundary_tags['outlet'].append(edge)
+ elif abs(y1 - ymax) < tolerance and abs(y2 - ymax) < tolerance:
+ boundary_tags['top'].append(edge)
+ elif abs(y1 - ymin) < tolerance and abs(y2 - ymin) < tolerance:
+ boundary_tags['bottom'].append(edge)
+ elif dx >= dy and (ymax - y_mid) <= 0.08 * y_span:
+ boundary_tags['top'].append(edge)
+ elif dx >= dy and (y_mid - ymin) <= 0.08 * y_span:
+ boundary_tags['bottom'].append(edge)
+ elif (xmax - x_mid) <= 0.08 * x_span:
+ boundary_tags['outlet'].append(edge)
+ else:
+ boundary_tags['inlet'].append(edge)
+
+ return boundary_edges, boundary_tags
+
+
+@dataclass(slots=True)
+class MeshTopology:
+ cell_to_vertices: np.ndarray
+ cell_to_edges: dict[int, list[Edge]]
+ edges: list[Edge]
+ boundary_edges: list[Edge]
+ boundary_tags: dict[str, list[Edge]]
+
+ @classmethod
+ def from_mesh(cls, vertices: Sequence[Sequence[float]],
+ connectivity: Sequence[Sequence[int]],
+ tolerance: float = 1.0e-6) -> 'MeshTopology':
+ vertices_array = _as_vertices(vertices)
+ connectivity_array = _as_connectivity(connectivity)
+ cell_to_vertices = connectivity_array.copy()
+ cell_to_edges: dict[int, list[Edge]] = {}
+ edges: list[Edge] = []
+
+ for cell_id, cell in enumerate(connectivity_array):
+ cell_edges = []
+ for index in range(len(cell)):
+ edge = _normalized_edge(
+ (int(cell[index]), int(cell[(index + 1) % len(cell)]))
+ )
+ cell_edges.append(edge)
+ edges.append(_sorted_edge(edge))
+ cell_to_edges[cell_id] = cell_edges
+
+ boundary_edges, boundary_tags = BoundaryClassifier.classify(
+ vertices_array,
+ edges,
+ tolerance=tolerance,
+ )
+ return cls(
+ cell_to_vertices=cell_to_vertices,
+ cell_to_edges=cell_to_edges,
+ edges=edges,
+ boundary_edges=boundary_edges,
+ boundary_tags=boundary_tags,
+ )
+
+
+def _coerce_mesh_data(mesh_or_data) -> MeshData:
+ if isinstance(mesh_or_data, MeshData):
+ return mesh_or_data
+
+ if isinstance(mesh_or_data, Mesh):
+ if mesh_or_data.data is None:
+ raise ValueError('Mesh model does not carry mesh data yet.')
+ return mesh_or_data.data
+
+ raise TypeError('Expected a Mesh model or MeshData instance.')
+
+
+def _coerce_boundary_definitions(mesh_or_data, boundary_definitions=None):
+ if isinstance(boundary_definitions, BoundaryDefinitions):
+ return boundary_definitions
+
+ if boundary_definitions is not None:
+ return BoundaryDefinitions.from_mapping(boundary_definitions)
+
+ boundary_mapping = getattr(mesh_or_data, 'boundary_conditions', None)
+ return BoundaryDefinitions.from_mapping(boundary_mapping)
+
+
+class MeshImporter(ABC):
+ """Base class for 2D mesh importers.
+
+ The current import/export layer only operates on 2D mesh data.
+ FIRE/FLMA is a special case on disk because it stores the 2D mesh as a
+ single-cell extrusion in the third dimension.
+ """
+
+ format_name = 'mesh'
+
+ @abstractmethod
+ def read(self, filename: str, **kwargs) -> MeshData:
+ """Read mesh data from disk."""
+
+
+class PlannedMeshImporter(MeshImporter):
+ """Placeholder importer used to mark future mesh-import entry points."""
+
+ detail = 'Only 2D mesh import/export is in scope at the moment.'
+
+ def read(self, filename: str, **kwargs) -> MeshData:
+ format_label = self.format_name.upper()
+ basename = os.path.basename(filename)
+ raise NotImplementedError(
+ f'{format_label} mesh import is reserved for future work '
+ f'({basename}). {self.detail}'
+ )
+
+
+class FlmaImporter(PlannedMeshImporter):
+ format_name = 'flma'
+ detail = (
+ 'Only 2D mesh import/export is in scope at the moment. '
+ 'FIRE/FLMA currently represents the 2D mesh as a one-cell extrusion '
+ 'in the third dimension.'
+ )
+
+
+class Su2Importer(PlannedMeshImporter):
+ format_name = 'su2'
+
+
+class GmshImporter(PlannedMeshImporter):
+ format_name = 'msh'
+
+
+class AbaqusInpImporter(PlannedMeshImporter):
+ format_name = 'inp'
+
+
+class CgnsImporter(PlannedMeshImporter):
+ format_name = 'cgns'
+
+
+class VtuImporter(PlannedMeshImporter):
+ format_name = 'vtu'
+
+
+class VtkImporter(PlannedMeshImporter):
+ format_name = 'vtk'
+
+
+class MeshExporter(ABC):
+ format_name = 'mesh'
+
+ @abstractmethod
+ def write(self, mesh_or_data, name: str,
+ boundary_definitions: BoundaryDefinitions | Mapping[str, str] | None = None,
+ **kwargs):
+ """Write mesh data to disk."""
+
+
+class FlmaExporter(MeshExporter):
+ format_name = 'flma'
+
+ def write(self, mesh_or_data, name: str,
+ boundary_definitions: BoundaryDefinitions | Mapping[str, str] | None = None,
+ **kwargs):
+ mesh_data = _coerce_mesh_data(mesh_or_data)
+ vertices = mesh_data.vertices.tolist()
+ connectivity = mesh_data.connectivity.tolist()
+ depth = float(kwargs.get('depth', 0.3))
+
+ with open(name, 'w') as handle:
+ number_of_vertices_2d = len(vertices)
+ handle.write(str(2 * number_of_vertices_2d) + '\n')
+
+ # FIRE expects a thin 3D volume. For now the 2D mesh is exported
+ # as a single-cell extrusion in the third dimension.
+ signum = -1.0
+ for _ in range(2):
+ for vertex in vertices:
+ handle.write(
+ f'{vertex[0]} {vertex[1]} {signum * depth / 2.0} '
+ )
+ signum = 1.0
+
+ cells = len(connectivity)
+ handle.write('\n' + str(cells) + '\n')
+
+ for cell in connectivity:
+ cell_connect = (
+ f'{cell[0]} {cell[1]} {cell[2]} {cell[3]} '
+ f'{cell[0] + number_of_vertices_2d} '
+ f'{cell[1] + number_of_vertices_2d} '
+ f'{cell[2] + number_of_vertices_2d} '
+ f'{cell[3] + number_of_vertices_2d}\n'
+ )
+ handle.write('8\n')
+ handle.write(cell_connect)
+
+ handle.write('\n' + str(cells) + '\n')
+ handle.write(' '.join(['5'] * cells))
+ handle.write('\n\n')
+
+ handle.write('6\n')
+ handle.write('symmetry\n')
+ handle.write('3\n')
+ handle.write(str(4 * len(connectivity)) + '\n')
+ for index in range(len(connectivity)):
+ handle.write(f' {index} 0')
+ for index in range(len(connectivity)):
+ handle.write(f' {index} 1')
+ handle.write('\n\n')
+
+ for selection in ('bottom', 'top', 'back', 'front'):
+ handle.write(selection + '\n')
+ handle.write('3\n')
+ handle.write('2\n')
+ direction = {'bottom': 2, 'top': 3, 'back': 4, 'front': 5}[selection]
+ handle.write(f'0 {direction}\n')
+ handle.write('\n')
+
+ logger.info('FIRE type mesh saved as %s', os.path.basename(name))
+
+
+class Su2Exporter(MeshExporter):
+ format_name = 'su2'
+
+ def write(self, mesh_or_data, name: str,
+ boundary_definitions: BoundaryDefinitions | Mapping[str, str] | None = None,
+ **kwargs):
+ mesh_data = _coerce_mesh_data(mesh_or_data)
+ definitions = _coerce_boundary_definitions(
+ mesh_or_data, boundary_definitions
+ )
+ vertices = mesh_data.vertices.tolist()
+ connectivity = mesh_data.connectivity.tolist()
+ tags = mesh_data.boundary_tags
+
+ with open(name, 'w') as handle:
+ handle.write('%\n% Problem dimension\n%\nNDIME= 2\n')
+ handle.write('%\n% Node coordinates\n%\n')
+ handle.write(f'NPOIN= {len(vertices)}\n')
+ for index, vertex in enumerate(vertices):
+ handle.write(f'{vertex[0]: .8e} {vertex[1]: .8e} {index}\n')
+
+ handle.write('%\n% Element connectivity\n%\n')
+ handle.write(f'NELEM= {len(connectivity)}\n')
+ for index, cell in enumerate(connectivity):
+ handle.write(
+ f'9 {cell[0]:10d} {cell[1]:10d} {cell[2]:10d} '
+ f'{cell[3]:10d} {index:>10d}\n'
+ )
+
+ handle.write('%\n% Boundary tags\n%\n')
+ active_boundaries = [
+ (tag, label) for tag, label in definitions.items()
+ if tag in tags
+ ]
+ handle.write(f'NMARK= {len(active_boundaries)}\n')
+ for tag, label in active_boundaries:
+ handle.write(f'MARKER_TAG= {label}\n')
+ handle.write(f'MARKER_ELEMS= {len(tags[tag])}\n')
+ for edge in tags[tag]:
+ handle.write(f'3 {edge[0]} {edge[1]}\n')
+
+ logger.info('SU2 type mesh saved as %s', os.path.basename(name))
+
+
+class VtuExporter(MeshExporter):
+ format_name = 'vtu'
+
+ def write(self, mesh_or_data, name: str,
+ boundary_definitions: BoundaryDefinitions | Mapping[str, str] | None = None,
+ **kwargs):
+ mesh_data = _coerce_mesh_data(mesh_or_data)
+ definitions = _coerce_boundary_definitions(
+ mesh_or_data, boundary_definitions
+ )
+
+ vertices = [tuple(vertex) + (0.0,) for vertex in mesh_data.vertices.tolist()]
+ connectivity = [np.array(cell, dtype=int)
+ for cell in mesh_data.connectivity.tolist()]
+ tags = mesh_data.boundary_tags
+
+ def cell_type_from_length(length):
+ if length == 2:
+ return 3
+ if length == 3:
+ return 5
+ if length == 4:
+ return 9
+ raise ValueError(f'No VTK cell type defined for {length}-node cells.')
+
+ polygon_lengths = [len(cell) for cell in connectivity]
+ polygon_connectivity = (
+ np.concatenate([cell for cell in connectivity])
+ if connectivity else np.array([], dtype=int)
+ )
+ polygon_types = np.array(
+ [cell_type_from_length(length) for length in polygon_lengths],
+ dtype=np.uint8,
+ )
+ polygon_boundary_ids = np.zeros(len(connectivity), dtype=np.int32)
+
+ boundary_names = [
+ label for tag, label in definitions.items() if tag in tags
+ ]
+ boundary_id_map = {name: index + 1 for index, name in enumerate(boundary_names)}
+ boundary_edges = []
+ boundary_lengths = []
+ boundary_types = []
+ boundary_ids = []
+
+ for tag, label in definitions.items():
+ for edge in tags.get(tag, []):
+ boundary_edges.append(np.array(edge, dtype=int))
+ boundary_lengths.append(2)
+ boundary_types.append(cell_type_from_length(2))
+ boundary_ids.append(boundary_id_map[label])
+
+ if boundary_edges:
+ boundary_connectivity = np.concatenate(boundary_edges)
+ boundary_cell_types = np.array(boundary_types, dtype=np.uint8)
+ boundary_ids_array = np.array(boundary_ids, dtype=np.int32)
+ else:
+ boundary_connectivity = np.array([], dtype=int)
+ boundary_cell_types = np.array([], dtype=np.uint8)
+ boundary_ids_array = np.array([], dtype=np.int32)
+
+ all_connectivity = np.concatenate(
+ [polygon_connectivity, boundary_connectivity]
+ )
+ all_cell_types = np.concatenate([polygon_types, boundary_cell_types])
+ all_boundary_ids = np.concatenate(
+ [polygon_boundary_ids, boundary_ids_array]
+ )
+ all_lengths = polygon_lengths + boundary_lengths
+ offsets = np.cumsum(all_lengths)
+
+ with open(name, 'w') as handle:
+ handle.write('\n')
+ handle.write(
+ '\n'
+ )
+ handle.write(' \n')
+ handle.write(
+ f' \n'
+ )
+ handle.write(' \n')
+ handle.write(
+ ' \n'
+ )
+ handle.write(' ' + ' '.join(map(str, all_boundary_ids)) + '\n')
+ handle.write(' \n')
+ handle.write(' \n')
+ handle.write(' \n')
+ handle.write(
+ ' \n'
+ )
+ for point in vertices:
+ handle.write(f' {point[0]} {point[1]} {point[2]}\n')
+ handle.write(' \n')
+ handle.write(' \n')
+ handle.write(' \n')
+ handle.write(
+ ' \n'
+ )
+ handle.write(' ' + ' '.join(map(str, all_connectivity)) + '\n')
+ handle.write(' \n')
+ handle.write(
+ ' \n'
+ )
+ handle.write(' ' + ' '.join(map(str, offsets)) + '\n')
+ handle.write(' \n')
+ handle.write(
+ ' \n'
+ )
+ handle.write(' ' + ' '.join(map(str, all_cell_types)) + '\n')
+ handle.write(' \n')
+ handle.write(' \n')
+ handle.write(' \n')
+ handle.write(' \n')
+ handle.write(' \n')
+
+ logger.info('VTU type mesh saved as %s', os.path.basename(name))
+
+
+class GmshExporter(MeshExporter):
+ format_name = 'gmsh'
+
+ def write(self, mesh_or_data, name: str,
+ boundary_definitions: BoundaryDefinitions | Mapping[str, str] | None = None,
+ **kwargs):
+ mesh_data = _coerce_mesh_data(mesh_or_data)
+ definitions = _coerce_boundary_definitions(
+ mesh_or_data, boundary_definitions
+ )
+ vertices = mesh_data.vertices.tolist()
+ connectivity = mesh_data.connectivity.tolist()
+ boundaries = mesh_data.boundary_tags
+
+ active_boundaries = [
+ (tag, label) for tag, label in definitions.items()
+ if tag in boundaries
+ ]
+ boundary_tags = {
+ tag: index + 1 for index, (tag, _label) in enumerate(active_boundaries)
+ }
+ domain_physical_tag = len(active_boundaries) + 1
+
+ with open(name, 'w') as handle:
+ handle.write('$MeshFormat\n2.2 0 8\n$EndMeshFormat\n')
+ handle.write('$PhysicalNames\n')
+ handle.write(f'{len(active_boundaries) + 1}\n')
+ for tag, label in active_boundaries:
+ handle.write(f'1 {boundary_tags[tag]} "{label}"\n')
+ handle.write(f'2 {domain_physical_tag} "Domain"\n')
+ handle.write('$EndPhysicalNames\n')
+
+ handle.write('$Nodes\n')
+ handle.write(f'{len(vertices)}\n')
+ for index, (x, y) in enumerate(vertices, start=1):
+ handle.write(f'{index} {x: .8e} {y: .8e} 0.00000000e+00\n')
+ handle.write('$EndNodes\n')
+
+ elements = []
+ element_id = 1
+ for tag, _label in active_boundaries:
+ physical_tag = boundary_tags[tag]
+ for edge in boundaries[tag]:
+ elements.append(
+ (
+ element_id,
+ 1,
+ 2,
+ physical_tag,
+ physical_tag,
+ [edge[0] + 1, edge[1] + 1],
+ )
+ )
+ element_id += 1
+
+ for cell in connectivity:
+ node_count = len(cell)
+ element_type = {3: 2, 4: 3, 6: 9, 8: 16}.get(node_count)
+ if element_type is None:
+ raise ValueError(
+ f'Unsupported element with {node_count} nodes.'
+ )
+ elements.append(
+ (
+ element_id,
+ element_type,
+ 2,
+ domain_physical_tag,
+ domain_physical_tag,
+ [node + 1 for node in cell],
+ )
+ )
+ element_id += 1
+
+ handle.write('$Elements\n')
+ handle.write(f'{len(elements)}\n')
+ for elem_id, elem_type, num_tags, physical_tag, geometrical_tag, node_ids in elements:
+ node_ids_str = ' '.join(map(str, node_ids))
+ handle.write(
+ f'{elem_id} {elem_type} {num_tags} {physical_tag} '
+ f'{geometrical_tag} {node_ids_str}\n'
+ )
+ handle.write('$EndElements\n')
+
+ logger.info('GMSH type mesh saved as %s', os.path.basename(name))
+
+
+class MeshImportRegistry:
+ _registry = {
+ 'flma': FlmaImporter(),
+ 'su2': Su2Importer(),
+ 'msh': GmshImporter(),
+ 'gmsh': GmshImporter(),
+ 'inp': AbaqusInpImporter(),
+ 'cgns': CgnsImporter(),
+ 'vtu': VtuImporter(),
+ 'vtk': VtkImporter(),
+ }
+
+ @classmethod
+ def _normalize_format(cls, mesh_format: str) -> str:
+ return mesh_format.strip().lower().lstrip('.')
+
+ @classmethod
+ def format_from_filename(cls, filename: str) -> str:
+ _, extension = os.path.splitext(filename)
+ if not extension:
+ raise ValueError(
+ f'Unable to determine mesh format from filename: {filename}'
+ )
+ return cls._normalize_format(extension)
+
+ @classmethod
+ def can_import(cls, filename: str) -> bool:
+ try:
+ mesh_format = cls.format_from_filename(filename)
+ except ValueError:
+ return False
+ return mesh_format in cls._registry
+
+ @classmethod
+ def supported_extensions(cls) -> tuple[str, ...]:
+ extensions = [
+ 'flma',
+ 'su2',
+ 'msh',
+ 'inp',
+ 'cgns',
+ 'vtu',
+ 'vtk',
+ ]
+ return tuple(extension for extension in extensions if extension in cls._registry)
+
+ @classmethod
+ def qt_file_dialog_filter(cls) -> str:
+ patterns = ' '.join(
+ f'*.{extension}' for extension in cls.supported_extensions()
+ )
+ return f'Mesh files ({patterns})'
+
+ @classmethod
+ def import_file(cls, filename: str, mesh_format: str | None = None,
+ **kwargs) -> MeshData:
+ key = cls._normalize_format(mesh_format) if mesh_format else (
+ cls.format_from_filename(filename)
+ )
+ try:
+ importer = cls._registry[key]
+ except KeyError as error:
+ raise ValueError(f'Unknown mesh import format: {key}') from error
+ return importer.read(filename, **kwargs)
+
+
+class MeshExportRegistry:
+ _extensions = {
+ 'flma': '.flma',
+ 'su2': '.su2',
+ 'gmsh': '.msh',
+ 'vtu': '.vtu',
+ }
+ _format_aliases = {
+ 'flma': 'flma',
+ 'fire': 'flma',
+ 'avl fire': 'flma',
+ 'su2': 'su2',
+ 'gmsh': 'gmsh',
+ 'msh': 'gmsh',
+ 'vtk': 'vtu',
+ 'vtu': 'vtu',
+ }
+ _registry = {
+ 'flma': FlmaExporter(),
+ 'su2': Su2Exporter(),
+ 'vtk': VtuExporter(),
+ 'vtu': VtuExporter(),
+ 'gmsh': GmshExporter(),
+ 'msh': GmshExporter(),
+ }
+
+ @classmethod
+ def normalize_format(cls, mesh_format: str) -> str:
+ if mesh_format is None:
+ raise ValueError('Mesh export format is required.')
+
+ key = mesh_format.strip().lower().lstrip('.')
+ key = key.replace('_', ' ').replace('-', ' ')
+ key = ' '.join(key.split())
+ normalized = cls._format_aliases.get(key, key)
+ if normalized not in cls._extensions:
+ raise ValueError(f'Unknown mesh export format: {mesh_format}')
+ return normalized
+
+ @classmethod
+ def extension_for(cls, mesh_format: str) -> str:
+ return cls._extensions[cls.normalize_format(mesh_format)]
+
+ @classmethod
+ def supported_formats(cls) -> tuple[str, ...]:
+ return tuple(cls._extensions)
+
+ @classmethod
+ def export(cls, mesh_or_data, mesh_format: str, name: str,
+ boundary_definitions: BoundaryDefinitions | Mapping[str, str] | None = None,
+ **kwargs):
+ key = cls.normalize_format(mesh_format)
+ try:
+ exporter = cls._registry[key]
+ except KeyError as error:
+ raise ValueError(f'Unknown mesh export format: {mesh_format}') from error
+ exporter.write(
+ mesh_or_data,
+ name=name,
+ boundary_definitions=boundary_definitions,
+ **kwargs,
+ )
+
+
+class MeshFactory:
+ """Factory class to create mesh model objects."""
+
+ _registry = {
+ 'block': BlockMesh,
+ 'block_structured': BlockStructuredMesh,
+ 'triangular': TriangularMesh,
+ 'unstructured': UnstructuredMesh,
+ }
+
+ @classmethod
+ def create_mesh(cls, mesh_type: str, **kwargs):
+ key = mesh_type.strip().lower()
+ try:
+ mesh_class = cls._registry[key]
+ except KeyError as error:
+ raise ValueError(f'Unknown mesh type: {mesh_type}') from error
+ return mesh_class(**kwargs)
diff --git a/src/MeshBuilders.py b/src/MeshBuilders.py
new file mode 100644
index 0000000..c885be1
--- /dev/null
+++ b/src/MeshBuilders.py
@@ -0,0 +1,553 @@
+from __future__ import annotations
+
+import copy
+from dataclasses import dataclass
+
+import numpy as np
+
+
+def _to_point_tuple(point) -> tuple[float, float]:
+ return float(point[0]), float(point[1])
+
+
+@dataclass(slots=True)
+class AirfoilBlockSettings:
+ name: str = 'block_airfoil'
+ divisions: int = 15
+ growth: float = 3.0
+ thickness: float = 0.04
+
+
+@dataclass(slots=True)
+class TrailingEdgeBlockSettings:
+ name: str = 'block_TE'
+ trailing_edge_divisions: int = 3
+ thickness: float = 0.04
+ divisions: int = 10
+ growth: float = 1.05
+
+
+@dataclass(slots=True)
+class TunnelBlockSettings:
+ name: str = 'block_tunnel'
+ tunnel_height: float = 2.0
+ divisions_height: int = 100
+ height_growth: float = 10.0
+ distribution: str = 'symmetric'
+ smoothing_algorithm: str = 'simple'
+ smoothing_iterations: int = 100
+ smoothing_tolerance: float = 1.0e-5
+ outer_boundary_slide: float = 1.0
+ elliptic_relaxation: float = 1.0
+ protected_guide_relaxation: float = 0.25
+ protected_guide_layers: int = 8
+ protected_guide_decay: float = 0.2
+ protected_guide_smoothing: int = 15
+
+
+@dataclass(slots=True)
+class WakeBlockSettings:
+ name: str = 'block_tunnel_wake'
+ tunnel_wake: float = 2.0
+ divisions: int = 100
+ growth: float = 0.1
+ spread: float = 0.4
+
+
+class LegacyBlockMeshBuilder:
+ """Reusable builders around the legacy BlockMesh implementation."""
+
+ c_curve_segment_samples = 10
+ c_curve_arc_samples = 200
+ c_curve_segment_blend = 0.2
+ c_curve_min_leg_fraction = 0.08
+ c_curve_bias_exponent = 1.35
+ side_transition_span = 30
+ chord_length = 1.0
+
+ def __init__(self, block_mesh_cls, create_smoother):
+ self.block_mesh_cls = block_mesh_cls
+ self.create_smoother = create_smoother
+
+ def build_airfoil_block(self, contour,
+ settings: AirfoilBlockSettings):
+ if contour is None:
+ raise ValueError('Airfoil contour data is required before building the airfoil block.')
+
+ x, y = contour
+ line = [_to_point_tuple(point) for point in zip(x, y)]
+ block = self.block_mesh_cls(name=settings.name)
+ block.addLine(line)
+ block.extrudeLine_cell_thickness(
+ line,
+ cell_thickness=settings.thickness,
+ growth=settings.growth,
+ divisions=settings.divisions,
+ direction=3,
+ )
+ return block
+
+ def build_trailing_edge_block(self, airfoil_block, has_trailing_edge: bool,
+ settings: TrailingEdgeBlockSettings):
+ if airfoil_block is None:
+ raise ValueError('Airfoil block is required before building the trailing edge block.')
+
+ line = self._compose_trailing_edge_line(
+ airfoil_block,
+ has_trailing_edge=has_trailing_edge,
+ trailing_edge_divisions=settings.trailing_edge_divisions,
+ )
+ block = self.block_mesh_cls(name=settings.name)
+ block.addLine(line)
+ block.extrudeLine_cell_thickness(
+ line,
+ cell_thickness=settings.thickness,
+ growth=settings.growth,
+ divisions=settings.divisions,
+ direction=4,
+ )
+ block.distribute(direction='u', number=-1)
+ block.transfinite()
+ return block
+
+ def build_tunnel_block(self, airfoil_block, trailing_edge_block,
+ settings: TunnelBlockSettings):
+ if airfoil_block is None or trailing_edge_block is None:
+ raise ValueError('Airfoil and trailing edge blocks are required before building the tunnel block.')
+
+ inner_segments = self._compose_tunnel_inner_segments(
+ trailing_edge_block,
+ airfoil_block,
+ )
+ inner_line = self._compose_tunnel_inner_line(inner_segments)
+ block = self.block_mesh_cls(name=settings.name)
+ block.addLine(inner_line)
+ block.addLine(
+ self._build_tunnel_outer_curve(
+ inner_line=inner_line,
+ tunnel_height=settings.tunnel_height,
+ distribution=settings.distribution,
+ segment_lengths=tuple(len(segment) for segment in inner_segments),
+ )
+ )
+
+ upper_start = np.array(
+ (block.getULines()[0][0][0], settings.tunnel_height),
+ dtype=float,
+ )
+ lower_end = np.array(
+ (block.getULines()[0][-1][0], -settings.tunnel_height),
+ dtype=float,
+ )
+ first_inner = np.array(block.getULines()[0][0], dtype=float)
+ last_inner = np.array(block.getULines()[0][-1], dtype=float)
+
+ left = self.block_mesh_cls.makeLine(
+ first_inner,
+ upper_start,
+ divisions=settings.divisions_height,
+ ratio=settings.height_growth,
+ )
+ right = self.block_mesh_cls.makeLine(
+ last_inner,
+ lower_end,
+ divisions=settings.divisions_height,
+ ratio=settings.height_growth,
+ )
+
+ block.transfinite(
+ boundary=[
+ block.getULines()[0],
+ block.getULines()[-1],
+ left,
+ right,
+ ]
+ )
+
+ blended_block = self.block_mesh_cls(name=settings.name)
+ for uline in self._blend_tunnel_lines(block):
+ blended_block.addLine(uline)
+
+ self._apply_tunnel_side_interpolation(blended_block)
+
+ if settings.smoothing_algorithm.strip().lower() == 'elliptic':
+ return blended_block
+
+ smoother = self.create_smoother(settings.smoothing_algorithm)
+ return smoother.smooth(
+ blended_block,
+ iterations=settings.smoothing_iterations,
+ tolerance=settings.smoothing_tolerance,
+ )
+
+ def build_wake_block(self, tunnel_block, trailing_edge_block,
+ tunnel_height: float,
+ settings: WakeBlockSettings):
+ if tunnel_block is None or trailing_edge_block is None:
+ raise ValueError('Tunnel and trailing edge blocks are required before building the wake block.')
+ if tunnel_height is None:
+ raise ValueError('Tunnel height must be known before building the wake block.')
+
+ block = self.block_mesh_cls(name=settings.name)
+ inner_line = self._compose_wake_inner_line(
+ tunnel_block,
+ trailing_edge_block,
+ )
+ block.addLine(inner_line)
+
+ upper_start = np.array(
+ (trailing_edge_block.getULines()[-1][0][0], tunnel_height),
+ dtype=float,
+ )
+ lower_start = np.array(
+ (trailing_edge_block.getULines()[-1][-1][0], -tunnel_height),
+ dtype=float,
+ )
+ upper_end = np.array(
+ (settings.tunnel_wake + self.chord_length, tunnel_height),
+ dtype=float,
+ )
+ lower_end = np.array(
+ (settings.tunnel_wake + self.chord_length, -tunnel_height),
+ dtype=float,
+ )
+
+ upper = self.block_mesh_cls.makeLine(
+ upper_end,
+ upper_start,
+ divisions=settings.divisions,
+ ratio=1.0 / settings.growth,
+ )
+ lower = self.block_mesh_cls.makeLine(
+ lower_end,
+ lower_start,
+ divisions=settings.divisions,
+ ratio=1.0 / settings.growth,
+ )
+ right = self.block_mesh_cls.makeLine(
+ lower_end,
+ upper_end,
+ divisions=len(inner_line) - 1,
+ ratio=1.0,
+ )
+
+ block.transfinite(boundary=[upper, lower, right, inner_line])
+
+ split_line = self._find_wake_split_line(
+ block.getULines()[0],
+ wake_length=settings.tunnel_wake,
+ spread=settings.spread,
+ )
+ block.distribute(direction='v', number=split_line)
+
+ vline_count = len(block.getVLines())
+ uline_count = len(block.getULines())
+ block.transfinite(
+ ij=[vline_count + split_line, vline_count - 1, 0, uline_count - 1]
+ )
+ block.transfinite(
+ ij=[0, vline_count + split_line, 0, uline_count - 1]
+ )
+ return block
+
+ def _compose_trailing_edge_line(self, airfoil_block, has_trailing_edge: bool,
+ trailing_edge_divisions: int):
+ first = airfoil_block.getLine(number=0, direction='v')
+ last = airfoil_block.getLine(number=-1, direction='v')
+ first = copy.deepcopy(first)
+ last_reversed = copy.deepcopy(last)
+ last_reversed.reverse()
+
+ line = copy.deepcopy(last_reversed)
+ if has_trailing_edge:
+ start = np.asarray(last_reversed[-1], dtype=float)
+ vector = np.asarray(first[0], dtype=float) - np.asarray(last[0], dtype=float)
+ for index in range(1, trailing_edge_divisions):
+ point = start + float(index) / trailing_edge_divisions * vector
+ line.append(_to_point_tuple(point))
+ line += first
+ else:
+ line += first[1:]
+ return line
+
+ def _compose_tunnel_inner_segments(self, trailing_edge_block, airfoil_block):
+ upper = copy.deepcopy(trailing_edge_block.getVLines()[-1])
+ upper.reverse()
+ middle = copy.deepcopy(airfoil_block.getULines()[-1])
+ lower = copy.deepcopy(trailing_edge_block.getVLines()[0])
+ return upper, middle, lower
+
+ def _compose_tunnel_inner_line(self, segments):
+ upper, middle, lower = segments
+ line = copy.deepcopy(upper)
+ del line[-1]
+ line += copy.deepcopy(middle)
+ del line[-1]
+ line += copy.deepcopy(lower)
+ return line
+
+ def _build_tunnel_outer_curve(self, inner_line, tunnel_height: float,
+ distribution: str, segment_lengths=None):
+ p1 = np.array((inner_line[0][0], tunnel_height), dtype=float)
+ p2 = np.array((0.0, tunnel_height), dtype=float)
+ p3 = np.array((0.0, -tunnel_height), dtype=float)
+ p4 = np.array((inner_line[-1][0], -tunnel_height), dtype=float)
+
+ if segment_lengths is None:
+ upper_points = self.c_curve_segment_samples
+ arc_points = self.c_curve_arc_samples
+ lower_points = self.c_curve_segment_samples
+ else:
+ upper_points, arc_points, lower_points = \
+ self._outer_curve_segment_point_counts(
+ p1,
+ p2,
+ p3,
+ p4,
+ segment_lengths,
+ tunnel_height,
+ )
+
+ line = self._sample_segment_point_count(
+ p1,
+ p2,
+ upper_points,
+ include_last=False,
+ )
+ line += self._sample_half_circle_point_count(
+ radius=tunnel_height,
+ start_degrees=90.0,
+ end_degrees=270.0,
+ count=arc_points,
+ include_last=False,
+ distribution=distribution,
+ )
+ line += self._sample_segment_point_count(
+ p3,
+ p4,
+ lower_points,
+ include_last=True,
+ )
+ return line
+
+ def _outer_curve_segment_point_counts(self, p1, p2, p3, p4,
+ segment_lengths, tunnel_height):
+ legacy_counts = np.array(
+ [
+ max(1, int(segment_lengths[0]) - 1),
+ max(1, int(segment_lengths[1]) - 1),
+ max(1, int(segment_lengths[2])),
+ ],
+ dtype=float,
+ )
+ total_points = int(np.sum(legacy_counts))
+
+ geometric_lengths = np.array(
+ [
+ np.linalg.norm(p2 - p1),
+ np.pi * float(tunnel_height),
+ np.linalg.norm(p4 - p3),
+ ],
+ dtype=float,
+ )
+ geometric_total = float(np.sum(geometric_lengths))
+ if geometric_total > 0.0:
+ geometric_counts = (
+ geometric_lengths / geometric_total * float(total_points)
+ )
+ else:
+ geometric_counts = np.array(legacy_counts, copy=True)
+
+ blend = float(np.clip(self.c_curve_segment_blend, 0.0, 1.0))
+ target_counts = (
+ (1.0 - blend) * legacy_counts +
+ blend * geometric_counts
+ )
+
+ minimum_leg = max(
+ 4,
+ int(round(self.c_curve_min_leg_fraction * float(total_points))),
+ )
+ minimum_counts = np.array([minimum_leg, 8, minimum_leg], dtype=int)
+ if np.sum(minimum_counts) > total_points:
+ minimum_counts = np.array([1, 1, 1], dtype=int)
+
+ counts = np.maximum(
+ minimum_counts,
+ np.floor(target_counts).astype(int),
+ )
+ difference = total_points - int(np.sum(counts))
+
+ if difference > 0:
+ fractions = target_counts - np.floor(target_counts)
+ order = np.argsort(fractions)[::-1]
+ index = 0
+ while difference > 0:
+ counts[order[index % len(order)]] += 1
+ difference -= 1
+ index += 1
+ elif difference < 0:
+ fractions = target_counts - np.floor(target_counts)
+ order = np.argsort(fractions)
+ index = 0
+ while difference < 0:
+ candidate = order[index % len(order)]
+ if counts[candidate] > minimum_counts[candidate]:
+ counts[candidate] -= 1
+ difference += 1
+ index += 1
+
+ return tuple(int(value) for value in counts)
+
+ def _blend_tunnel_lines(self, block):
+ old_ulines = copy.deepcopy(block.getULines())
+ line_count = len(old_ulines)
+ x_inner, y_inner = list(zip(*old_ulines[0]))
+ normals = self.block_mesh_cls.curveNormals(
+ np.asarray(x_inner, dtype=float),
+ np.asarray(y_inner, dtype=float),
+ )
+
+ blended_lines = []
+ for j, uline in enumerate(old_ulines):
+ if j == 0 or j == line_count - 1:
+ blended_lines.append(uline)
+ continue
+
+ blended = []
+ for i, point in enumerate(uline):
+ if i == 0 or i == len(uline) - 1:
+ blended.append(point)
+ continue
+
+ outer_point = np.array(point, dtype=float)
+ inner_point = np.array(old_ulines[0][i], dtype=float)
+ normal = normals[i]
+ offset = outer_point - inner_point
+ distance = np.dot(offset, normal) / np.linalg.norm(normal)
+ projected = inner_point + distance * normal
+ blend = float(j) / float(line_count)
+ exponent = 0.6
+ new_point = (1.0 - blend**exponent) * projected + \
+ blend**exponent * outer_point
+ blended.append(_to_point_tuple(new_point))
+
+ blended_lines.append(blended)
+
+ return blended_lines
+
+ def _apply_tunnel_side_interpolation(self, block):
+ vline_count = len(block.getVLines())
+ uline_count = len(block.getULines())
+ side_span = min(self.side_transition_span, vline_count - 1)
+ if side_span <= 0:
+ return
+
+ block.transfinite(ij=[0, side_span, 0, uline_count - 1])
+ block.transfinite(
+ ij=[vline_count - side_span - 1, vline_count - 1, 0, uline_count - 1]
+ )
+
+ def _compose_wake_inner_line(self, tunnel_block, trailing_edge_block):
+ line = copy.deepcopy(tunnel_block.getVLines()[-1])
+ line.reverse()
+ del line[-1]
+ line += copy.deepcopy(trailing_edge_block.getULines()[-1])
+ del line[-1]
+ line += copy.deepcopy(tunnel_block.getVLines()[0])
+ return line
+
+ def _find_wake_split_line(self, line, wake_length: float, spread: float):
+ threshold = self.chord_length + wake_length * spread
+ line_length = len(line)
+ for index, point in enumerate(line):
+ if point[0] < threshold:
+ return index - line_length
+ return -(line_length // 2)
+
+ @staticmethod
+ def _distribution_interval(distribution: str):
+ intervals = {
+ 'symmetric': (-1.3, 1.3),
+ 'lower': (-1.2, 1.5),
+ 'upper': (-1.5, 1.2),
+ }
+ return intervals.get(distribution, intervals['symmetric'])
+
+ @staticmethod
+ def _sample_segment(start, end, samples: int, include_last: bool):
+ vector = end - start
+ points = []
+ for parameter in np.linspace(0.0, 1.0, samples):
+ points.append(_to_point_tuple(start + parameter * vector))
+ if not include_last and points:
+ points.pop()
+ return points
+
+ @staticmethod
+ def _sample_segment_point_count(start, end, count: int, include_last: bool):
+ count = max(0, int(count))
+ if count == 0:
+ return []
+
+ parameters = np.linspace(0.0, 1.0, count + (0 if include_last else 1))
+ if not include_last:
+ parameters = parameters[:-1]
+
+ vector = end - start
+ return [_to_point_tuple(start + parameter * vector) for parameter in parameters]
+
+ @staticmethod
+ def _sample_half_circle(radius: float, start_degrees: float,
+ end_degrees: float, samples: int,
+ include_last: bool):
+ points = []
+ for angle in np.linspace(start_degrees, end_degrees, samples):
+ radians = np.radians(angle)
+ points.append(
+ (
+ float(radius * np.cos(radians)),
+ float(radius * np.sin(radians)),
+ )
+ )
+ if not include_last and points:
+ points.pop()
+ return points
+
+ @classmethod
+ def _sample_half_circle_point_count(cls, radius: float, start_degrees: float,
+ end_degrees: float, count: int,
+ include_last: bool, distribution: str):
+ count = max(0, int(count))
+ if count == 0:
+ return []
+
+ parameters = np.linspace(0.0, 1.0, count + (0 if include_last else 1))
+ if not include_last:
+ parameters = parameters[:-1]
+
+ parameters = cls._apply_arc_distribution(parameters, distribution)
+
+ angles = start_degrees + parameters * (end_degrees - start_degrees)
+ points = []
+ for angle in angles:
+ radians = np.radians(angle)
+ points.append(
+ (
+ float(radius * np.cos(radians)),
+ float(radius * np.sin(radians)),
+ )
+ )
+ return points
+
+ @classmethod
+ def _apply_arc_distribution(cls, parameters, distribution: str):
+ parameters = np.asarray(parameters, dtype=float)
+ distribution = str(distribution).strip().lower()
+ exponent = max(1.0, float(cls.c_curve_bias_exponent))
+
+ if distribution == 'upper':
+ return parameters**exponent
+ if distribution == 'lower':
+ return 1.0 - (1.0 - parameters)**exponent
+ return parameters
diff --git a/src/MeshGraphics.py b/src/MeshGraphics.py
new file mode 100644
index 0000000..151fc3a
--- /dev/null
+++ b/src/MeshGraphics.py
@@ -0,0 +1,263 @@
+from __future__ import annotations
+
+from PySide6 import QtGui, QtCore
+
+import GraphicsItemsCollection as gic
+import GraphicsItem
+from Utils import get_main_window, scalar_to_rgb
+
+
+class MeshSceneRenderer:
+ """Qt scene renderer for mesh-related graphics items."""
+
+ def __init__(self, mainwindow=None):
+ self.mw = mainwindow or get_main_window()
+
+ def render_mesh(self, airfoil, blocks):
+ self._set_item_visibility(getattr(airfoil, 'splineMarkersGroup', None),
+ visible=False)
+ self._set_checkbox(
+ 'airfoil_spline_points_checkbox',
+ checked=False,
+ )
+
+ self._remove_scene_item(getattr(airfoil, 'mesh', None))
+ mesh_items = []
+ for block in blocks:
+ for lines in (block.getULines(), block.getVLines()):
+ for line in lines:
+ mesh_items.append(
+ self._make_polyline_item(
+ line,
+ color=QtGui.QColor(0, 0, 0, 255),
+ width=0.8,
+ )
+ )
+
+ airfoil.mesh = self.mw.scene.createItemGroup(mesh_items)
+ self._set_checkbox('mesh_checkbox', checked=True, enabled=True)
+ return airfoil.mesh
+
+ def render_unstructured_mesh(self, target, mesh_data):
+ self._remove_scene_item(getattr(target, 'mesh', None))
+
+ edge_items = []
+ seen_edges = set()
+ vertices = getattr(mesh_data, 'vertices', None)
+ connectivity = getattr(mesh_data, 'connectivity', None)
+ if vertices is None or connectivity is None:
+ return None
+
+ for cell in connectivity:
+ count = len(cell)
+ for index in range(count):
+ a_value = int(cell[index])
+ b_value = int(cell[(index + 1) % count])
+ edge = self._sorted_edge((a_value, b_value))
+ if edge in seen_edges:
+ continue
+ seen_edges.add(edge)
+ line = (
+ vertices[edge[0]],
+ vertices[edge[1]],
+ )
+ edge_items.append(
+ self._make_polyline_item(
+ line,
+ color=QtGui.QColor(0, 0, 0, 255),
+ width=0.7,
+ )
+ )
+
+ target.mesh = self.mw.scene.createItemGroup(edge_items)
+ target.mesh.setVisible(True)
+ self._set_checkbox('mesh_checkbox', checked=True, enabled=True)
+ return target.mesh
+
+ def render_block_outline(self, airfoil, blocks, *, layout_plan=None):
+ self._remove_scene_item(getattr(airfoil, 'mesh_blocks', None))
+ outline_items = []
+ blocks = list(blocks)
+
+ if (
+ layout_plan is not None and (
+ getattr(layout_plan, 'singularities', None) or
+ getattr(layout_plan, 'separatrices', None)
+ )):
+ outline_items.extend(self._make_layout_outline_items(layout_plan))
+
+ if not outline_items:
+ for block in blocks:
+ for lines in (block.getULines(), block.getVLines()):
+ for line in (lines[0], lines[-1]):
+ outline_items.append(
+ self._make_polyline_item(
+ line,
+ color=QtGui.QColor(220, 38, 38, 255),
+ width=3.0,
+ )
+ )
+
+ airfoil.mesh_blocks = self.mw.scene.createItemGroup(outline_items)
+ airfoil.mesh_blocks.setVisible(True)
+ self._set_checkbox('mesh_blocks_checkbox', checked=True, enabled=True)
+ return airfoil.mesh_blocks
+
+ def render_constraint_outline(self, target, loops):
+ self._remove_scene_item(getattr(target, 'mesh_blocks', None))
+ red = QtGui.QColor(220, 38, 38, 255)
+ outline_items = []
+
+ for loop in loops or []:
+ points = getattr(loop, 'points', loop)
+ outline_items.append(
+ self._make_polyline_item(
+ points,
+ color=red,
+ width=2.2,
+ )
+ )
+
+ target.mesh_blocks = self.mw.scene.createItemGroup(outline_items)
+ target.mesh_blocks.setVisible(True)
+ self._set_checkbox('mesh_blocks_checkbox', checked=True, enabled=True)
+ return target.mesh_blocks
+
+ def render_mesh_quality(self, vertices, connectivity, quality, airfoil=None):
+ if airfoil is not None:
+ self._remove_scene_item(getattr(airfoil, 'mesh_quality', None))
+
+ quads = []
+ colors = [scalar_to_rgb(q, range='256') for q in quality]
+ for index, cell in enumerate(connectivity):
+ quad = gic.GraphicsCollection()
+ points = [QtCore.QPointF(*vertices[vertex]) for vertex in cell]
+ quad.Polygon(QtGui.QPolygonF(points), '')
+ quad.pen.setColor(QtGui.QColor(0, 0, 0, 255))
+ quad.brush.setColor(QtGui.QColor(*colors[index]))
+ quad.pen.setWidthF(0.8)
+ quad.pen.setCosmetic(True)
+ quads.append(GraphicsItem.GraphicsItem(quad))
+
+ group = self.mw.scene.createItemGroup(quads)
+ if airfoil is not None:
+ airfoil.mesh_quality = group
+ return group
+
+ def _make_polyline_item(self, line, color, width):
+ contour = gic.GraphicsCollection()
+ points = [QtCore.QPointF(x, y) for x, y in line]
+ contour.Polyline(QtGui.QPolygonF(points), '')
+ contour.pen.setColor(color)
+ contour.pen.setWidthF(width)
+ contour.pen.setCosmetic(True)
+ contour.brush.setStyle(QtCore.Qt.NoBrush)
+ return GraphicsItem.GraphicsItem(contour)
+
+ def _make_layout_outline_items(self, layout_plan):
+ red = QtGui.QColor(220, 38, 38, 255)
+ items = []
+
+ if getattr(layout_plan, 'outer_boundary', None) is not None:
+ items.append(
+ self._make_polyline_item(
+ layout_plan.outer_boundary,
+ color=red,
+ width=2.6,
+ )
+ )
+
+ for loop in getattr(layout_plan, 'boundary_loops', []) or []:
+ items.append(
+ self._make_polyline_item(
+ loop,
+ color=red,
+ width=2.2,
+ )
+ )
+
+ for separatrix in getattr(layout_plan, 'separatrices', []) or []:
+ items.append(
+ self._make_polyline_item(
+ separatrix,
+ color=red,
+ width=2.2,
+ )
+ )
+
+ marker_size = self._marker_size(layout_plan)
+ for singularity in getattr(layout_plan, 'singularities', []) or []:
+ items.append(
+ self._make_singularity_item(
+ singularity,
+ color=red,
+ size=marker_size,
+ )
+ )
+
+ return items
+
+ def _marker_size(self, layout_plan):
+ outer = getattr(layout_plan, 'outer_boundary', None)
+ if outer is None or len(outer) == 0:
+ return 0.03
+
+ x_values = [float(point[0]) for point in outer]
+ y_values = [float(point[1]) for point in outer]
+ diagonal = ((max(x_values) - min(x_values)) ** 2 +
+ (max(y_values) - min(y_values)) ** 2) ** 0.5
+ return max(0.008, 0.0012 * diagonal)
+
+ def _make_singularity_item(self, singularity, *, color, size):
+ metadata = getattr(singularity, 'metadata', {}) or {}
+ dx, dy = metadata.get('display_offset', (0.0, 0.0))
+ x_value = float(singularity.position[0]) + float(dx)
+ y_value = float(singularity.position[1]) + float(dy)
+ kind = str(getattr(singularity, 'kind', 'boundary')).strip().lower()
+ label = metadata.get('label', kind.title())
+
+ if kind == 'boundary':
+ marker = gic.GraphicsCollection()
+ points = QtGui.QPolygonF([
+ QtCore.QPointF(x_value, y_value + size),
+ QtCore.QPointF(x_value + size, y_value),
+ QtCore.QPointF(x_value, y_value - size),
+ QtCore.QPointF(x_value - size, y_value),
+ ])
+ marker.Polygon(points, label)
+ else:
+ marker = gic.GraphicsCollection()
+ marker.Circle(x_value, y_value, size)
+ marker.setTooltip(label)
+
+ marker.pen.setColor(color)
+ marker.pen.setWidthF(1.5)
+ marker.pen.setCosmetic(True)
+ marker.brush.setColor(QtGui.QColor(color.red(), color.green(), color.blue(), 80))
+ marker.brush.setStyle(QtCore.Qt.SolidPattern)
+ return GraphicsItem.GraphicsItem(marker)
+
+ @staticmethod
+ def _sorted_edge(edge):
+ a_value = int(edge[0])
+ b_value = int(edge[1])
+ return (a_value, b_value) if a_value <= b_value else (b_value, a_value)
+
+ def _remove_scene_item(self, item):
+ if item is None:
+ return
+ if item.scene() is not None:
+ item.scene().removeItem(item)
+
+ def _set_item_visibility(self, item, visible: bool):
+ if item is not None:
+ item.setVisible(visible)
+
+ def _set_checkbox(self, name, checked=None, enabled=None):
+ checkbox = getattr(self.mw.mainArea, name, None)
+ if checkbox is None:
+ return
+ if checked is not None:
+ checkbox.setChecked(checked)
+ if enabled is not None:
+ checkbox.setEnabled(enabled)
diff --git a/src/Meshing.py b/src/Meshing.py
index 5a94940..cf4a8ec 100644
--- a/src/Meshing.py
+++ b/src/Meshing.py
@@ -1,1592 +1,1110 @@
-
-import os
-import copy
-from datetime import date
-import locale
-import numpy as np
-from scipy import interpolate
-
-from PySide6 import QtGui, QtCore, QtWidgets
-
-import PyAero
-import GraphicsItemsCollection as gic
-import GraphicsItem
-import Elliptic
-import Connect
-from Smooth_angle_based import SmoothAngleBased
-from Utils import Utils
-from Settings import OUTPUTDATA
-import logging
-logger = logging.getLogger(__name__)
-
-
-class Windtunnel:
- """
- The Windtunnel class is responsible for generating a computational fluid dynamics (CFD) mesh
- around an airfoil within a wind tunnel. It includes methods for creating different parts of
- the mesh, such as the airfoil mesh, trailing edge mesh, tunnel mesh, and tunnel wake mesh.
- Additionally, it provides functionality for mesh quality assessment, drawing the mesh, and
- exporting the mesh in various formats.
- """
-
- def __init__(self):
-
- # contains list of BlockMesh objects
- self.blocks = []
-
- # get MainWindow instance (overcomes handling parents)
- self.mainwindow = QtCore.QCoreApplication.instance().mainwindow
-
- def AirfoilMesh(self, name='', contour=None, divisions=15, ratio=3.0,
- thickness=0.04):
-
- # get airfoil contour coordinates
- x, y = contour
-
- # make a list of point tuples
- # [(x1, y1), (x2, y2), (x3, y3), ... , (xn, yn)]
- line = list(zip(x, y))
-
- # block mesh around airfoil contour
- self.block_airfoil = BlockMesh(name=name)
- self.block_airfoil.addLine(line)
-
- # self.block_airfoil.extrudeLine(line, length=thickness, direction=3,
- # divisions=divisions, ratio=ratio)
- self.block_airfoil.extrudeLine_cell_thickness(line,
- cell_thickness=thickness,
- growth=ratio,
- divisions=divisions,
- direction=3)
-
- self.blocks.append(self.block_airfoil)
-
- def TrailingEdgeMesh(self, name='', te_divisions=3,
- thickness=0.04, divisions=10, ratio=1.05):
-
- # compile first line of trailing edge block
- first = self.block_airfoil.getLine(number=0, direction='v')
- last = self.block_airfoil.getLine(number=-1, direction='v')
- last_reversed = copy.deepcopy(last)
- last_reversed.reverse()
-
- vec = np.array(first[0]) - np.array(last[0])
- line = copy.deepcopy(last_reversed)
-
- # in case of TE add the points from the TE
- if self.mainwindow.airfoil.has_TE:
- for i in range(1, te_divisions):
- p = last_reversed[-1] + float(i) / te_divisions * vec
- # p is type numpy.float, so convert it to float
- line.append((float(p[0]), float(p[1])))
- line += first
- # handle case with sharp trailing edge
- else:
- line += first[1:]
-
- # trailing edge block mesh
- block_te = BlockMesh(name=name)
- block_te.addLine(line)
-
- # block_te.extrudeLine(line, length=length, direction=4,
- # divisions=divisions, ratio=ratio)
- block_te.extrudeLine_cell_thickness(line,
- cell_thickness=thickness,
- growth=ratio,
- divisions=divisions,
- direction=4)
-
- # equidistant point distribution
- block_te.distribute(direction='u', number=-1)
-
- # make a transfinite interpolation
- # i.e. recreate points inside the block
- block_te.transfinite()
-
- self.block_te = block_te
- self.blocks.append(block_te)
-
- def TunnelMesh(self, name='', tunnel_height=2.0, divisions_height=100,
- ratio_height=10.0, dist='symmetric',
- smoothing_algorithm='simple',
- smoothing_iterations=10,
- smoothing_tolerance=1e-3):
- block_tunnel = BlockMesh(name=name)
-
- self.tunnel_height = tunnel_height
-
- # line composed of trailing edge and airfoil meshes
- line = self.block_te.getVLines()[-1]
- line.reverse()
- del line[-1]
- line += self.block_airfoil.getULines()[-1]
- del line[-1]
- line += self.block_te.getVLines()[0]
- block_tunnel.addLine(line)
-
- # line composed of upper, lower and front line segments
- p1 = np.array((block_tunnel.getULines()[0][0][0], tunnel_height))
- p2 = np.array((0.0, tunnel_height))
- p3 = np.array((0.0, -tunnel_height))
- p4 = np.array((block_tunnel.getULines()[0][-1][0], -tunnel_height))
-
- # upper line of wind tunnel
- line = list()
- vec = p2 - p1
- for t in np.linspace(0.0, 1.0, 10):
- p = p1 + t * vec
- line.append(p.tolist())
- del line[-1]
-
- # front half circle of wind tunnel
- for phi in np.linspace(90.0, 270.0, 200):
- phir = np.radians(phi)
- x = tunnel_height * np.cos(phir)
- y = tunnel_height * np.sin(phir)
- line.append((x, y))
- del line[-1]
-
- # lower line of wind tunnel
- vec = p4 - p3
- for t in np.linspace(0.0, 1.0, 10):
- p = p3 + t * vec
- line.append(p.tolist())
-
- # make numpy array
- line = np.array(line)
-
- # interpolate a spline through line
- # at this point "line" is the big "C" of the windtunnel until TE
- tck, _ = interpolate.splprep(line.T, s=0, k=1)
-
- # point distribution on upper, front and lower part
- if dist == 'symmetric':
- ld = -1.3
- ud = 1.3
- if dist == 'lower':
- ld = -1.2
- ud = 1.5
- if dist == 'upper':
- ld = -1.5
- ud = 1.2
- xx = np.linspace(ld, ud, len(block_tunnel.getULines()[0]))
- t = (np.tanh(xx) + 1.0) / 2.0
-
- # calculate new points on the big "C" according to t distribution
- xs, ys = interpolate.splev(t, tck, der=0)
- line = list(zip(xs.tolist(), ys.tolist()))
-
- block_tunnel.addLine(line)
-
- p5 = np.array(block_tunnel.getULines()[0][0])
- p6 = np.array(block_tunnel.getULines()[0][-1])
-
- # first vline
- vline1 = BlockMesh.makeLine(p5, p1, divisions=divisions_height,
- ratio=ratio_height)
-
- # last vline
- vline2 = BlockMesh.makeLine(p6, p4, divisions=divisions_height,
- ratio=ratio_height)
-
- boundary = [block_tunnel.getULines()[0],
- block_tunnel.getULines()[-1],
- vline1,
- vline2]
- block_tunnel.transfinite(boundary=boundary)
-
- # blending between normals (inner lines) and transfinite (outer lines)
- ulines = list()
- old_ulines = block_tunnel.getULines()
-
- for j, uline in enumerate(block_tunnel.getULines()):
-
- # skip first and last line
- if j == 0 or j == len(block_tunnel.getULines()) - 1:
- ulines.append(uline)
- continue
-
- line = list()
- xo, yo = list(zip(*old_ulines[0]))
- xo = np.array(xo)
- yo = np.array(yo)
- normals = BlockMesh.curveNormals(xo, yo)
-
- for i, point in enumerate(uline):
-
- # skip first and last point
- if i == 0 or i == len(uline) - 1:
- line.append(point)
- continue
-
- pt = np.array(old_ulines[j][i])
- pto = np.array(old_ulines[0][i])
- vec = pt - pto
- # projection of vec into normal
- dist = np.dot(vec, normals[i]) / np.linalg.norm(normals[i])
- pn = pto + dist * normals[i]
- v = float(j) / float(len(block_tunnel.getULines()))
- exp = 0.6
- pnew = (1.0 - v**exp) * pn + v**exp * pt
- line.append((pnew.tolist()[0], pnew.tolist()[1]))
-
- ulines.append(line)
-
- block_tunnel = BlockMesh(name=name)
- for uline in ulines:
- block_tunnel.addLine(uline)
-
- # make transfinite interpolation from boundary lines
- ij = [0, 30, 0, len(block_tunnel.getULines()) - 1]
- block_tunnel.transfinite(ij=ij)
- ij = [len(block_tunnel.getVLines()) - 31,
- len(block_tunnel.getVLines()) - 1,
- 0,
- len(block_tunnel.getULines()) - 1]
- block_tunnel.transfinite(ij=ij)
-
- # FIXME:
- # FIXME: refactoring needed here (put smoother in blockmesh class)
- # FIXME: and refactor complete meshing functions
- # FIXME:
-
- if smoothing_algorithm == 'simple':
- # FIXME:
- # FIXME: this can be improved
- # FIXME: and at least documented
- # FIXME:
- smooth = Smooth(block_tunnel)
-
- nodes = smooth.selectNodes(domain='interior')
- block_tunnel = smooth.smooth(nodes, iterations=1,
- algorithm='laplace')
- ij = [1, 30, 1, len(block_tunnel.getULines()) - 2]
- nodes = smooth.selectNodes(domain='ij', ij=ij)
- block_tunnel = smooth.smooth(nodes, iterations=2,
- algorithm='laplace')
- ij = [len(block_tunnel.getVLines()) - 31,
- len(block_tunnel.getVLines()) - 2,
- 1,
- len(block_tunnel.getULines()) - 2]
- nodes = smooth.selectNodes(domain='ij', ij=ij)
- block_tunnel = smooth.smooth(nodes, iterations=3,
- algorithm='laplace')
-
- elif smoothing_algorithm == 'elliptic':
- # elliptic grid generation
- smoother = Elliptic.Elliptic(block_tunnel.getULines())
- new_ulines = smoother.smooth(iterations=smoothing_iterations,
- tolerance=smoothing_tolerance,
- bnd_type=None, # can be 'Neumann'
- verbose=True)
- block_tunnel.setUlines(new_ulines)
-
- elif smoothing_algorithm == 'angle_based':
- smoother = SmoothAngleBased(block_tunnel, data_source='block')
- smoothed_vertices = smoother.smooth(iterations=smoothing_iterations,
- tolerance=smoothing_tolerance,
- verbose=True)
- new_ulines = smoother.mapToUlines(smoothed_vertices)
- block_tunnel.setUlines(new_ulines)
-
- self.block_tunnel = block_tunnel
- self.blocks.append(block_tunnel)
-
- def TunnelMeshWake(self, name='', tunnel_wake=2.0,
- divisions=100, ratio=0.1, spread=0.4):
-
- chord = 1.0
-
- block_tunnel_wake = BlockMesh(name=name)
-
- # line composed of trailing edge and block_tunnel meshes
- line = self.block_tunnel.getVLines()[-1]
- line.reverse()
- del line[-1]
- line += self.block_te.getULines()[-1]
- del line[-1]
- line += self.block_tunnel.getVLines()[0]
- block_tunnel_wake.addLine(line)
-
- #
- p1 = np.array((self.block_te.getULines()[-1][0][0],
- self.tunnel_height))
- p4 = np.array((self.block_te.getULines()[-1][-1][0],
- - self.tunnel_height))
- p7 = np.array((tunnel_wake + chord, self.tunnel_height))
- p8 = np.array((tunnel_wake + chord, -self.tunnel_height))
-
- upper = BlockMesh.makeLine(p7, p1, divisions=divisions,
- ratio=1.0 / ratio)
- lower = BlockMesh.makeLine(p8, p4, divisions=divisions,
- ratio=1.0 / ratio)
- left = line
- right = BlockMesh.makeLine(p8, p7, divisions=len(left) - 1, ratio=1.0)
-
- boundary = [upper, lower, right, left]
- block_tunnel_wake.transfinite(boundary=boundary)
-
- # equalize division line in wake
- for i, u in enumerate(block_tunnel_wake.getULines()[0]):
- if u[0] < chord + tunnel_wake * spread:
- ll = len(block_tunnel_wake.getULines()[0])
- line_no = -ll + i
- break
- block_tunnel_wake.distribute(direction='v', number=line_no)
-
- # transfinite left of division line
- ij = [len(block_tunnel_wake.getVLines()) + line_no,
- len(block_tunnel_wake.getVLines()) - 1,
- 0,
- len(block_tunnel_wake.getULines()) - 1]
- block_tunnel_wake.transfinite(ij=ij)
-
- # transfinite right of division line
- ij = [0,
- len(block_tunnel_wake.getVLines()) + line_no,
- 0,
- len(block_tunnel_wake.getULines()) - 1]
- block_tunnel_wake.transfinite(ij=ij)
-
- self.block_tunnel_wake = block_tunnel_wake
- self.blocks.append(block_tunnel_wake)
-
- def makeMesh(self):
-
- toolbox = self.mainwindow.centralwidget.toolbox
-
- if self.mainwindow.airfoil:
- if not hasattr(self.mainwindow.airfoil, 'spline_data'):
- message = 'Splining needs to be done first.'
- self.mainwindow.slots.messageBox(message)
- return
-
- contour = self.mainwindow.airfoil.spline_data[0]
-
- else:
- self.mainwindow.slots.messageBox('No airfoil loaded.')
- return
-
- # delete blocks outline if existing
- # because a new one will be generated
- if hasattr(self.mainwindow.airfoil, 'mesh_blocks'):
- self.mainwindow.scene.removeItem(
- self.mainwindow.airfoil.mesh_blocks)
- del self.mainwindow.airfoil.mesh_blocks
-
- progdialog = QtWidgets.QProgressDialog(
- "Meshing in progress", "Cancel", 0, 100, self.mainwindow)
- progdialog.setFixedWidth(300)
- progdialog.setMinimumDuration(0)
- progdialog.setWindowTitle('Generating the CFD mesh')
- progdialog.setWindowModality(QtCore.Qt.WindowModal)
- progdialog.setCancelButtonText('Abort meshing ...')
- progdialog.show()
-
- progdialog.setValue(10)
- # progdialog.setLabelText('making blocks')
-
- self.AirfoilMesh(name='block_airfoil',
- contour=contour,
- divisions=toolbox.points_n.value(),
- ratio=toolbox.ratio.value(),
- thickness=toolbox.normal_thickness.value())
- progdialog.setValue(20)
-
- if progdialog.wasCanceled():
- return
-
- self.TrailingEdgeMesh(name='block_TE',
- te_divisions=toolbox.te_div.value(),
- thickness=toolbox.length_te.value(),
- divisions=toolbox.points_te.value(),
- ratio=toolbox.ratio_te.value())
- progdialog.setValue(30)
-
- if progdialog.wasCanceled():
- return
-
- self.TunnelMesh(name='block_tunnel',
- tunnel_height=toolbox.tunnel_height.value(),
- divisions_height=toolbox.divisions_height.value(),
- ratio_height=toolbox.ratio_height.value(),
- dist=toolbox.dist.currentText(),
- smoothing_algorithm=toolbox.smoothing_algorithm,
- smoothing_iterations=toolbox.smoother_iterations.value(),
- smoothing_tolerance=float(toolbox.smoother_tolerance.text()))
- progdialog.setValue(50)
-
- if progdialog.wasCanceled():
- return
-
- self.TunnelMeshWake(name='block_tunnel_wake',
- tunnel_wake=toolbox.tunnel_wake.value(),
- divisions=toolbox.divisions_wake.value(),
- ratio=toolbox.ratio_wake.value(),
- spread=toolbox.spread.value() / 100.0)
- progdialog.setValue(70)
-
- if progdialog.wasCanceled():
- return
-
- # connect mesh blocks
- connect = Connect.Connect(progdialog)
- vertices, connectivity, progdialog = \
- connect.connectAllBlocks(self.blocks)
-
- # add mesh to Wind-tunnel instance
- self.mesh = vertices, connectivity
-
- # generate cell to vertex connectivity from mesh
- self.makeLCV()
-
- # generate cell to edge connectivity from mesh
- self.makeLCE()
-
- # generate boundaries from mesh connectivity
- self.makeBoundaries()
-
- logger.info('Mesh around {} created'.
- format(self.mainwindow.airfoil.name))
- logger.info('Mesh has {} vertices and {} elements'.
- format(len(vertices), len(connectivity)))
-
- self.drawMesh(self.mainwindow.airfoil)
- self.drawBlockOutline(self.mainwindow.airfoil)
-
- # mesh quality
- # quality = self.MeshQuality(crit='k2inf')
- # self.drawMeshQuality(quality)
-
- progdialog.setValue(100)
-
- # enable mesh export and set filename and boundary definitions
- toolbox.box_meshexport.setEnabled(True)
-
- def makeLCV(self):
- """Make cell to vertex connectivity for the mesh
- LCV is identical to connectivity
- """
- _, connectivity = self.mesh
- self.LCV = connectivity
-
- def makeLVC(self):
- _, connectivity = self.mesh
- nodes = list(set([node for cell in connectivity for node in cell]))
- self.lvc = dict()
- for node in nodes:
- for cell in connectivity:
- if node in cell:
- self.lvc.setdefault(node, []).append(cell.tolist())
-
- def makeLCE(self):
- """Make cell to edge connectivity for the mesh"""
- _, connectivity = self.mesh
- self.LCE = dict()
- self.edges = list()
-
- for i, cell in enumerate(connectivity):
- # example for quadrilateral:
- # cell: [0, 1, 5, 4]
- # edges: [(0,1), (1,5), (5,4), (4,0)]
- edges = [(cell[j], cell[(j + 1) % len(cell)])
- for j in range(len(cell))]
-
- # all edges for cell i
- self.LCE[i] = edges
-
- # all edges in one list
- self.edges += [tuple(sorted(edge)) for edge in edges]
-
- def makeLCC(self):
- """Make cell to cell connectivity for the mesh"""
- pass
-
- def makeBoundaries(self):
- """A boundary edge is an edge that belongs only to one cell"""
-
- vertices, _ = self.mesh
- vertices = np.array(vertices)
-
- edges = self.edges
-
- seen = set()
- unique = list()
- doubles = set()
- for edge in edges:
- if edge not in seen:
- seen.add(edge)
- unique.append(edge)
- else:
- doubles.add(edge)
-
- self.boundary_edges = [edge for edge in unique if edge not in doubles]
-
- # tag edges for boundary definitions
- # FIXME
- # FIXME here it's done the dirty way
- # FIXME at least try to make it faster later
- # FIXME
- self.boundary_tags = {'airfoil': [],
- 'inlet': [],
- 'outlet': [],
- 'top': [],
- 'bottom': []}
-
- ### FIXME
- ### FIXME too dirty below (do not work with toplerances!!!)
- ### FIXME
-
- xmax = np.max(vertices[:,0])
- ymax = np.max(vertices[:,1])
- ymin = np.min(vertices[:,1])
-
- for edge in self.boundary_edges:
- x1 = vertices[edge[0]][0]
- y1 = vertices[edge[0]][1]
- x2 = vertices[edge[1]][0]
- y2 = vertices[edge[1]][1]
- tol = 1e-6 # tolerance for coordinate comparison
- if x1 > -0.1 and x1 < 1.1 and y1 < 0.5 and y1 > -0.5:
- self.boundary_tags['airfoil'].append(edge)
- elif abs(x1 - xmax) < tol and abs(x2 - xmax) < tol:
- self.boundary_tags['outlet'].append(edge)
- elif abs(y1 - ymax) < tol and abs(y2 - ymax) < tol:
- self.boundary_tags['top'].append(edge)
- elif abs(y1 - ymin) < tol and abs(y2 - ymin) < tol:
- self.boundary_tags['bottom'].append(edge)
- else:
- self.boundary_tags['inlet'].append(edge)
-
- return
-
- def drawMesh(self, airfoil):
- """Add the mesh as ItemGroup to the scene
-
- Args:
- airfoil (TYPE): object containing all airfoil properties and data
- """
-
- # toggle spline points
- self.mainwindow.centralwidget.airfoil_spline_points_checkbox.click()
-
- # delete old mesh if existing
- if hasattr(airfoil, 'mesh'):
- logger.debug('MESH item type: {}'.format(type(airfoil.mesh)))
- self.mainwindow.scene.removeItem(airfoil.mesh)
-
- mesh = list()
-
- for block in self.blocks:
- for lines in [block.getULines(),
- block.getVLines()]:
- for line in lines:
-
- # instantiate a graphics item
- contour = gic.GraphicsCollection()
- # make it polygon type and populate its points
- points = [QtCore.QPointF(x, y) for x, y in line]
- contour.Polyline(QtGui.QPolygonF(points), '')
- # set its properties
- contour.pen.setColor(QtGui.QColor(0, 0, 0, 255))
- contour.pen.setWidthF(0.8)
- contour.pen.setCosmetic(True)
- contour.brush.setStyle(QtCore.Qt.NoBrush)
-
- # add contour as a GraphicsItem to the scene
- # these are the objects which are drawn in the GraphicsView
- meshline = GraphicsItem.GraphicsItem(contour)
- mesh.append(meshline)
-
- airfoil.mesh = self.mainwindow.scene.createItemGroup(mesh)
-
- # activate viewing options if mesh is created and displayed
- self.mainwindow.centralwidget.mesh_checkbox.setChecked(True)
- self.mainwindow.centralwidget.mesh_checkbox.setEnabled(True)
-
- def drawMeshQuality(self, quality):
-
- vertices, connectivity = self.mesh
- quads = list()
- colors = [Utils.scalar_to_rgb(q, range='256') for q in quality]
-
- for i, cell in enumerate(connectivity):
- quad = gic.GraphicsCollection()
- points = [QtCore.QPointF(*vertices[vertex]) for vertex in cell]
- quad.Polygon(QtGui.QPolygonF(points), '')
- quad.pen.setColor(QtGui.QColor(0, 0, 0, 255))
- quad.brush.setColor(QtGui.QColor(*colors[i]))
- quad.pen.setWidthF(0.8)
- quad.pen.setCosmetic(True)
- quaditem = GraphicsItem.GraphicsItem(quad)
- quads.append(quaditem)
-
- self.mainwindow.scene.createItemGroup(quads)
-
- def drawBlockOutline(self, airfoil):
- """Add the mesh block outlines to the scene
-
- Args:
- airfoil (TYPE): object containing all airfoil properties and data
- """
-
- # FIXME
- # FIXME Refactoring of code duplication here and in drawMesh
- # FIXME
-
- mesh_blocks = list()
-
- for block in self.blocks:
- for lines in [block.getULines()]:
- for line in [lines[0], lines[-1]]:
-
- # instantiate a graphics item
- contour = gic.GraphicsCollection()
- # make it polygon type and populate its points
- points = [QtCore.QPointF(x, y) for x, y in line]
- contour.Polyline(QtGui.QPolygonF(points), '')
- # set its properties
- contour.pen.setColor(QtGui.QColor(202, 31, 123, 255))
- contour.pen.setWidthF(3.0)
- contour.pen.setCosmetic(True)
- contour.brush.setStyle(QtCore.Qt.NoBrush)
-
- # add contour as a GraphicsItem to the scene
- # these are the objects which are drawn in the GraphicsView
- meshline = GraphicsItem.GraphicsItem(contour)
- mesh_blocks.append(meshline)
-
- for lines in [block.getVLines()]:
- for line in [lines[0], lines[-1]]:
-
- # instantiate a graphics item
- contour = gic.GraphicsCollection()
- # make it polygon type and populate its points
- points = [QtCore.QPointF(x, y) for x, y in line]
- contour.Polyline(QtGui.QPolygonF(points), '')
- # set its properties
- contour.pen.setColor(QtGui.QColor(202, 31, 123, 255))
- contour.pen.setWidthF(3.0)
- contour.pen.setCosmetic(True)
- contour.brush.setStyle(QtCore.Qt.NoBrush)
-
- # add contour as a GraphicsItem to the scene
- # these are the objects which are drawn in the GraphicsView
- meshline = GraphicsItem.GraphicsItem(contour)
- mesh_blocks.append(meshline)
-
- airfoil.mesh_blocks = self.mainwindow.scene \
- .createItemGroup(mesh_blocks)
-
- # initial visibility of mesh blocks is False, but enabled
- airfoil.mesh_blocks.setVisible(False)
- self.mainwindow.centralwidget.mesh_blocks_checkbox.setEnabled(True)
-
- def MeshQuality(self, crit='k2inf'):
- vertices, connectivity = self.mesh
-
- if crit == 'k2inf':
- v12 = vertices[connectivity[:, 1]] - vertices[connectivity[:, 0]]
- v23 = vertices[connectivity[:, 2]] - vertices[connectivity[:, 1]]
- v34 = vertices[connectivity[:, 3]] - vertices[connectivity[:, 2]]
- v41 = vertices[connectivity[:, 0]] - vertices[connectivity[:, 3]]
- a = np.linalg.norm(v12)
- b = np.linalg.norm(v23)
- c = np.linalg.norm(v34)
- d = np.linalg.norm(v41)
- p = 0.5 * (a + b + c + d)
- q2 = np.sqrt(a**2 + b**2 + c**2 + d**2)
-
- alpha = Utils.angle_between(v12, -v41)
- beta = Utils.angle_between(v23, -v12)
- gamma = Utils.angle_between(v34, -v23)
- delta = Utils.angle_between(v41, -v12)
- theta = 0.5 * (alpha + gamma)
-
- # quad area using Bretschneider’s formula
- A = np.sqrt((p -a)*(p-b)*(p-c)*(p-d) - a*b*c*d*np.cos(theta))
-
- ka = (a**2 + d**2) / (a*d*np.sin(alpha))
- kb = (a**2 + b**2) / (a*b*np.sin(beta))
- kc = (b**2 + c**2) / (b*c*np.sin(gamma))
- kd = (c**2 + d**2) / (c*d*np.sin(delta))
- k = np.stack((ka, kb, kc, kd))
-
- quality = np.max(k, axis=0) / 2.
-
- self.mesh.quality = quality
-
- return self.mesh.quality
-
-
-class BlockMesh:
-
- def __init__(self, name='block'):
- self.name = name
- self.ULines = list()
-
- def addLine(self, line):
- # line is a list of (x, y) tuples
- self.ULines.append(line)
-
- def getULines(self):
- return self.ULines
-
- def setUlines(self, ulines):
- self.ULines = ulines
-
- def getVLines(self):
- vlines = list()
- U, V = self.getDivUV()
-
- # loop over all u-lines
- for i in range(U + 1):
- # prepare new v-line
- vline = list()
- # collect i-th point on each u-line
- for uline in self.getULines():
- vline.append(uline[i])
- vlines.append(vline)
-
- return vlines
-
- def getLine(self, number=0, direction='u'):
- if direction.lower() == 'u':
- lines = self.getULines()
- if direction.lower() == 'v':
- lines = self.getVLines()
- return lines[number]
-
- def getDivUV(self):
- u = len(self.getULines()[0]) - 1
- v = len(self.getULines()) - 1
- return u, v
-
- def getNodeCoo(self, node):
- I, J = node[0], node[1]
- uline = self.getULines()[J]
- point = uline[I]
- return np.array(point)
-
- def setNodeCoo(self, node, new_pos):
- I, J = node[0], node[1]
- uline = self.getULines()[J]
- uline[I] = new_pos
- return
-
- @staticmethod
- def makeLine(p1, p2, divisions=1, ratio=1.0):
- vec = p2 - p1
- dist = np.linalg.norm(vec)
- spacing = BlockMesh.spacing(divisions=divisions,
- ratio=ratio, length=dist)
- line = list()
- line.append((p1.tolist()[0], p1.tolist()[1]))
- for i in range(1, len(spacing)):
- p = p1 + spacing[i] * Utils.unit_vector(vec)
- line.append((p.tolist()[0], p.tolist()[1]))
- del line[-1]
- line.append((p2.tolist()[0], p2.tolist()[1]))
- return line
-
- def extrudeLine_cell_thickness(self, line, cell_thickness=0.04,
- growth=1.05,
- divisions=1,
- direction=3):
- x, y = list(zip(*line))
- x = np.array(x)
- y = np.array(y)
- if direction == 3:
- spacing, _ = self.spacing_cell_thickness(
- cell_thickness=cell_thickness,
- growth=growth,
- divisions=divisions)
- normals = self.curveNormals(x, y)
- for i in range(1, len(spacing)):
- xo = x + spacing[i] * normals[:, 0]
- yo = y + spacing[i] * normals[:, 1]
- line = list(zip(xo.tolist(), yo.tolist()))
- self.addLine(line)
- elif direction == 4:
- spacing, _ = self.spacing_cell_thickness(
- cell_thickness=cell_thickness,
- growth=growth,
- divisions=divisions)
- normals = self.curveNormals(x, y)
- normalx = normals[:, 0].mean()
- normaly = normals[:, 1].mean()
- for i in range(1, len(spacing)):
- xo = x + spacing[i] * normalx
- yo = y + spacing[i] * normaly
- line = list(zip(xo.tolist(), yo.tolist()))
- self.addLine(line)
-
- def extrudeLine(self, line, direction=0, length=0.1, divisions=1,
- ratio=1.00001, constant=False):
- x, y = list(zip(*line))
- x = np.array(x)
- y = np.array(y)
- if constant and direction == 0:
- x.fill(length)
- line = list(zip(x.tolist(), y.tolist()))
- self.addLine(line)
- elif constant and direction == 1:
- y.fill(length)
- line = list(zip(x.tolist(), y.tolist()))
- self.addLine(line)
- elif direction == 3:
- spacing = self.spacing(divisions=divisions,
- ratio=ratio,
- length=length)
- normals = self.curveNormals(x, y)
- for i in range(1, len(spacing)):
- xo = x + spacing[i] * normals[:, 0]
- yo = y + spacing[i] * normals[:, 1]
- line = list(zip(xo.tolist(), yo.tolist()))
- self.addLine(line)
- elif direction == 4:
- spacing = self.spacing(divisions=divisions,
- ratio=ratio,
- length=length)
- normals = self.curveNormals(x, y)
- normalx = normals[:, 0].mean()
- normaly = normals[:, 1].mean()
- for i in range(1, len(spacing)):
- xo = x + spacing[i] * normalx
- yo = y + spacing[i] * normaly
- line = list(zip(xo.tolist(), yo.tolist()))
- self.addLine(line)
-
- def distribute(self, direction='u', number=0, type='constant'):
-
- if direction == 'u':
- line = np.array(self.getULines()[number])
- elif direction == 'v':
- line = np.array(self.getVLines()[number])
-
- # interpolate B-spline through data points
- # here, a linear interpolant is derived "k=1"
- # splprep returns:
- # tck ... tuple (t,c,k) containing the vector of knots,
- # the B-spline coefficients, and the degree of the spline.
- # u ... array of the parameters for each given point (knot)
- tck, u = interpolate.splprep(line.T, s=0, k=1)
-
- if type == 'constant':
- t = np.linspace(0.0, 1.0, num=len(line))
- if type == 'transition':
- first = np.array(self.getULines()[0])
- last = np.array(self.getULines()[-1])
- tck_first, u_first = interpolate.splprep(first.T, s=0, k=1)
- tck_last, u_last = interpolate.splprep(last.T, s=0, k=1)
- if number < 0.0:
- number = len(self.getVLines())
- v = float(number) / float(len(self.getVLines()))
- t = (1.0 - v) * u_first + v * u_last
-
- # evaluate function at any parameter "0<=t<=1"
- line = interpolate.splev(t, tck, der=0)
- line = list(zip(line[0].tolist(), line[1].tolist()))
-
- if direction == 'u':
- self.getULines()[number] = line
- elif direction == 'v':
- for i, uline in enumerate(self.getULines()):
- self.getULines()[i][number] = line[i]
-
- @staticmethod
- def spacing_cell_thickness(cell_thickness=0.04, growth=1.1, divisions=10):
-
- # add cell thickness of first layer
- spacing = [cell_thickness]
-
- for i in range(divisions - 1):
- spacing.append(spacing[0] + spacing[-1] * growth)
-
- spacing.insert(0, 0.0)
-
- length = np.sum(spacing)
-
- return spacing, length
-
- @staticmethod
- def spacing(divisions=10, ratio=1.0, length=1.0):
- """Calculate point distribution on a line
-
- Args:
- divisions (int, optional): Number of subdivisions
- ratio (float, optional): Ratio of last to first subdivision size
- length (float, optional): length of line
-
- Returns:
- array: individual line segment lengths
- """
-
- if divisions == 1:
- sp = [0.0, 1.0]
- return np.array(sp)
-
- growth = ratio**(1.0 / (float(divisions) - 1.0))
-
- if growth == 1.0:
- growth = 1.0 + 1.0e-10
-
- s = [1.0]
- for i in range(1, divisions + 1):
- s.append(growth**i)
-
- spacing = np.array(s)
- spacing -= spacing[0]
- spacing /= spacing[-1]
- spacing *= length
-
- return spacing
-
- def mapLines(self, line_1, line_2):
- """Map the distribution of points from one line to another line
-
- Args:
- line_1 (LIST): Source line (will be mapped)
- line_2 (LIST): Destination line (upon this line_1 is mapped)
- """
- pass
-
- @staticmethod
- def curveNormals(x, y, closed=False):
- istart = 0
- iend = 0
- n = list()
-
- for i, _ in enumerate(x):
-
- if closed:
- if i == len(x) - 1:
- iend = -i - 1
- else:
- if i == 0:
- istart = 1
- if i == len(x) - 1:
- iend = -1
-
- a = np.array([x[i + 1 + iend] - x[i - 1 + istart],
- y[i + 1 + iend] - y[i - 1 + istart]])
- e = Utils.unit_vector(a)
- n.append([e[1], -e[0]])
- istart = 0
- iend = 0
- return np.array(n)
-
- def transfinite(self, boundary=[], ij=[]):
- """Make a transfinite interpolation.
-
- http://en.wikipedia.org/wiki/Transfinite_interpolation
-
- upper
- --------------------
- | |
- | |
- left | | right
- | |
- | |
- --------------------
- lower
-
- Example input for the lower boundary:
- lower = [(0.0, 0.0), (0.1, 0.3), (0.5, 0.4)]
- """
-
- if boundary:
- lower = boundary[0]
- upper = boundary[1]
- left = boundary[2]
- right = boundary[3]
- elif ij:
- lower = self.getULines()[ij[2]][ij[0]:ij[1] + 1]
- upper = self.getULines()[ij[3]][ij[0]:ij[1] + 1]
- left = self.getVLines()[ij[0]][ij[2]:ij[3] + 1]
- right = self.getVLines()[ij[1]][ij[2]:ij[3] + 1]
- else:
- lower = self.getULines()[0]
- upper = self.getULines()[-1]
- left = self.getVLines()[0]
- right = self.getVLines()[-1]
-
- # FIXME
- # FIXME left and right need to swapped from input
- # FIXME
- # FIXME like: left, right = right, left
- # FIXME
-
- lower = np.array(lower)
- upper = np.array(upper)
- left = np.array(left)
- right = np.array(right)
-
- # convert the block boundary curves into parametric form
- # as curves need to be between 0 and 1
- # interpolate B-spline through data points
- # here, a linear interpolant is derived "k=1"
- # splprep returns:
- # tck ... tuple (t,c,k) containing the vector of knots,
- # the B-spline coefficients, and the degree of the spline.
- # u ... array of the parameters for each given point (knot)
- tck_lower, u_lower = interpolate.splprep(lower.T, s=0, k=1)
- tck_upper, u_upper = interpolate.splprep(upper.T, s=0, k=1)
- tck_left, u_left = interpolate.splprep(left.T, s=0, k=1)
- tck_right, u_right = interpolate.splprep(right.T, s=0, k=1)
-
- nodes = np.zeros((len(left) * len(lower), 2))
-
- # corner points
- c1 = lower[0]
- c2 = upper[0]
- c3 = lower[-1]
- c4 = upper[-1]
-
- for i, xi in enumerate(u_lower):
- for j, eta in enumerate(u_left):
-
- node = i * len(u_left) + j
-
- point = (1.0 - xi) * left[j] + xi * right[j] + \
- (1.0 - eta) * lower[i] + eta * upper[i] - \
- ((1.0 - xi) * (1.0 - eta) * c1 + (1.0 - xi) * eta * c2 +
- xi * (1.0 - eta) * c3 + xi * eta * c4)
-
- nodes[node, 0] = point[0]
- nodes[node, 1] = point[1]
-
- vlines = list()
- vline = list()
- i = 0
- for node in nodes:
- i += 1
- vline.append(node)
- if i % len(left) == 0:
- vlines.append(vline)
- vline = list()
-
- vlines.reverse()
-
- if ij:
- ulines = self.makeUfromV(vlines)
- n = -1
- for k in range(ij[2], ij[3] + 1):
- n += 1
- self.ULines[k][ij[0]:ij[1] + 1] = ulines[n]
- else:
- self.ULines = self.makeUfromV(vlines)
-
- return
-
- @staticmethod
- def makeUfromV(vlines):
- ulines = list()
- uline = list()
- for i in range(len(vlines[0])):
- for vline in vlines:
- x, y = vline[i][0], vline[i][1]
- uline.append((x, y))
- ulines.append(uline[::-1])
- uline = list()
- return ulines
-
- @staticmethod
- def writeFLMA(wind_tunnel, name='', depth=0.3):
- '''Write mesh to AVL-FIRE *.flma format'''
-
- basename = os.path.basename(name)
- nameroot, extension = os.path.splitext(basename)
-
- mesh = wind_tunnel.mesh
-
- vertices, connectivity = mesh
-
- with open(name, 'w') as f:
-
- number_of_vertices_2D = len(vertices)
-
- numvertex = '8'
-
- # write number of points to FLMA file (*2 for z-direction)
- f.write(str(2 * number_of_vertices_2D) + '\n')
-
- signum = -1.
-
- # write x-, y- and z-coordinates to FLMA file
- # loop 1D direction (symmetry)
- for _ in range(2):
- for vertex in vertices:
- f.write(str(vertex[0]) + ' ' + str(vertex[1]) +
- ' ' + str(signum * depth / 2.0) + ' ')
- signum = 1.
-
- # write number of cells to FLMA file
- cells = len(connectivity)
- f.write('\n' + str(cells) + '\n')
-
- # write cell connectivity to FLMA file
- for cell in connectivity:
- cell_connect = str(cell[0]) + ' ' + \
- str(cell[1]) + ' ' + \
- str(cell[2]) + ' ' + \
- str(cell[3]) + ' ' + \
- str(cell[0] + number_of_vertices_2D) + ' ' + \
- str(cell[1] + number_of_vertices_2D) + ' ' + \
- str(cell[2] + number_of_vertices_2D) + ' ' + \
- str(cell[3] + number_of_vertices_2D) + '\n'
-
- f.write(numvertex + '\n')
- f.write(cell_connect)
-
- # FIRE element type (FET) for HEX element
- fetHEX = '5'
- f.write('\n' + str(cells) + '\n')
- for i in range(cells):
- f.write(fetHEX + ' ')
- f.write('\n\n')
-
- # FIRE element type (FET) for Quad element
- fetQuad = '3\n'
-
- # write FIRE selections to FLMA file
- # number of selections
- f.write('6\n')
- # selection name
- f.write('symmetry\n')
- # FIRE element type
- f.write(fetQuad)
- # 2x number of faces in the selection
- # her we take 4x because we put both symmetry selections together
- f.write(str(4 * len(connectivity)) + '\n')
- # cells of the face-selection and face direction (0-5)
- for i in range(len(connectivity)):
- f.write(f' {i} 0')
- for i in range(len(connectivity)):
- f.write(f' {i} 1')
- f.write('\n')
- f.write('\n')
- #
- # FIXME
- # FIXME find all cell around the airfoil, at the outlet and at the inlet
- # FIXME
- #
- # selection name
- f.write('bottom\n')
- f.write(fetQuad)
- f.write('2\n')
- f.write('0 2\n')
- f.write('\n')
- f.write('top\n')
- f.write(fetQuad)
- f.write('2\n')
- f.write('0 3\n')
- f.write('\n')
- f.write('back\n')
- f.write(fetQuad)
- f.write('2\n')
- f.write('0 4\n')
- f.write('\n')
- f.write('front\n')
- f.write(fetQuad)
- f.write('2\n')
- f.write('0 5\n')
-
- logger.info('FIRE type mesh saved as {}'.
- format(os.path.join(OUTPUTDATA, basename)))
-
- @staticmethod
- def writeSU2_nolib(wind_tunnel, name=''):
- '''Write mesh to SU2 format without using meshio'''
-
- mesh = wind_tunnel.mesh
- vertices, connectivity = mesh
- tags = wind_tunnel.boundary_tags
-
- num_airfoil_edges = len(tags['airfoil'])
- num_inlet_edges = len(tags['inlet'])
- num_outlet_edges = len(tags['outlet'])
- num_top_edges = len(tags['top'])
- num_bottom_edges = len(tags['bottom'])
-
- with open(name, 'w') as f:
- # write header
- f.write('%\n')
- f.write('% Problem dimension\n')
- f.write('%\n')
- f.write('NDIME= 2\n')
-
- f.write('%\n')
- f.write('% Node coordinates\n')
- f.write('%\n')
- f.write('NPOIN= ' + str(len(vertices)) + '\n')
- # write vertices
- for i, vertex in enumerate(vertices):
- f.write(f'{vertex[0]: .8e} {vertex[1]: .8e} {i:<}\n')
-
- f.write('%\n')
- f.write('% Element connectivity\n')
- f.write('%\n')
- f.write('NELEM= ' + str(len(connectivity)) + '\n')
- # write elements
- for i, cell in enumerate(connectivity):
- f.write(f'9 {cell[0]:10d} {cell[1]:10d} {cell[2]:10d} {cell[3]:10d} {i:>10d}\n')
-
- f.write('%\n')
- f.write('% Boundary tags\n')
- f.write('%\n')
- # write boundary tags
- f.write('NMARK= 5\n')
-
- f.write('MARKER_TAG= airfoil\n')
- f.write('MARKER_ELEMS= ' + str(num_airfoil_edges) + '\n')
- for edge in tags['airfoil']:
- f.write(f'3 {edge[0]} {edge[1]}\n')
-
- f.write('MARKER_TAG= inlet\n')
- f.write('MARKER_ELEMS= ' + str(num_inlet_edges) + '\n')
- for edge in tags['inlet']:
- f.write(f'3 {edge[0]} {edge[1]}\n')
-
- f.write('MARKER_TAG= outlet\n')
- f.write('MARKER_ELEMS= ' + str(num_outlet_edges) + '\n')
- for edge in tags['outlet']:
- f.write(f'3 {edge[0]} {edge[1]}\n')
-
- f.write('MARKER_TAG= top\n')
- f.write('MARKER_ELEMS= ' + str(num_top_edges) + '\n')
- for edge in tags['top']:
- f.write(f'3 {edge[0]} {edge[1]}\n')
-
- f.write('MARKER_TAG= bottom\n')
- f.write('MARKER_ELEMS= ' + str(num_bottom_edges) + '\n')
- for edge in tags['bottom']:
- f.write(f'3 {edge[0]} {edge[1]}\n')
-
- basename = os.path.basename(name)
- logger.info('SU2 type mesh saved as {}'.
- format(os.path.join(OUTPUTDATA, basename)))
-
- @staticmethod
- def writeVTK_nolib(wind_tunnel, name=''):
- """
- Write a VTU file (UnstructuredGrid)."""
-
- mesh = wind_tunnel.mesh
- vertices, connectivity = mesh
- tags = wind_tunnel.boundary_tags
-
- vertices = [v + (0.0,) for v in vertices]
- # Determine number of points
- num_vertices = len(vertices)
-
- # Convert connectivity (list of lists) to a consistent format internally
- # without changing the external data model.
- polygon_cells = [np.array(cell, dtype=int) for cell in connectivity]
-
-
- # Function to determine VTK cell type based on number of vertices in a cell
- # Add more mappings if you have other cell types.
- def cell_type_from_length(n):
- if n == 2:
- return 3 # VTK_LINE
- elif n == 3:
- return 5 # VTK_TRIANGLE
- elif n == 4:
- return 9 # VTK_QUAD
- else:
- raise ValueError(f"No VTK cell type defined for {n}-node cells.")
-
- # Process main polygonal cells
- # For these cells, we assign boundary_id=0 as a default.
- # Flatten their connectivity
-
- # Process main polygonal cells
- polygon_cell_lengths = [len(cell) for cell in polygon_cells]
- polygon_connectivity_flat = np.concatenate([cell for cell in polygon_cells]) if polygon_cells else np.array([], dtype=int)
- polygon_cell_types = np.array([cell_type_from_length(l) for l in polygon_cell_lengths], dtype=np.uint8)
- polygon_boundary_ids = np.zeros(len(polygon_cells), dtype=np.int32) # default boundary_id=0
-
- # Process boundary edges (line cells)
- # Assign each boundary name a unique ID starting from 1
- boundary_names = list(tags.keys())
- boundary_id_map = {name: i+1 for i, name in enumerate(boundary_names)}
-
- # Flatten boundary edges into a single connectivity array
- # Each edge is a 2-vertex line cell
- boundary_edges = []
- boundary_edge_lengths = []
- boundary_edge_types = []
- boundary_edge_ids = []
-
- for bname, edges in tags.items():
- for edge in edges:
- edge = np.array(edge, dtype=int) # ensure numpy array
- if len(edge) != 2:
- raise ValueError("Boundary edges must have exactly 2 vertices.")
- boundary_edges.append(edge)
- boundary_edge_lengths.append(2)
- boundary_edge_types.append(cell_type_from_length(2))
- boundary_edge_ids.append(boundary_id_map[bname])
-
- if len(boundary_edges) > 0:
- boundary_connectivity_flat = np.concatenate(boundary_edges)
- boundary_cell_types = np.array(boundary_edge_types, dtype=np.uint8)
- boundary_ids_array = np.array(boundary_edge_ids, dtype=np.int32)
- else:
- boundary_connectivity_flat = np.array([], dtype=int)
- boundary_cell_types = np.array([], dtype=np.uint8)
- boundary_ids_array = np.array([], dtype=np.int32)
-
- # Combine polygonal cells and boundary line cells
- all_connectivity_flat = np.concatenate([polygon_connectivity_flat, boundary_connectivity_flat])
- all_cell_types = np.concatenate([polygon_cell_types, boundary_cell_types])
- all_boundary_ids = np.concatenate([polygon_boundary_ids, boundary_ids_array])
- all_cell_lengths = polygon_cell_lengths + boundary_edge_lengths
-
- num_cells = len(all_cell_lengths)
- offsets = np.cumsum(all_cell_lengths)
-
- # Write VTU file in ASCII format
- with open(name, "w") as f:
- f.write('\n')
- f.write('\n')
- f.write(' \n')
- f.write(f' \n')
-
- # Cell Data: boundary_ids
- f.write(' \n')
- f.write(' \n')
- f.write(' ' + ' '.join(map(str, all_boundary_ids)) + '\n')
- f.write(' \n')
- f.write(' \n')
-
- # vertices
- f.write(' \n')
- f.write(' \n')
- for p in vertices:
- f.write(f' {p[0]} {p[1]} {p[2]}\n')
- f.write(' \n')
- f.write(' \n')
-
- # Cells
- f.write(' \n')
- # connectivity
- f.write(' \n')
- f.write(' ' + ' '.join(map(str, all_connectivity_flat)) + '\n')
- f.write(' \n')
-
- # offsets
- f.write(' \n')
- f.write(' ' + ' '.join(map(str, offsets)) + '\n')
- f.write(' \n')
-
- # types
- f.write(' \n')
- f.write(' ' + ' '.join(map(str, all_cell_types)) + '\n')
- f.write(' \n')
-
- f.write(' \n')
-
- f.write(' \n')
- f.write(' \n')
- f.write(' \n')
-
- basename = os.path.basename(name)
- logger.info('VTK type mesh saved as {}'.
- format(os.path.join(OUTPUTDATA, basename)))
-
- @staticmethod
- def writeGMSH_nolib(wind_tunnel, name=''):
- """Writes a GMSH 2.2 format mesh file."""
-
- mesh = wind_tunnel.mesh
- vertices, connectivity = mesh
- boundaries = wind_tunnel.boundary_tags
-
- # Assign unique physical tags to boundaries
- boundary_tags = {name: idx + 1 for idx, name in enumerate(boundaries.keys())}
- num_physical_names = len(boundaries) + 1 # +1 for the domain (elements)
-
- # Assign a physical tag for the domain elements
- domain_physical_tag = num_physical_names
-
- # Write the mesh file
- with open(name, 'w') as f:
- # Write MeshFormat section
- f.write('$MeshFormat\n')
- f.write('2.2 0 8\n') # Version 2.2, ASCII mode, size of double precision
- f.write('$EndMeshFormat\n')
-
- # Write PhysicalNames section
- f.write('$PhysicalNames\n')
- f.write(f'{num_physical_names}\n')
- # Write boundary physical names
- for name1, tag in boundary_tags.items():
- f.write(f'1 {tag} "{name1}"\n') # Dimension 1 for lines
- # Write domain physical name
- f.write(f'2 {domain_physical_tag} "Domain"\n') # Dimension 2 for surface elements
- f.write('$EndPhysicalNames\n')
-
- # Write Nodes section
- f.write('$Nodes\n')
- f.write(f'{len(vertices)}\n')
- z = 0.0 # 2D mesh
- for idx, (x, y) in enumerate(vertices, start=1):
- f.write(f'{idx} {x: .8e} {y: .8e} {z: .8e}\n')
- f.write('$EndNodes\n')
-
- # Prepare elements
- elements_data = []
- elem_id = 1
-
- # Write boundary edge elements
- for name1, edges in boundaries.items():
- physical_tag = boundary_tags[name1]
- geometrical_tag = physical_tag # For simplicity, set geometrical tag equal to physical tag
- element_type = 1 # Line elements
- num_tags = 2
- for edge in edges:
- node1, node2 = edge
- elements_data.append((elem_id,
- element_type,
- num_tags,
- physical_tag,
- geometrical_tag,
- [node1+1, node2+1])) # +1 to match GMSH 1-based indexing
- elem_id += 1
-
- # Write domain elements
- for elem_nodes in connectivity:
- num_nodes = len(elem_nodes)
- if num_nodes == 3:
- element_type = 2 # Triangle
- elif num_nodes == 4:
- element_type = 3 # Quadrangle
- elif num_nodes == 6:
- element_type = 9 # 6-node second order triangle
- elif num_nodes == 8:
- element_type = 16 # 8-node second order quadrangle
- else:
- raise ValueError(f"Unsupported element with {num_nodes} nodes.")
-
- num_tags = 2
- physical_tag = domain_physical_tag
- geometrical_tag = physical_tag
- elements_data.append((elem_id,
- element_type,
- num_tags,
- physical_tag,
- geometrical_tag,
- [node+1 for node in elem_nodes])) # +1 to match GMSH 1-based indexing
- elem_id += 1
-
- # Write Elements section
- f.write('$Elements\n')
- f.write(f'{len(elements_data)}\n')
- for elem in elements_data:
- elem_id, elem_type, num_tags, physical_tag, geometrical_tag, node_ids = elem
- node_ids_str = ' '.join(map(str, node_ids))
- f.write(f'{elem_id} {elem_type} {num_tags} {physical_tag} {geometrical_tag} {node_ids_str}\n')
- f.write('$EndElements\n')
-
- logger.info(f'GMSH type mesh saved as {name}')
-
-
-class Smooth:
-
- def __init__(self, block):
- self.block = block
-
- def getNeighbours(self, node):
- """Get a list of neighbours around a node """
-
- i, j = node[0], node[1]
- neighbours = {1: (i - 1, j - 1), 2: (i, j - 1), 3: (i + 1, j - 1),
- 4: (i + 1, j), 5: (i + 1, j + 1), 6: (i, j + 1),
- 7: (i - 1, j + 1), 8: (i - 1, j)}
- return neighbours
-
- def smooth(self, nodes, iterations=1, algorithm='laplace'):
- """Smoothing of a square lattice mesh
-
- Algorithms:
- - Angle based
- Tian Zhou:
- AN ANGLE-BASED APPROACH TO TWO-DIMENSIONAL MESH SMOOTHING
- - Laplace
- Mean of surrounding node coordinates
- - Parallelogram smoothing
- Sanjay Kumar Khattri:
- A NEW SMOOTHING ALGORITHM FOR QUADRILATERAL AND HEXAHEDRAL MESHES
-
- Args:
- nodes (TYPE): List of (i, j) tuples for the nodes to be smoothed
- iterations (int, optional): Number of smoothing iterations
- algorithm (str, optional): Smoothing algorithm
- """
-
- # loop number of smoothing iterations
- for i in range(iterations):
-
- new_pos = list()
-
- # smooth a node (i, j)
- for node in nodes:
- nb = self.getNeighbours(node)
-
- if algorithm == 'laplace':
- new_pos = (self.block.getNodeCoo(nb[2]) +
- self.block.getNodeCoo(nb[4]) +
- self.block.getNodeCoo(nb[6]) +
- self.block.getNodeCoo(nb[8])) / 4.0
-
- if algorithm == 'parallelogram':
-
- new_pos = (self.block.getNodeCoo(nb[1]) +
- self.block.getNodeCoo(nb[3]) +
- self.block.getNodeCoo(nb[5]) +
- self.block.getNodeCoo(nb[7])) / 4.0 - \
- (self.block.getNodeCoo(nb[2]) +
- self.block.getNodeCoo(nb[4]) +
- self.block.getNodeCoo(nb[6]) +
- self.block.getNodeCoo(nb[8])) / 2.0
-
- if algorithm == 'angle_based':
- pass
-
- self.block.setNodeCoo(node, new_pos.tolist())
-
- return self.block
-
- def selectNodes(self, domain='interior', ij=[]):
- """Generate a node index list
-
- Args:
- domain (str, optional): Defines the part of the domain where
- nodes shall be selected
-
- Returns:
- List: Indices as (i, j) tuples
- """
- U, V = self.block.getDivUV()
- nodes = list()
-
- # select all nodes except boundary nodes
- if domain == 'interior':
- istart = 1
- iend = U
- jstart = 1
- jend = V
-
- if domain == 'ij':
- istart = ij[0]
- iend = ij[1]
- jstart = ij[2]
- jend = ij[3]
-
- for i in range(istart, iend):
- for j in range(jstart, jend):
- nodes.append((i, j))
-
- return nodes
+import os
+from dataclasses import dataclass, field, replace
+
+import numpy as np
+
+from PySide6 import QtCore, QtWidgets
+
+from BlockMesh import BlockMesh as LegacyBlockMesh
+from BlockMesh import Smooth as LegacySmooth
+import Domain
+import Mesh as MeshModel
+import MeshGraphics
+import MeshBuilders
+import Connect
+from Elliptic import BoundaryGuide, SlidingBoundary, EllipticSolver
+from ExperimentalCGrid import (
+ ExperimentalCGridGenerator,
+ ExperimentalCGridSettings,
+)
+from ExperimentalOGrid import (
+ ExperimentalOGridGenerator,
+ ExperimentalOGridSettings,
+)
+from QuadPipeline import (
+ HybridQuadPipeline,
+ HybridQuadPipelineSettings,
+ HybridStage1Settings,
+ HybridStage2Settings,
+)
+from QuadQuality import QuadQualityEvaluator
+from Smoother import SmootherFactory
+from StructuredCore import StructuredMeshSettings, TunnelBoundaryControl
+from StructuredEngine import StructuredEngine
+from Utils import get_main_window
+import logging
+logger = logging.getLogger(__name__)
+
+OUTPUT = os.path.join(os.path.dirname(__file__), 'output')
+
+
+def _default_metric_pipeline_settings():
+ return HybridQuadPipelineSettings(
+ layout_strategy='metric_c_grid',
+ protect_near_wall=False,
+ stage2=HybridStage2Settings(enabled=False, sweeps=0),
+ stage1=HybridStage1Settings(
+ enabled=False,
+ algorithm='none',
+ iterations=0,
+ ),
+ )
+
+
+@dataclass(slots=True)
+class WindtunnelMeshSettings:
+ airfoil: MeshBuilders.AirfoilBlockSettings
+ trailing_edge: MeshBuilders.TrailingEdgeBlockSettings
+ tunnel: MeshBuilders.TunnelBlockSettings
+ wake: MeshBuilders.WakeBlockSettings
+ engine: str = 'metric_based'
+ metric_based: HybridQuadPipelineSettings = field(
+ default_factory=_default_metric_pipeline_settings
+ )
+ hybrid: HybridQuadPipelineSettings = field(
+ default_factory=HybridQuadPipelineSettings
+ )
+ hybrid_staged: HybridQuadPipelineSettings = field(
+ default_factory=HybridQuadPipelineSettings
+ )
+ experimental: ExperimentalCGridSettings = field(
+ default_factory=ExperimentalCGridSettings
+ )
+ experimental_o: ExperimentalOGridSettings = field(
+ default_factory=ExperimentalOGridSettings
+ )
+ structured: StructuredMeshSettings = field(
+ default_factory=StructuredMeshSettings
+ )
+
+
+class NullProgressDialog:
+ def setFixedWidth(self, _width):
+ return None
+
+ def setMinimumDuration(self, _duration):
+ return None
+
+ def setWindowTitle(self, _title):
+ return None
+
+ def setWindowModality(self, _modality):
+ return None
+
+ def setCancelButtonText(self, _text):
+ return None
+
+ def show(self):
+ return None
+
+ def setValue(self, _value):
+ return None
+
+ def wasCanceled(self):
+ return False
+
+
+class Windtunnel:
+ """
+ The Windtunnel class is responsible for generating a computational fluid dynamics (CFD) mesh
+ around an airfoil within a wind tunnel. It includes methods for creating different parts of
+ the mesh, such as the airfoil mesh, trailing edge mesh, tunnel mesh, and tunnel wake mesh.
+ Additionally, it provides functionality for mesh quality assessment, drawing the mesh, and
+ exporting the mesh in various formats.
+ """
+
+ def __init__(self):
+
+ # contains list of BlockMesh objects
+ self.blocks = []
+ self.block_airfoil = None
+ self.block_te = None
+ self.block_tunnel = None
+ self.block_tunnel_wake = None
+ self.tunnel_height = None
+ self._block_builder = None
+ self._experimental_c_grid_generator = None
+ self._experimental_o_grid_generator = None
+ self._structured_engine = None
+ self._hybrid_pipeline = None
+ self._scene_renderer = None
+ self.topology = None
+ self.mesh_model = None
+ self.domain_model = None
+ self.quality = None
+ self.quality_report = None
+ self.layout_plan = None
+ self.pipeline_metadata = {}
+ self.hybrid_pipeline_settings = None
+ self.hybrid_stage_state = {
+ 'stage4': False,
+ 'stage2': False,
+ 'stage1': False,
+ }
+ self.boundary_definitions = MeshModel.BoundaryDefinitions()
+ self.mesh_engine = 'metric_based'
+
+ # MainWindow instance
+ self.mw = get_main_window()
+
+ def getBlockBuilder(self):
+ if self._block_builder is None:
+ self._block_builder = MeshBuilders.LegacyBlockMeshBuilder(
+ block_mesh_cls=LegacyBlockMesh,
+ create_smoother=SmootherFactory.create_smoother,
+ )
+ return self._block_builder
+
+ def getExperimentalCGridGenerator(self):
+ if self._experimental_c_grid_generator is None:
+ self._experimental_c_grid_generator = ExperimentalCGridGenerator()
+ return self._experimental_c_grid_generator
+
+ def getExperimentalOGridGenerator(self):
+ if self._experimental_o_grid_generator is None:
+ self._experimental_o_grid_generator = ExperimentalOGridGenerator()
+ return self._experimental_o_grid_generator
+
+ def getStructuredEngine(self):
+ if self._structured_engine is None:
+ self._structured_engine = StructuredEngine()
+ return self._structured_engine
+
+ def getHybridPipeline(self):
+ if self._hybrid_pipeline is None:
+ self._hybrid_pipeline = HybridQuadPipeline()
+ return self._hybrid_pipeline
+
+ def registerBlock(self, attribute_name, block):
+ setattr(self, attribute_name, block)
+ self.blocks.append(block)
+ return block
+
+ def getSceneRenderer(self):
+ if not hasattr(self.mw, 'scene') or not hasattr(self.mw, 'mainArea'):
+ return None
+ if self._scene_renderer is None:
+ self._scene_renderer = MeshGraphics.MeshSceneRenderer(self.mw)
+ return self._scene_renderer
+
+ def _resetHybridPipelineState(self):
+ self.hybrid_pipeline_settings = None
+ self.hybrid_stage_state = {
+ 'stage4': False,
+ 'stage2': False,
+ 'stage1': False,
+ }
+
+ def _createProgressDialog(self, label_text='Meshing in progress',
+ window_title='Generating the CFD mesh'):
+ if isinstance(self.mw, QtWidgets.QWidget) and \
+ QtWidgets.QApplication.instance() is not None:
+ progdialog = QtWidgets.QProgressDialog(
+ label_text,
+ 'Cancel',
+ 0,
+ 100,
+ self.mw,
+ )
+ progdialog.setFixedWidth(300)
+ progdialog.setMinimumDuration(0)
+ progdialog.setWindowTitle(window_title)
+ progdialog.setWindowModality(QtCore.Qt.WindowModal)
+ progdialog.setCancelButtonText('Abort meshing ...')
+ progdialog.show()
+ return progdialog
+ return NullProgressDialog()
+
+ def buildMeshModel(self, airfoil=None):
+ airfoil = airfoil or getattr(self.mw, 'airfoil', None)
+ name = airfoil.name if airfoil else 'wind_tunnel_mesh'
+ if hasattr(self, 'mesh'):
+ self.ensureTopology()
+ return MeshModel.BlockStructuredMesh.from_windtunnel(
+ self,
+ name=name,
+ boundary_conditions=self.getBoundaryDefinitions().as_dict(),
+ )
+
+ def setBoundaryDefinitions(self, boundary_definitions=None, **names):
+ current = self.getBoundaryDefinitions().as_dict()
+ if boundary_definitions is not None:
+ current.update(
+ MeshModel.BoundaryDefinitions.from_mapping(
+ boundary_definitions
+ ).as_dict()
+ )
+
+ for key, value in names.items():
+ if value is not None:
+ current[key] = value
+
+ self.boundary_definitions = MeshModel.BoundaryDefinitions.from_mapping(
+ current
+ )
+ if self.mesh_model is not None:
+ self.mesh_model.boundary_conditions = (
+ self.boundary_definitions.as_dict()
+ )
+ return self.boundary_definitions
+
+ def getBoundaryDefinitions(self):
+ if isinstance(
+ getattr(self, 'boundary_definitions', None),
+ MeshModel.BoundaryDefinitions,
+ ):
+ return self.boundary_definitions
+ self.boundary_definitions = MeshModel.BoundaryDefinitions()
+ return self.boundary_definitions
+
+ def export_mesh(self, mesh_format, name='', boundary_definitions=None,
+ **kwargs):
+ definitions = self.getBoundaryDefinitions()
+ if boundary_definitions is not None:
+ definitions = self.setBoundaryDefinitions(boundary_definitions)
+
+ if self.mesh_model is None:
+ self.mesh_model = self.buildMeshModel(
+ airfoil=getattr(self.mw, 'airfoil', None)
+ )
+ else:
+ self.mesh_model.boundary_conditions = definitions.as_dict()
+
+ MeshModel.MeshExportRegistry.export(
+ self.mesh_model,
+ mesh_format=mesh_format,
+ name=name,
+ boundary_definitions=definitions,
+ **kwargs,
+ )
+ return name
+
+ def buildDomainModel(self, airfoil=None, mesh_model=None):
+ airfoil = airfoil or getattr(self.mw, 'airfoil', None)
+ mesh_model = mesh_model or self.mesh_model or self.buildMeshModel(
+ airfoil=airfoil
+ )
+ return Domain.DomainBuilder.from_mesh(
+ mesh_model,
+ airfoil=airfoil,
+ name=f'{airfoil.name if airfoil else "wind_tunnel"}_domain',
+ metadata={'source': self.__class__.__name__},
+ )
+
+ def setMesh(self, vertices, connectivity):
+ self.mesh = vertices, connectivity
+ self.topology = None
+ self.mesh_model = None
+ self.domain_model = None
+
+ def rebuildTopology(self):
+ vertices, connectivity = self.mesh
+ self.topology = MeshModel.MeshTopology.from_mesh(vertices, connectivity)
+ self.LCV = self.topology.cell_to_vertices
+ self.LCE = self.topology.cell_to_edges
+ self.edges = list(self.topology.edges)
+ self.boundary_edges = list(self.topology.boundary_edges)
+ self.boundary_tags = {
+ tag: list(edges)
+ for tag, edges in self.topology.boundary_tags.items()
+ }
+
+ if self.mesh_model is not None and self.mesh_model.data is not None:
+ self.mesh_model.data.cell_to_vertices = self.LCV
+ self.mesh_model.data.cell_to_edges = self.LCE
+ self.mesh_model.data.boundary_tags = self.boundary_tags
+ self.mesh_model.data.metadata['boundary_edges'] = (
+ list(self.boundary_edges)
+ )
+ return self.topology
+
+ def ensureTopology(self):
+ if self.topology is None:
+ return self.rebuildTopology()
+ return self.topology
+
+ def publishMeshArtifacts(self, airfoil=None):
+ airfoil = airfoil or getattr(self.mw, 'airfoil', None)
+ self.rebuildTopology()
+ self.mesh_model = self.buildMeshModel(airfoil=airfoil)
+ self.domain_model = self.buildDomainModel(
+ airfoil=airfoil,
+ mesh_model=self.mesh_model,
+ )
+ if airfoil is not None:
+ airfoil.mesh_model = self.mesh_model
+ airfoil.domain_model = self.domain_model
+ return self.mesh_model, self.domain_model
+
+ def AirfoilMesh(self, name='', contour=None, divisions=15, ratio=3.0,
+ thickness=0.04):
+ settings = MeshBuilders.AirfoilBlockSettings(
+ name=name,
+ divisions=divisions,
+ growth=ratio,
+ thickness=thickness,
+ )
+ block = self.getBlockBuilder().build_airfoil_block(contour, settings)
+ return self.registerBlock('block_airfoil', block)
+
+ def TrailingEdgeMesh(self, name='', te_divisions=3,
+ thickness=0.04, divisions=10, ratio=1.05):
+ settings = MeshBuilders.TrailingEdgeBlockSettings(
+ name=name,
+ trailing_edge_divisions=te_divisions,
+ thickness=thickness,
+ divisions=divisions,
+ growth=ratio,
+ )
+ block = self.getBlockBuilder().build_trailing_edge_block(
+ self.block_airfoil,
+ has_trailing_edge=getattr(self.mw.airfoil, 'has_TE', False),
+ settings=settings,
+ )
+ return self.registerBlock('block_te', block)
+
+ def TunnelMesh(self, name='', tunnel_height=2.0, divisions_height=100,
+ ratio_height=10.0, dist='symmetric',
+ smoothing_algorithm='simple',
+ smoothing_iterations=10,
+ smoothing_tolerance=1e-3,
+ outer_boundary_slide=0.0,
+ elliptic_relaxation=0.2,
+ protected_guide_relaxation=0.25,
+ protected_guide_layers=5,
+ protected_guide_decay=0.8,
+ protected_guide_smoothing=3):
+ settings = MeshBuilders.TunnelBlockSettings(
+ name=name,
+ tunnel_height=tunnel_height,
+ divisions_height=divisions_height,
+ height_growth=ratio_height,
+ distribution=dist,
+ smoothing_algorithm=smoothing_algorithm,
+ smoothing_iterations=smoothing_iterations,
+ smoothing_tolerance=smoothing_tolerance,
+ outer_boundary_slide=outer_boundary_slide,
+ elliptic_relaxation=elliptic_relaxation,
+ protected_guide_relaxation=protected_guide_relaxation,
+ protected_guide_layers=protected_guide_layers,
+ protected_guide_decay=protected_guide_decay,
+ protected_guide_smoothing=protected_guide_smoothing,
+ )
+ block = self.getBlockBuilder().build_tunnel_block(
+ self.block_airfoil,
+ self.block_te,
+ settings,
+ )
+ if settings.smoothing_algorithm.strip().lower() == 'elliptic':
+ block = self._smoothProtectedTunnelBlock(block, settings)
+ self.tunnel_height = settings.tunnel_height
+ return self.registerBlock('block_tunnel', block)
+
+ @staticmethod
+ def _concatenateInterfaceSegments(*segments):
+ arrays = []
+ for index, segment in enumerate(segments):
+ coordinates = np.asarray(segment, dtype=float)
+ if coordinates.ndim != 2 or coordinates.shape[1] != 2:
+ raise ValueError('Expected interface segments with 2D coordinates.')
+ if index < len(segments) - 1:
+ coordinates = coordinates[:-1]
+ if len(coordinates):
+ arrays.append(coordinates)
+
+ if not arrays:
+ return np.empty((0, 2), dtype=float)
+ return np.vstack(arrays)
+
+ def _protectedTunnelBoundaryGuide(self):
+ if self.block_airfoil is None or self.block_te is None:
+ raise ValueError('Protected tunnel smoothing requires airfoil and trailing edge blocks.')
+
+ airfoil_ulines = self.block_airfoil.getULines()
+ trailing_edge_vlines = self.block_te.getVLines()
+
+ if len(airfoil_ulines) < 2:
+ raise ValueError('Airfoil block must contain at least two u-lines for protected tunnel smoothing.')
+ if len(trailing_edge_vlines) < 3:
+ raise ValueError('Trailing edge block must contain at least three v-lines for protected tunnel smoothing.')
+
+ boundary_line = self._concatenateInterfaceSegments(
+ trailing_edge_vlines[-1][::-1],
+ airfoil_ulines[-1],
+ trailing_edge_vlines[0],
+ )
+ adjacent_line = self._concatenateInterfaceSegments(
+ trailing_edge_vlines[-2][::-1],
+ airfoil_ulines[-2],
+ trailing_edge_vlines[1],
+ )
+
+ if boundary_line.shape != adjacent_line.shape:
+ raise ValueError(
+ 'Protected tunnel interface lines must have matching shapes.'
+ )
+
+ return boundary_line, boundary_line - adjacent_line
+
+ @staticmethod
+ def _smoothInterfaceProfile(values, passes):
+ profile = np.asarray(values, dtype=float)
+ passes = max(0, int(passes))
+ if profile.ndim != 1 or profile.size < 3 or passes == 0:
+ return np.array(profile, copy=True, dtype=float)
+
+ smoothed = np.array(profile, copy=True, dtype=float)
+ for _ in range(passes):
+ updated = np.array(smoothed, copy=True, dtype=float)
+ updated[1:-1] = (
+ 0.25 * smoothed[:-2] +
+ 0.50 * smoothed[1:-1] +
+ 0.25 * smoothed[2:]
+ )
+ smoothed = updated
+
+ return smoothed
+
+ def _protectedTunnelGuideProfile(self, tunnel_block, boundary_line, guide_vectors,
+ settings):
+ normals = EllipticSolver.curveNormals(boundary_line[:, 0], boundary_line[:, 1])
+ normal_strength = np.sum(guide_vectors * normals, axis=1)
+ normal_strength = self._smoothInterfaceProfile(
+ normal_strength,
+ settings.protected_guide_smoothing,
+ )
+ projected_vectors = normal_strength[:, None] * normals
+
+ tunnel_ulines = tunnel_block.getULines()
+ interior_layers = max(0, len(tunnel_ulines) - 2)
+ layer_count = min(
+ max(1, int(settings.protected_guide_layers)),
+ interior_layers,
+ )
+ if layer_count <= 0:
+ return projected_vectors, None
+
+ first_offsets = np.asarray(tunnel_ulines[1], dtype=float) - boundary_line
+ first_normal_strength = np.sum(first_offsets * normals, axis=1)
+ fallback_scale = np.ones_like(first_normal_strength)
+
+ scale_factors = []
+ for layer_index in range(1, layer_count + 1):
+ offsets = np.asarray(tunnel_ulines[layer_index], dtype=float) - boundary_line
+ layer_normal_strength = np.sum(offsets * normals, axis=1)
+ scale = np.divide(
+ layer_normal_strength,
+ first_normal_strength,
+ out=np.array(fallback_scale * float(layer_index), copy=True),
+ where=np.abs(first_normal_strength) > 1.0e-10,
+ )
+ scale = np.maximum(scale, 0.0)
+ scale = self._smoothInterfaceProfile(
+ scale,
+ max(0, settings.protected_guide_smoothing - 1),
+ )
+ scale_factors.append(scale)
+
+ return projected_vectors, np.asarray(scale_factors, dtype=float)
+
+ def _smoothProtectedTunnelBlock(self, tunnel_block, settings):
+ boundary_line, guide_vectors = self._protectedTunnelBoundaryGuide()
+ tunnel_inner_line = np.asarray(tunnel_block.getULines()[0], dtype=float)
+
+ if tunnel_inner_line.shape != boundary_line.shape:
+ raise ValueError(
+ 'Tunnel block inner boundary does not match the protected interface layout.'
+ )
+ if not np.allclose(tunnel_inner_line, boundary_line):
+ raise ValueError(
+ 'Tunnel block inner boundary coordinates do not match the protected interface coordinates.'
+ )
+ projected_vectors, layer_scale_factors = self._protectedTunnelGuideProfile(
+ tunnel_block,
+ boundary_line,
+ guide_vectors,
+ settings,
+ )
+
+ boundary_guides = {
+ 'bottom': BoundaryGuide(
+ target_vectors=projected_vectors,
+ relaxation=settings.protected_guide_relaxation,
+ layers=settings.protected_guide_layers,
+ decay=settings.protected_guide_decay,
+ layer_scale_factors=layer_scale_factors,
+ ),
+ }
+ sliding_boundaries = None
+ if settings.outer_boundary_slide > 0.0:
+ outer_geometry = np.asarray(tunnel_block.getULines()[-1], dtype=float)
+ sliding_boundaries = {
+ 'top': SlidingBoundary(
+ geometry=outer_geometry,
+ relaxation=float(np.clip(settings.outer_boundary_slide, 0.0, 1.0)),
+ control_origins=boundary_line,
+ control_directions=projected_vectors,
+ control_segment='c_arc',
+ ),
+ }
+
+ smoother = SmootherFactory.create_smoother('elliptic')
+ return smoother.smooth(
+ tunnel_block,
+ iterations=settings.smoothing_iterations,
+ tolerance=settings.smoothing_tolerance,
+ boundary_guides=boundary_guides,
+ sliding_boundaries=sliding_boundaries,
+ relaxation=settings.elliptic_relaxation,
+ )
+
+ def TunnelMeshWake(self, name='', tunnel_wake=2.0,
+ divisions=100, ratio=0.1, spread=0.4):
+ settings = MeshBuilders.WakeBlockSettings(
+ name=name,
+ tunnel_wake=tunnel_wake,
+ divisions=divisions,
+ growth=ratio,
+ spread=spread,
+ )
+ block = self.getBlockBuilder().build_wake_block(
+ self.block_tunnel,
+ self.block_te,
+ tunnel_height=self.tunnel_height,
+ settings=settings,
+ )
+ return self.registerBlock('block_tunnel_wake', block)
+
+ def _finalizeMeshGeneration(self, airfoil, progdialog):
+ connect = Connect.Connect(progdialog)
+ vertices, connectivity = connect.connectAllBlocks(self.blocks)
+
+ self.setMesh(vertices, connectivity)
+ self.publishMeshArtifacts(airfoil=airfoil)
+
+ logger.info('Mesh around {} created'.format(airfoil.name))
+ logger.info('Mesh has {} vertices and {} elements'.format(
+ len(vertices),
+ len(connectivity),
+ ))
+
+ self.drawMesh(airfoil)
+ self.drawBlockOutline(airfoil)
+
+ progdialog.setValue(100)
+ return True
+
+ def _publishPipelineMetadata(self):
+ if self.mesh_model is None or self.mesh_model.data is None:
+ return
+
+ metadata = self.mesh_model.data.metadata
+ if self.layout_plan is not None:
+ metadata['layout_plan'] = {
+ 'boundary_point_count': int(len(self.layout_plan.boundary_points)),
+ 'singularity_count': int(len(self.layout_plan.singularities)),
+ 'metadata': dict(self.layout_plan.metadata),
+ }
+ if self.pipeline_metadata:
+ metadata['quad_pipeline'] = dict(self.pipeline_metadata)
+
+ def _hybridStageRequirement(self, airfoil=None):
+ airfoil = airfoil or getattr(self.mw, 'airfoil', None)
+ if airfoil is None:
+ raise ValueError('No airfoil loaded.')
+ if not airfoil.has_spline:
+ raise ValueError('The contour needs to be prepared first.')
+ if self.layout_plan is None or not self.blocks:
+ raise ValueError('Please run Stage 4 first.')
+ if self.mesh_engine != 'hybrid_staged':
+ raise ValueError('Please run Stage 4 with the staged hybrid engine first.')
+ return airfoil
+
+ def _finalizeHybridPipelineStep(self, airfoil, progdialog, settings, *,
+ stage2_metadata=None,
+ stage1_metadata=None):
+ self.pipeline_metadata = self.getHybridPipeline().build_metadata(
+ self.layout_plan,
+ settings,
+ engine='hybrid_staged',
+ stage2_metadata=stage2_metadata,
+ stage1_metadata=stage1_metadata,
+ )
+ if not self._finalizeMeshGeneration(airfoil, progdialog):
+ return False
+ self.MeshQuality(crit='k2inf')
+ self._publishPipelineMetadata()
+ return True
+
+ def _buildStandardBlocks(self, settings: WindtunnelMeshSettings, contour,
+ progdialog):
+ self.AirfoilMesh(
+ name=settings.airfoil.name,
+ contour=contour,
+ divisions=settings.airfoil.divisions,
+ ratio=settings.airfoil.growth,
+ thickness=settings.airfoil.thickness,
+ )
+ progdialog.setValue(20)
+
+ if progdialog.wasCanceled():
+ return False
+
+ self.TrailingEdgeMesh(
+ name=settings.trailing_edge.name,
+ te_divisions=settings.trailing_edge.trailing_edge_divisions,
+ thickness=settings.trailing_edge.thickness,
+ divisions=settings.trailing_edge.divisions,
+ ratio=settings.trailing_edge.growth,
+ )
+ progdialog.setValue(30)
+
+ if progdialog.wasCanceled():
+ return False
+
+ self.TunnelMesh(
+ name=settings.tunnel.name,
+ tunnel_height=settings.tunnel.tunnel_height,
+ divisions_height=settings.tunnel.divisions_height,
+ ratio_height=settings.tunnel.height_growth,
+ dist=settings.tunnel.distribution,
+ smoothing_algorithm=settings.tunnel.smoothing_algorithm,
+ smoothing_iterations=settings.tunnel.smoothing_iterations,
+ smoothing_tolerance=settings.tunnel.smoothing_tolerance,
+ outer_boundary_slide=settings.tunnel.outer_boundary_slide,
+ elliptic_relaxation=settings.tunnel.elliptic_relaxation,
+ protected_guide_relaxation=settings.tunnel.protected_guide_relaxation,
+ protected_guide_layers=settings.tunnel.protected_guide_layers,
+ protected_guide_decay=settings.tunnel.protected_guide_decay,
+ protected_guide_smoothing=settings.tunnel.protected_guide_smoothing,
+ )
+ progdialog.setValue(50)
+
+ if progdialog.wasCanceled():
+ return False
+
+ self.TunnelMeshWake(
+ name=settings.wake.name,
+ tunnel_wake=settings.wake.tunnel_wake,
+ divisions=settings.wake.divisions,
+ ratio=settings.wake.growth,
+ spread=settings.wake.spread,
+ )
+ progdialog.setValue(70)
+
+ if progdialog.wasCanceled():
+ return False
+
+ return True
+
+ def _makeHybridMesh(self, settings: WindtunnelMeshSettings, airfoil,
+ progdialog):
+ contour = airfoil.spline_data.coordinates
+ result = self.getHybridPipeline().run(
+ contour=contour,
+ settings=settings.hybrid,
+ airfoil_settings=settings.airfoil,
+ tunnel_settings=settings.tunnel,
+ wake_settings=settings.wake,
+ trailing_edge_settings=settings.trailing_edge,
+ contour_metadata=getattr(airfoil.spline_data, 'metadata', None),
+ )
+ self.blocks = result.blocks
+ self.layout_plan = result.layout_plan
+ self.pipeline_metadata = result.metadata
+ progdialog.setValue(70)
+
+ if progdialog.wasCanceled():
+ return False
+
+ if not self._finalizeMeshGeneration(airfoil, progdialog):
+ return False
+
+ self.MeshQuality(crit='k2inf')
+ self._publishPipelineMetadata()
+ return True
+
+ def _makeMetricMesh(self, settings: WindtunnelMeshSettings, airfoil,
+ progdialog):
+ contour = airfoil.spline_data.coordinates
+ metric_settings = settings.metric_based
+ result = self.getHybridPipeline().run_stage4(
+ contour=contour,
+ settings=metric_settings,
+ airfoil_settings=settings.airfoil,
+ tunnel_settings=settings.tunnel,
+ wake_settings=settings.wake,
+ trailing_edge_settings=settings.trailing_edge,
+ contour_metadata=getattr(airfoil.spline_data, 'metadata', None),
+ engine='metric_based',
+ )
+ self.blocks = result.blocks
+ self.layout_plan = result.layout_plan
+ self.pipeline_metadata = result.metadata
+ self.hybrid_pipeline_settings = metric_settings
+ self.hybrid_stage_state = {
+ 'stage4': True,
+ 'stage2': False,
+ 'stage1': False,
+ }
+ progdialog.setValue(70)
+
+ if progdialog.wasCanceled():
+ return False
+
+ if not self._finalizeMeshGeneration(airfoil, progdialog):
+ return False
+
+ self.MeshQuality(crit='k2inf')
+ self._publishPipelineMetadata()
+ return True
+
+ def _makeHybridStage4Mesh(self, settings: WindtunnelMeshSettings, airfoil,
+ progdialog):
+ contour = airfoil.spline_data.coordinates
+ staged_settings = settings.hybrid_staged
+ result = self.getHybridPipeline().run_stage4(
+ contour=contour,
+ settings=staged_settings,
+ airfoil_settings=settings.airfoil,
+ tunnel_settings=settings.tunnel,
+ wake_settings=settings.wake,
+ trailing_edge_settings=settings.trailing_edge,
+ contour_metadata=getattr(airfoil.spline_data, 'metadata', None),
+ engine='hybrid_staged',
+ )
+ self.blocks = result.blocks
+ self.layout_plan = result.layout_plan
+ self.pipeline_metadata = result.metadata
+ self.hybrid_pipeline_settings = staged_settings
+ self.hybrid_stage_state = {
+ 'stage4': True,
+ 'stage2': False,
+ 'stage1': False,
+ }
+ progdialog.setValue(70)
+
+ if progdialog.wasCanceled():
+ return False
+
+ if not self._finalizeMeshGeneration(airfoil, progdialog):
+ return False
+
+ self.MeshQuality(crit='k2inf')
+ self._publishPipelineMetadata()
+ return True
+
+ def _makeExperimentalMesh(self, settings: WindtunnelMeshSettings,
+ airfoil, progdialog):
+ if self.mesh_engine == 'experimental_o':
+ generator = self.getExperimentalOGridGenerator()
+ blocks = generator.build_blocks(
+ contour=airfoil.spline_data.coordinates,
+ radius=settings.tunnel.tunnel_height,
+ wake_length=settings.wake.tunnel_wake,
+ settings=settings.experimental_o,
+ trailing_edge_settings=settings.trailing_edge,
+ )
+ for attribute_name, block in zip(
+ (
+ 'block_experimental_o_grid_top',
+ 'block_experimental_o_grid_left',
+ 'block_experimental_o_grid_bottom',
+ 'block_experimental_o_grid_right',
+ ),
+ blocks):
+ self.registerBlock(attribute_name, block)
+ else:
+ generator = self.getExperimentalCGridGenerator()
+ if getattr(airfoil, 'has_TE', False):
+ blocks = generator.build_blunt_blocks(
+ contour=airfoil.spline_data.coordinates,
+ radius=settings.tunnel.tunnel_height,
+ wake_length=settings.wake.tunnel_wake,
+ settings=settings.experimental,
+ trailing_edge_settings=settings.trailing_edge,
+ )
+ for attribute_name, block in zip(
+ (
+ 'block_experimental_te_patch',
+ 'block_experimental_wake_bridge',
+ 'block_experimental_c_grid',
+ ),
+ blocks):
+ self.registerBlock(attribute_name, block)
+ else:
+ block = generator.build_block(
+ contour=airfoil.spline_data.coordinates,
+ radius=settings.tunnel.tunnel_height,
+ wake_length=settings.wake.tunnel_wake,
+ settings=settings.experimental,
+ )
+ self.registerBlock('block_experimental_c_grid', block)
+ self.tunnel_height = settings.tunnel.tunnel_height
+
+ progdialog.setValue(70)
+ if progdialog.wasCanceled():
+ return False
+
+ return self._finalizeMeshGeneration(airfoil, progdialog)
+
+ def _makeStructuredMesh(self, settings: WindtunnelMeshSettings,
+ airfoil, progdialog):
+ engine = self.getStructuredEngine()
+ named_blocks = engine.build_blocks(
+ spline_data=airfoil.spline_data,
+ settings=settings.structured,
+ )
+ for attribute_name, block in named_blocks:
+ self.registerBlock(attribute_name, block)
+ self.tunnel_height = settings.structured.tunnel_height
+
+ progdialog.setValue(70)
+ if progdialog.wasCanceled():
+ return False
+
+ if not self._finalizeMeshGeneration(airfoil, progdialog):
+ return False
+ self.MeshQuality(crit='k2inf')
+ return True
+
+ def makeMesh(self, settings: WindtunnelMeshSettings, airfoil=None):
+
+ airfoil = airfoil or getattr(self.mw, 'airfoil', None)
+ if airfoil is None:
+ raise ValueError('No airfoil loaded.')
+ if not airfoil.has_spline:
+ raise ValueError('The contour needs to be prepared first.')
+
+ self.blocks = []
+ self.block_airfoil = None
+ self.block_te = None
+ self.block_tunnel = None
+ self.block_tunnel_wake = None
+ self.tunnel_height = None
+ self.layout_plan = None
+ self.pipeline_metadata = {}
+ self._resetHybridPipelineState()
+ self.quality = None
+ self.quality_report = None
+ self.mesh_engine = str(settings.engine).strip().lower() or 'metric_based'
+ if self.mesh_engine == 'experimental':
+ self.mesh_engine = 'experimental_c'
+
+ contour = airfoil.spline_data.coordinates
+
+ # delete blocks outline if existing
+ # because a new one will be generated
+ if getattr(airfoil, 'mesh_blocks', None) is not None and \
+ hasattr(self.mw, 'scene'):
+ self.mw.scene.removeItem(airfoil.mesh_blocks)
+ airfoil.mesh_blocks = None
+
+ progdialog = self._createProgressDialog()
+
+ progdialog.setValue(10)
+ # progdialog.setLabelText('making blocks')
+
+ if self.mesh_engine in ('experimental_c', 'experimental_o'):
+ return self._makeExperimentalMesh(settings, airfoil, progdialog)
+ if self.mesh_engine == 'structured':
+ return self._makeStructuredMesh(settings, airfoil, progdialog)
+ if self.mesh_engine == 'metric_based':
+ return self._makeMetricMesh(settings, airfoil, progdialog)
+ if self.mesh_engine == 'hybrid':
+ return self._makeHybridMesh(settings, airfoil, progdialog)
+ if self.mesh_engine == 'hybrid_staged':
+ return self._makeHybridStage4Mesh(settings, airfoil, progdialog)
+
+ if not self._buildStandardBlocks(settings, contour, progdialog):
+ return False
+
+ return self._finalizeMeshGeneration(airfoil, progdialog)
+
+ def makeHybridStage4Mesh(self, settings: WindtunnelMeshSettings,
+ airfoil=None):
+ return self.makeMesh(
+ replace(settings, engine='hybrid_staged'),
+ airfoil=airfoil,
+ )
+
+ def applyHybridStage2(self, settings: WindtunnelMeshSettings, airfoil=None):
+ airfoil = self._hybridStageRequirement(airfoil=airfoil)
+ staged_settings = settings.hybrid_staged
+ self.hybrid_pipeline_settings = staged_settings
+ self.mesh_engine = 'hybrid_staged'
+
+ progdialog = self._createProgressDialog(
+ label_text='Applying Stage 2 redistribution',
+ window_title='Applying Hybrid Stage 2',
+ )
+ progdialog.setValue(20)
+
+ pipeline = self.getHybridPipeline()
+ stage2_metadata = pipeline.apply_stage2(
+ self.blocks,
+ staged_settings.stage2,
+ protect_near_wall=staged_settings.protect_near_wall,
+ )
+ stage1_metadata = pipeline.default_stage1_metadata(
+ staged_settings.stage1,
+ protect_near_wall=staged_settings.protect_near_wall,
+ )
+ self.hybrid_stage_state = {
+ 'stage4': True,
+ 'stage2': bool(stage2_metadata.get('applied', False)),
+ 'stage1': False,
+ }
+
+ progdialog.setValue(70)
+ if progdialog.wasCanceled():
+ return False
+
+ return self._finalizeHybridPipelineStep(
+ airfoil,
+ progdialog,
+ staged_settings,
+ stage2_metadata=stage2_metadata,
+ stage1_metadata=stage1_metadata,
+ )
+
+ def applyHybridStage1(self, settings: WindtunnelMeshSettings, airfoil=None):
+ airfoil = self._hybridStageRequirement(airfoil=airfoil)
+ staged_settings = settings.hybrid_staged
+ self.hybrid_pipeline_settings = staged_settings
+ self.mesh_engine = 'hybrid_staged'
+
+ progdialog = self._createProgressDialog(
+ label_text='Applying Stage 1 cleanup',
+ window_title='Applying Hybrid Stage 1',
+ )
+ progdialog.setValue(20)
+
+ pipeline = self.getHybridPipeline()
+ existing_stage2_metadata = dict(
+ self.pipeline_metadata.get(
+ 'stage2',
+ pipeline.default_stage2_metadata(
+ staged_settings.stage2,
+ protect_near_wall=staged_settings.protect_near_wall,
+ ),
+ )
+ )
+ stage1_metadata = pipeline.apply_stage1(
+ self.blocks,
+ staged_settings.stage1,
+ protect_near_wall=staged_settings.protect_near_wall,
+ )
+ self.hybrid_stage_state = {
+ 'stage4': True,
+ 'stage2': bool(existing_stage2_metadata.get('applied', False)),
+ 'stage1': bool(stage1_metadata.get('applied', False)),
+ }
+
+ progdialog.setValue(70)
+ if progdialog.wasCanceled():
+ return False
+
+ return self._finalizeHybridPipelineStep(
+ airfoil,
+ progdialog,
+ staged_settings,
+ stage2_metadata=existing_stage2_metadata,
+ stage1_metadata=stage1_metadata,
+ )
+
+ def makeLCV(self):
+ """Make cell to vertex connectivity for the mesh
+ LCV is identical to connectivity
+ """
+ self.ensureTopology()
+ return self.LCV
+
+ def makeLVC(self):
+ _, connectivity = self.mesh
+ nodes = list(set([node for cell in connectivity for node in cell]))
+ self.lvc = dict()
+ for node in nodes:
+ for cell in connectivity:
+ if node in cell:
+ self.lvc.setdefault(node, []).append(cell.tolist())
+
+ def makeLCE(self):
+ """Make cell to edge connectivity for the mesh"""
+ self.ensureTopology()
+ return self.LCE
+
+ def makeLCC(self):
+ """Make cell to cell connectivity for the mesh"""
+ pass
+
+ def makeBoundaries(self):
+ """A boundary edge is an edge that belongs only to one cell"""
+ self.ensureTopology()
+ return self.boundary_tags
+
+ def drawMesh(self, airfoil):
+ renderer = self.getSceneRenderer()
+ if renderer is None:
+ return None
+ return renderer.render_mesh(airfoil, self.blocks)
+
+ def drawMeshQuality(self, quality):
+ renderer = self.getSceneRenderer()
+ if renderer is None:
+ return None
+ vertices, connectivity = self.mesh
+ airfoil = getattr(self.mw, 'airfoil', None)
+ return renderer.render_mesh_quality(
+ vertices,
+ connectivity,
+ quality,
+ airfoil=airfoil,
+ )
+
+ def drawBlockOutline(self, airfoil):
+ renderer = self.getSceneRenderer()
+ if renderer is None:
+ return None
+ return renderer.render_block_outline(
+ airfoil,
+ self.blocks,
+ layout_plan=self.layout_plan,
+ )
+
+ def MeshQuality(self, crit='k2inf'):
+ vertices, connectivity = self.mesh
+ self.quality_report = QuadQualityEvaluator.evaluate(
+ vertices,
+ connectivity,
+ criterion=crit,
+ )
+ self.quality = self.quality_report.values
+ if self.mesh_model is not None and self.mesh_model.data is not None:
+ self.mesh_model.data.quality = self.quality
+ return self.quality
+
+
+class BlockMesh(LegacyBlockMesh):
+ """Backward-compatible export shim for legacy callers."""
+
+ @staticmethod
+ def writeFLMA(wind_tunnel, name='', depth=0.3):
+ return wind_tunnel.export_mesh('flma', name=name, depth=depth)
+
+ @staticmethod
+ def writeSU2(wind_tunnel, name=''):
+ return wind_tunnel.export_mesh('su2', name=name)
+
+ @staticmethod
+ def writeVTU(wind_tunnel, name=''):
+ return wind_tunnel.export_mesh('vtu', name=name)
+
+ @staticmethod
+ def writeGMSH(wind_tunnel, name=''):
+ return wind_tunnel.export_mesh('gmsh', name=name)
+
+ writeVTK = writeVTU
+
+
+Smooth = LegacySmooth
diff --git a/src/MetricTriangulation.py b/src/MetricTriangulation.py
new file mode 100644
index 0000000..68d5280
--- /dev/null
+++ b/src/MetricTriangulation.py
@@ -0,0 +1,1000 @@
+from __future__ import annotations
+
+import configparser
+from dataclasses import dataclass, field
+from pathlib import Path
+from typing import Any, Iterable
+
+import numpy as np
+
+import Domain
+import Mesh
+from Shape import Arc, Circle, Line, Polygon, Polyline, Rectangle
+import Settings
+
+try:
+ from scipy.spatial import Delaunay, QhullError
+except ImportError: # pragma: no cover - guarded at runtime in the UI.
+ Delaunay = None
+ QhullError = RuntimeError
+
+
+PointArray = np.ndarray
+BOUNDARY_RECOVERY_SWEEP_LIMIT = 3
+
+
+@dataclass(slots=True)
+class MetricTriangulationSettings:
+ example: str = 'rectangle'
+ width: float = 4.0
+ height: float = 2.0
+ hole_radius: float = 0.35
+ hole_spacing: float = 1.40
+ outer_resolution: int = 72
+ hole_resolution: int = 44
+ interior_x: int = 32
+ interior_y: int = 16
+ qhull_options: str = 'Qbb Qc Q12 QJ'
+ airfoil_path: str | None = None
+
+ def __post_init__(self):
+ self.example = str(self.example).strip().lower() or 'rectangle'
+ if self.example not in MetricExampleFactory.available_examples():
+ raise ValueError(f'Unknown metric test example: {self.example}')
+
+ self.width = float(self.width)
+ self.height = float(self.height)
+ self.hole_radius = float(self.hole_radius)
+ self.hole_spacing = float(self.hole_spacing)
+ self.outer_resolution = max(8, int(self.outer_resolution))
+ self.hole_resolution = max(12, int(self.hole_resolution))
+ self.interior_x = max(0, int(self.interior_x))
+ self.interior_y = max(0, int(self.interior_y))
+
+ if self.width <= 0.0 or self.height <= 0.0:
+ raise ValueError('Rectangle width and height must be positive.')
+ if self.hole_radius < 0.0:
+ raise ValueError('Hole radius must be non-negative.')
+ if self.hole_spacing < 0.0:
+ raise ValueError('Hole spacing must be non-negative.')
+
+
+@dataclass(slots=True)
+class ConstraintLoop:
+ tag: str
+ points: PointArray
+ is_hole: bool = False
+ metadata: dict[str, Any] = field(default_factory=dict)
+
+ def copy(self) -> 'ConstraintLoop':
+ return ConstraintLoop(
+ tag=self.tag,
+ points=np.array(self.points, dtype=float, copy=True),
+ is_hole=self.is_hole,
+ metadata=dict(self.metadata),
+ )
+
+
+@dataclass(slots=True)
+class MetricTriangulationResult:
+ name: str
+ domain: Domain.Domain
+ loops: list[ConstraintLoop]
+ mesh: Mesh.TriangularMesh
+ warnings: list[str] = field(default_factory=list)
+ metadata: dict[str, Any] = field(default_factory=dict)
+
+ @property
+ def mesh_data(self) -> Mesh.MeshData:
+ return self.mesh.data
+
+
+class _ConstrainedTriangulation:
+ def __init__(self, vertices: PointArray, triangles: np.ndarray):
+ self.vertices = np.asarray(vertices, dtype=float)
+ self.triangles = [
+ self._normalize_triangle(np.asarray(triangle, dtype=int))
+ for triangle in np.asarray(triangles, dtype=int)
+ ]
+ self.edge_to_triangles: dict[tuple[int, int], list[int]] = {}
+ self._rebuild_topology()
+
+ def _rebuild_topology(self):
+ edge_to_triangles: dict[tuple[int, int], list[int]] = {}
+ for triangle_index, triangle in enumerate(self.triangles):
+ for index in range(3):
+ edge = MetricTriangulator._sorted_edge((
+ int(triangle[index]),
+ int(triangle[(index + 1) % 3]),
+ ))
+ edge_to_triangles.setdefault(edge, []).append(triangle_index)
+ self.edge_to_triangles = edge_to_triangles
+
+ def has_edge(self, edge: tuple[int, int]) -> bool:
+ return MetricTriangulator._sorted_edge(edge) in self.edge_to_triangles
+
+ def recover_edge(self, edge: tuple[int, int],
+ constrained_edges: set[tuple[int, int]],
+ max_iterations: int | None = None) -> bool:
+ target = MetricTriangulator._sorted_edge(edge)
+ if self.has_edge(target):
+ constrained_edges.add(target)
+ return True
+
+ if max_iterations is None:
+ max_iterations = max(20, 8 * len(self.triangles))
+
+ for _ in range(max_iterations):
+ if self.has_edge(target):
+ constrained_edges.add(target)
+ return True
+
+ candidates = self._intersecting_edges(target, constrained_edges)
+ if not candidates:
+ return False
+
+ flipped = False
+ for candidate in candidates:
+ if self._flip_edge(candidate, constrained_edges):
+ flipped = True
+ break
+ if not flipped:
+ return False
+
+ return self.has_edge(target)
+
+ def filtered_triangles(self, mask: np.ndarray) -> np.ndarray:
+ kept = [
+ np.asarray(triangle, dtype=int)
+ for triangle, keep in zip(self.triangles, mask)
+ if bool(keep)
+ ]
+ if not kept:
+ raise ValueError('No triangles remain after applying the domain filters.')
+ return np.asarray(kept, dtype=int)
+
+ def _intersecting_edges(self, edge: tuple[int, int],
+ constrained_edges: set[tuple[int, int]]
+ ) -> list[tuple[int, int]]:
+ start = self.vertices[edge[0]]
+ end = self.vertices[edge[1]]
+ xmin = min(float(start[0]), float(end[0]))
+ xmax = max(float(start[0]), float(end[0]))
+ ymin = min(float(start[1]), float(end[1]))
+ ymax = max(float(start[1]), float(end[1]))
+ candidates = []
+
+ for existing_edge in self.edge_to_triangles:
+ if existing_edge == edge:
+ continue
+ if edge[0] in existing_edge or edge[1] in existing_edge:
+ continue
+
+ boundary_start = self.vertices[existing_edge[0]]
+ boundary_end = self.vertices[existing_edge[1]]
+ if (
+ max(float(boundary_start[0]), float(boundary_end[0])) < xmin or
+ min(float(boundary_start[0]), float(boundary_end[0])) > xmax or
+ max(float(boundary_start[1]), float(boundary_end[1])) < ymin or
+ min(float(boundary_start[1]), float(boundary_end[1])) > ymax):
+ continue
+
+ if not MetricTriangulator._segments_cross_proper(
+ start, end, boundary_start, boundary_end):
+ continue
+
+ if existing_edge in constrained_edges:
+ return []
+ candidates.append(existing_edge)
+
+ return candidates
+
+ def _flip_edge(self, edge: tuple[int, int],
+ constrained_edges: set[tuple[int, int]]) -> bool:
+ edge = MetricTriangulator._sorted_edge(edge)
+ if edge in constrained_edges:
+ return False
+
+ adjacent = self.edge_to_triangles.get(edge, [])
+ if len(adjacent) != 2:
+ return False
+
+ triangle_a = self.triangles[adjacent[0]]
+ triangle_b = self.triangles[adjacent[1]]
+ opposite_a = self._opposite_vertex(triangle_a, edge)
+ opposite_b = self._opposite_vertex(triangle_b, edge)
+ if opposite_a is None or opposite_b is None or opposite_a == opposite_b:
+ return False
+
+ new_edge = MetricTriangulator._sorted_edge((opposite_a, opposite_b))
+ if new_edge in constrained_edges:
+ return False
+
+ a_point = self.vertices[edge[0]]
+ b_point = self.vertices[edge[1]]
+ c_point = self.vertices[opposite_a]
+ d_point = self.vertices[opposite_b]
+ if not MetricTriangulator._segments_cross_proper(
+ a_point, b_point, c_point, d_point):
+ return False
+
+ new_triangle_a = self._normalize_triangle(
+ np.asarray((opposite_a, opposite_b, edge[0]), dtype=int)
+ )
+ new_triangle_b = self._normalize_triangle(
+ np.asarray((opposite_b, opposite_a, edge[1]), dtype=int)
+ )
+
+ if (
+ abs(MetricTriangulator._triangle_area(
+ self.vertices[new_triangle_a])) <= 1.0e-12 or
+ abs(MetricTriangulator._triangle_area(
+ self.vertices[new_triangle_b])) <= 1.0e-12):
+ return False
+
+ self.triangles[adjacent[0]] = new_triangle_a
+ self.triangles[adjacent[1]] = new_triangle_b
+ self._rebuild_topology()
+ return True
+
+ def _normalize_triangle(self, triangle: np.ndarray) -> np.ndarray:
+ if MetricTriangulator._triangle_area(self.vertices[triangle]) < 0.0:
+ return np.asarray((triangle[0], triangle[2], triangle[1]), dtype=int)
+ return np.asarray(triangle, dtype=int)
+
+ @staticmethod
+ def _opposite_vertex(triangle: np.ndarray, edge: tuple[int, int]) -> int | None:
+ for vertex in triangle:
+ if int(vertex) not in edge:
+ return int(vertex)
+ return None
+
+
+class MetricExampleFactory:
+ _AVAILABLE = (
+ 'rectangle',
+ 'rectangle_circle',
+ 'rectangle_two_circles',
+ 'default_airfoil_tunnel',
+ )
+
+ @classmethod
+ def available_examples(cls) -> tuple[str, ...]:
+ return cls._AVAILABLE
+
+ @classmethod
+ def build_domain(cls, settings: MetricTriangulationSettings) -> Domain.Domain:
+ if settings.example == 'default_airfoil_tunnel':
+ outer = cls._build_wind_tunnel_outer_boundary(settings)
+ else:
+ outer = Rectangle(
+ width=settings.width,
+ height=settings.height,
+ center=(0.0, 0.0),
+ name='outer_box',
+ )
+
+ holes = []
+ if settings.example == 'rectangle_circle':
+ holes.append(
+ Circle(center=(0.0, 0.0), radius=settings.hole_radius, name='hole_1')
+ )
+ elif settings.example == 'rectangle_two_circles':
+ half_spacing = 0.5 * settings.hole_spacing
+ holes.extend([
+ Circle(
+ center=(-half_spacing, 0.0),
+ radius=settings.hole_radius,
+ name='hole_1',
+ ),
+ Circle(
+ center=(half_spacing, 0.0),
+ radius=settings.hole_radius,
+ name='hole_2',
+ ),
+ ])
+ elif settings.example == 'default_airfoil_tunnel':
+ holes.append(
+ cls._load_default_airfoil_shape(settings)
+ )
+
+ cls._validate_holes(settings, outer, holes)
+ return Domain.Domain(
+ name=f'metric_test_{settings.example}',
+ outer_boundary=outer,
+ inner_shapes=holes,
+ metadata={
+ 'source': 'metric_tests',
+ 'example': settings.example,
+ },
+ )
+
+ @staticmethod
+ def _validate_holes(settings: MetricTriangulationSettings,
+ outer,
+ holes: Iterable):
+ xmin_outer, xmax_outer, ymin_outer, ymax_outer = outer.bounds()
+ radius = settings.hole_radius
+ tolerance = 1.0e-9
+
+ for hole in holes:
+ if isinstance(hole, Circle):
+ center_x, center_y = hole.center
+ if center_x - radius <= xmin_outer + tolerance or center_x + radius >= xmax_outer - tolerance:
+ raise ValueError(
+ 'Hole radius / spacing places a circle outside the outer boundary width.'
+ )
+ if center_y - radius <= ymin_outer + tolerance or center_y + radius >= ymax_outer - tolerance:
+ raise ValueError(
+ 'Hole radius places a circle outside the outer boundary height.'
+ )
+ continue
+
+ xmin, xmax, ymin, ymax = hole.bounds()
+ if xmin <= xmin_outer + tolerance or xmax >= xmax_outer - tolerance:
+ raise ValueError('Airfoil hole exceeds the outer boundary width.')
+ if ymin <= ymin_outer + tolerance or ymax >= ymax_outer - tolerance:
+ raise ValueError('Airfoil hole exceeds the outer boundary height.')
+
+ @classmethod
+ def _load_default_airfoil_shape(cls,
+ settings: MetricTriangulationSettings
+ ) -> Domain.BoundaryLoop:
+ airfoil_path = cls._resolve_airfoil_path(settings.airfoil_path)
+ coordinates = cls._read_airfoil_coordinates(airfoil_path)
+ return Domain.BoundaryLoop(
+ name='default_airfoil',
+ segments=[Polygon(coordinates, name='default_airfoil_contour')],
+ closed=True,
+ metadata={
+ 'role': 'airfoil',
+ 'use_source_vertices': True,
+ },
+ )
+
+ @staticmethod
+ def _build_wind_tunnel_outer_boundary(
+ settings: MetricTriangulationSettings) -> Domain.BoundaryLoop:
+ radius = 0.5 * float(settings.height)
+ inlet_center_x = 0.5
+ outlet_x = inlet_center_x + float(settings.width) - radius
+ if outlet_x <= inlet_center_x + 1.0e-9:
+ raise ValueError(
+ 'Wind-tunnel width must be larger than half the tunnel height.'
+ )
+
+ return Domain.BoundaryLoop(
+ name='outer_wind_tunnel',
+ segments=[
+ Line(
+ p1=(outlet_x, radius),
+ p2=(inlet_center_x, radius),
+ name='top',
+ ),
+ Arc(
+ center=(inlet_center_x, 0.0),
+ radius=radius,
+ start_angle=0.5 * np.pi,
+ end_angle=1.5 * np.pi,
+ name='inlet',
+ ),
+ Line(
+ p1=(inlet_center_x, -radius),
+ p2=(outlet_x, -radius),
+ name='bottom',
+ ),
+ Line(
+ p1=(outlet_x, -radius),
+ p2=(outlet_x, radius),
+ name='outlet',
+ ),
+ ],
+ closed=True,
+ metadata={
+ 'shape': 'wind_tunnel',
+ 'role': 'outer',
+ },
+ )
+
+ @staticmethod
+ def _resolve_airfoil_path(configured_path: str | None) -> Path:
+ if configured_path:
+ path = Path(configured_path)
+ if not path.is_absolute():
+ path = Settings.ROOT / path
+ return path.resolve()
+
+ parser = configparser.ConfigParser(
+ interpolation=configparser.ExtendedInterpolation()
+ )
+ parser.optionxform = str
+ parser.read(Settings.CONFIG_FILE, encoding='utf-8')
+ default_path = parser.get('Application', 'DEFAULT_AIRFOIL')
+ return (Settings.ROOT / default_path).resolve()
+
+ @staticmethod
+ def _read_airfoil_coordinates(path: Path) -> list[tuple[float, float]]:
+ if not path.exists():
+ raise ValueError(f'Default airfoil file does not exist: {path}')
+
+ points = []
+ with path.open('r', encoding='utf-8') as handle:
+ for raw_line in handle:
+ line = raw_line.strip()
+ if not line or line.startswith('#'):
+ continue
+ parts = line.split()
+ if len(parts) < 2:
+ continue
+ try:
+ x_value = float(parts[0])
+ y_value = float(parts[1])
+ except ValueError:
+ continue
+ points.append((x_value, y_value))
+
+ if len(points) < 3:
+ raise ValueError(f'Airfoil file does not contain enough coordinates: {path}')
+
+ coordinates = np.asarray(points, dtype=float)
+ coordinates[:, 0] -= np.min(coordinates[:, 0])
+ scale = np.max(coordinates[:, 0])
+ if scale <= 1.0e-12:
+ raise ValueError(f'Airfoil file has invalid chord length: {path}')
+ coordinates[:, 0] /= scale
+ coordinates[:, 1] /= scale
+
+ if np.linalg.norm(coordinates[0] - coordinates[-1]) > 1.0e-12:
+ coordinates = np.vstack([coordinates, coordinates[0]])
+ return [
+ (float(point[0]), float(point[1]))
+ for point in coordinates[:-1]
+ ]
+
+
+class MetricTriangulator:
+ @classmethod
+ def generate(cls, settings: MetricTriangulationSettings) -> MetricTriangulationResult:
+ if Delaunay is None:
+ raise ValueError(
+ 'SciPy is required for the metric triangulation playground.'
+ )
+
+ domain = MetricExampleFactory.build_domain(settings)
+ loops = cls._sample_loops(domain, settings)
+ warnings: list[str] = []
+ vertices, boundary_tags, loop_vertex_ids = cls._assemble_vertices(
+ loops,
+ settings,
+ )
+ simplices = cls._delaunay(vertices, settings.qhull_options)
+ triangulation = _ConstrainedTriangulation(vertices, simplices)
+
+ constrained_edges: set[tuple[int, int]] = set()
+ pending_edges = [
+ cls._sorted_edge(edge)
+ for edges in boundary_tags.values()
+ for edge in edges
+ ]
+ recovery_sweeps_used = 0
+
+ for sweep in range(BOUNDARY_RECOVERY_SWEEP_LIMIT):
+ failed_edges = []
+ for edge in pending_edges:
+ if triangulation.recover_edge(edge, constrained_edges):
+ continue
+ failed_edges.append(edge)
+ recovery_sweeps_used = sweep + 1
+ pending_edges = failed_edges
+ if not pending_edges:
+ break
+
+ if pending_edges:
+ warnings.append(
+ f'{len(pending_edges)} constraint segments could not be recovered directly.'
+ )
+
+ triangles = cls._filter_triangles(
+ vertices,
+ triangulation,
+ loop_vertex_ids,
+ )
+
+ mesh_edge_set = cls._edge_set(triangles)
+ boundary_tags, missing_boundary_edges = cls._existing_boundary_tags(
+ boundary_tags,
+ mesh_edge_set,
+ )
+ if missing_boundary_edges:
+ warnings.append(
+ f'{missing_boundary_edges} constraint edges are not present in '
+ 'the filtered triangulation.'
+ )
+
+ mesh_data = Mesh.MeshData(
+ vertices=vertices,
+ connectivity=triangles,
+ boundary_tags=boundary_tags,
+ metadata={
+ 'source': 'MetricTriangulator',
+ 'example': settings.example,
+ 'recovery_sweeps_used': recovery_sweeps_used,
+ 'warnings': list(warnings),
+ },
+ )
+ mesh = Mesh.TriangularMesh(
+ name=f'{settings.example}_triangulation',
+ data=mesh_data,
+ metadata={
+ 'engine': 'metric_test_cdt',
+ 'example': settings.example,
+ },
+ )
+
+ return MetricTriangulationResult(
+ name=mesh.name,
+ domain=domain,
+ loops=loops,
+ mesh=mesh,
+ warnings=warnings,
+ metadata={
+ 'example': settings.example,
+ 'loop_count': len(loops),
+ 'recovery_sweeps_used': recovery_sweeps_used,
+ 'warning_count': len(warnings),
+ },
+ )
+
+ @classmethod
+ def _sample_loops(cls, domain: Domain.Domain,
+ settings: MetricTriangulationSettings) -> list[ConstraintLoop]:
+ loops = [
+ ConstraintLoop(
+ tag='outer',
+ points=cls._sample_boundary_loop(
+ domain.outer_boundary,
+ resolution=settings.outer_resolution,
+ ),
+ is_hole=False,
+ metadata={'role': 'outer'},
+ )
+ ]
+
+ for index, hole in enumerate(domain.inner_shapes, start=1):
+ loops.append(
+ ConstraintLoop(
+ tag=f'hole_{index}',
+ points=cls._sample_boundary_loop(
+ hole,
+ resolution=settings.hole_resolution,
+ ),
+ is_hole=True,
+ metadata={'role': 'hole', 'index': index},
+ )
+ )
+
+ return loops
+
+ @classmethod
+ def _sample_boundary_loop(cls, loop: Domain.BoundaryLoop,
+ resolution: int) -> PointArray:
+ if loop.metadata.get('use_source_vertices'):
+ return cls._normalize_loop_points(loop.to_polygon(resolution=None))
+
+ if len(loop.segments) == 1:
+ segment = loop.segments[0]
+ if isinstance(segment, Circle):
+ return cls._normalize_loop_points(
+ segment.sample_points(resolution=resolution)
+ )
+ if isinstance(segment, (Rectangle, Polygon, Polyline)):
+ return cls._sample_polyline_preserving_vertices(
+ segment.sample_points(resolution=None),
+ resolution=resolution,
+ closed=segment.is_closed,
+ )
+
+ return cls._sample_composite_loop(loop, resolution)
+
+ @classmethod
+ def _sample_composite_loop(cls, loop: Domain.BoundaryLoop,
+ resolution: int) -> PointArray:
+ segments = list(loop.segments)
+ if not segments:
+ raise ValueError(f'Boundary loop {loop.name} is empty.')
+
+ base_counts = np.asarray(
+ [
+ 3 if isinstance(segment, (Arc, Circle)) else 2
+ for segment in segments
+ ],
+ dtype=int,
+ )
+ minimum_resolution = int(np.sum(base_counts - 1))
+ target_resolution = max(int(resolution), minimum_resolution)
+
+ lengths = np.asarray(
+ [cls._shape_length(segment) for segment in segments],
+ dtype=float,
+ )
+ counts = np.array(base_counts, copy=True)
+ extra_points = target_resolution - minimum_resolution
+ total_length = float(np.sum(lengths))
+ if extra_points > 0 and total_length > 1.0e-14:
+ raw = extra_points * lengths / total_length
+ increments = np.floor(raw).astype(int)
+ remainder = extra_points - int(np.sum(increments))
+ if remainder > 0:
+ order = np.argsort(-(raw - increments))
+ increments[order[:remainder]] += 1
+ counts += increments
+
+ points: list[tuple[float, float]] = []
+ for segment, count in zip(segments, counts):
+ segment_points = segment.sample_points(resolution=int(count))
+ if not segment_points:
+ continue
+ current = list(segment_points)
+ if points:
+ if np.allclose(points[-1], current[0], atol=1.0e-9):
+ points.extend(current[1:])
+ elif np.allclose(points[-1], current[-1], atol=1.0e-9):
+ current.reverse()
+ points.extend(current[1:])
+ else:
+ points.extend(current)
+ else:
+ points.extend(current)
+
+ if loop.is_closed and not np.allclose(points[0], points[-1], atol=1.0e-9):
+ points.append(points[0])
+ return cls._normalize_loop_points(points)
+
+ @staticmethod
+ def _shape_length(shape) -> float:
+ if isinstance(shape, Line):
+ start = np.asarray(shape.start, dtype=float)
+ end = np.asarray(shape.end, dtype=float)
+ return float(np.linalg.norm(end - start))
+
+ if isinstance(shape, Arc):
+ points = np.asarray(shape.sample_points(resolution=128), dtype=float)
+ else:
+ points = np.asarray(shape.sample_points(resolution=None), dtype=float)
+
+ if len(points) < 2:
+ return 0.0
+ return float(np.sum(np.linalg.norm(np.diff(points, axis=0), axis=1)))
+
+ @classmethod
+ def _sample_polyline_preserving_vertices(cls,
+ points: Iterable[tuple[float, float]],
+ resolution: int,
+ closed: bool) -> PointArray:
+ loop = cls._normalize_loop_points(points)
+ base = loop[:-1]
+ if resolution <= len(base):
+ return np.vstack([base, base[0]])
+
+ deltas = np.diff(loop, axis=0)
+ segment_lengths = np.linalg.norm(deltas, axis=1)
+ total_length = float(np.sum(segment_lengths))
+ if total_length <= 1.0e-14:
+ return loop
+
+ extra_points = resolution - len(base)
+ raw = extra_points * segment_lengths / total_length
+ counts = np.floor(raw).astype(int)
+ remainder = extra_points - int(np.sum(counts))
+ if remainder > 0:
+ order = np.argsort(-(raw - counts))
+ counts[order[:remainder]] += 1
+
+ sampled = [loop[0]]
+ for index, count in enumerate(counts):
+ start = loop[index]
+ end = loop[index + 1]
+ if count > 0:
+ weights = np.arange(1, count + 1, dtype=float) / (count + 1.0)
+ interpolated = start + weights[:, None] * (end - start)
+ sampled.extend(interpolated)
+ sampled.append(end)
+
+ return cls._normalize_loop_points(sampled)
+
+ @staticmethod
+ def _normalize_loop_points(points: Iterable[tuple[float, float]]) -> PointArray:
+ array = np.asarray(list(points), dtype=float)
+ if array.ndim != 2 or array.shape[1] != 2:
+ raise ValueError('Loop points must form an array with shape (n, 2).')
+
+ cleaned = [array[0]]
+ for point in array[1:]:
+ if np.linalg.norm(point - cleaned[-1]) > 1.0e-12:
+ cleaned.append(point)
+ loop = np.asarray(cleaned, dtype=float)
+ if np.linalg.norm(loop[0] - loop[-1]) > 1.0e-12:
+ loop = np.vstack([loop, loop[0]])
+ if len(loop) < 4:
+ raise ValueError('Each loop needs at least three unique vertices.')
+ return loop
+
+ @classmethod
+ def _assemble_vertices(cls, loops: list[ConstraintLoop],
+ settings: MetricTriangulationSettings):
+ builder = _VertexBuilder()
+ boundary_tags: dict[str, list[tuple[int, int]]] = {}
+ loop_vertex_ids: list[np.ndarray] = []
+
+ for loop in loops:
+ ids = [builder.add(point) for point in loop.points[:-1]]
+ ids.append(ids[0])
+ loop_ids = np.asarray(ids, dtype=int)
+ loop_vertex_ids.append(loop_ids)
+ boundary_tags[loop.tag] = [
+ (int(loop_ids[index]), int(loop_ids[index + 1]))
+ for index in range(len(loop_ids) - 1)
+ ]
+
+ for point in cls._interior_grid_points(loops, settings):
+ builder.add(point)
+
+ return builder.as_array(), boundary_tags, loop_vertex_ids
+
+ @classmethod
+ def _interior_grid_points(cls, loops: list[ConstraintLoop],
+ settings: MetricTriangulationSettings) -> list[tuple[float, float]]:
+ if settings.interior_x <= 0 or settings.interior_y <= 0:
+ return []
+
+ outer = loops[0].points
+ xmin = float(np.min(outer[:, 0]))
+ xmax = float(np.max(outer[:, 0]))
+ ymin = float(np.min(outer[:, 1]))
+ ymax = float(np.max(outer[:, 1]))
+ x_values = np.linspace(xmin, xmax, settings.interior_x + 2)[1:-1]
+ y_values = np.linspace(ymin, ymax, settings.interior_y + 2)[1:-1]
+ grid_x, grid_y = np.meshgrid(x_values, y_values, indexing='xy')
+ points = np.column_stack((grid_x.ravel(), grid_y.ravel()))
+ inside = cls._points_in_domain(points, loops)
+ return [
+ (float(point[0]), float(point[1]))
+ for point in points[inside]
+ ]
+
+ @classmethod
+ def _delaunay(cls, vertices: PointArray, qhull_options: str) -> np.ndarray:
+ try:
+ simplices = Delaunay(vertices, qhull_options=qhull_options).simplices
+ except QhullError as error:
+ raise ValueError(f'Delaunay triangulation failed: {error}') from error
+ return np.asarray(simplices, dtype=int)
+
+ @staticmethod
+ def _edge_set(connectivity: np.ndarray) -> set[tuple[int, int]]:
+ edges: set[tuple[int, int]] = set()
+ for cell in np.asarray(connectivity, dtype=int):
+ count = len(cell)
+ for index in range(count):
+ a_value = int(cell[index])
+ b_value = int(cell[(index + 1) % count])
+ if a_value <= b_value:
+ edges.add((a_value, b_value))
+ else:
+ edges.add((b_value, a_value))
+ return edges
+
+ @classmethod
+ def _filter_triangles(cls, vertices: PointArray,
+ triangulation: _ConstrainedTriangulation,
+ loop_vertex_ids: list[np.ndarray]) -> np.ndarray:
+ loops = [
+ vertices[vertex_ids]
+ for vertex_ids in loop_vertex_ids
+ ]
+ simplices = np.asarray(triangulation.triangles, dtype=int)
+ triangles = vertices[simplices]
+ areas = np.abs(cls._triangle_area_array(triangles))
+ centroids = np.mean(triangles, axis=1)
+ loop_specs = [
+ ConstraintLoop('outer', loops[0], is_hole=False),
+ *[
+ ConstraintLoop(f'hole_{index}', loop, is_hole=True)
+ for index, loop in enumerate(loops[1:], start=1)
+ ],
+ ]
+ inside_domain = cls._points_in_domain(centroids, loop_specs)
+ keep_mask = (areas > 1.0e-12) & inside_domain
+ return triangulation.filtered_triangles(keep_mask)
+
+ @classmethod
+ def _existing_boundary_tags(cls, boundary_tags: dict[str, list[tuple[int, int]]],
+ mesh_edge_set: set[tuple[int, int]]):
+ existing: dict[str, list[tuple[int, int]]] = {}
+ missing_count = 0
+ for tag, edges in boundary_tags.items():
+ present = []
+ for edge in edges:
+ if cls._sorted_edge(edge) in mesh_edge_set:
+ present.append(edge)
+ else:
+ missing_count += 1
+ existing[tag] = present
+ return existing, missing_count
+
+ @classmethod
+ def _point_in_domain(cls, point: tuple[float, float],
+ loops: list[ConstraintLoop]) -> bool:
+ points = np.asarray([[float(point[0]), float(point[1])]], dtype=float)
+ return bool(cls._points_in_domain(points, loops)[0])
+
+ @classmethod
+ def _points_in_domain(cls, points: np.ndarray,
+ loops: list[ConstraintLoop]) -> np.ndarray:
+ if not loops:
+ return np.zeros(len(points), dtype=bool)
+
+ inside = cls._points_in_polygon(points, loops[0].points)
+ for hole in loops[1:]:
+ inside &= ~cls._points_in_polygon(points, hole.points)
+ return inside
+
+ @classmethod
+ def _point_in_polygon(cls, point: tuple[float, float],
+ polygon: PointArray) -> bool:
+ points = np.asarray([[float(point[0]), float(point[1])]], dtype=float)
+ return bool(cls._points_in_polygon(points, polygon)[0])
+
+ @staticmethod
+ def _points_in_polygon(points: np.ndarray, polygon: PointArray,
+ tolerance: float = 1.0e-12) -> np.ndarray:
+ if len(points) == 0:
+ return np.zeros(0, dtype=bool)
+
+ points = np.asarray(points, dtype=float)
+ x_values = points[:, 0][:, None]
+ y_values = points[:, 1][:, None]
+ x1 = polygon[:-1, 0][None, :]
+ y1 = polygon[:-1, 1][None, :]
+ x2 = polygon[1:, 0][None, :]
+ y2 = polygon[1:, 1][None, :]
+
+ cross = (x_values - x1) * (y2 - y1) - (y_values - y1) * (x2 - x1)
+ min_x = np.minimum(x1, x2) - tolerance
+ max_x = np.maximum(x1, x2) + tolerance
+ min_y = np.minimum(y1, y2) - tolerance
+ max_y = np.maximum(y1, y2) + tolerance
+ on_segment = (
+ np.abs(cross) <= tolerance
+ ) & (
+ x_values >= min_x
+ ) & (
+ x_values <= max_x
+ ) & (
+ y_values >= min_y
+ ) & (
+ y_values <= max_y
+ )
+
+ denominator = y2 - y1
+ safe_denominator = np.where(
+ np.abs(denominator) <= tolerance,
+ 1.0,
+ denominator,
+ )
+ intersects = ((y1 > y_values) != (y2 > y_values))
+ x_intersections = x1 + (y_values - y1) * (x2 - x1) / safe_denominator
+ toggles = intersects & (x_intersections >= x_values - tolerance)
+ inside = np.count_nonzero(toggles, axis=1) % 2 == 1
+ return inside | np.any(on_segment, axis=1)
+
+ @staticmethod
+ def _point_on_segment(point: tuple[float, float],
+ start: np.ndarray,
+ end: np.ndarray,
+ tolerance: float = 1.0e-9) -> bool:
+ px, py = float(point[0]), float(point[1])
+ x1, y1 = float(start[0]), float(start[1])
+ x2, y2 = float(end[0]), float(end[1])
+ cross = (px - x1) * (y2 - y1) - (py - y1) * (x2 - x1)
+ if abs(cross) > tolerance:
+ return False
+ dot = (px - x1) * (px - x2) + (py - y1) * (py - y2)
+ return dot <= tolerance
+
+ @staticmethod
+ def _triangle_area(triangle: PointArray) -> float:
+ ax_value = float(triangle[1][0] - triangle[0][0])
+ ay_value = float(triangle[1][1] - triangle[0][1])
+ bx_value = float(triangle[2][0] - triangle[0][0])
+ by_value = float(triangle[2][1] - triangle[0][1])
+ return 0.5 * (ax_value * by_value - ay_value * bx_value)
+
+ @staticmethod
+ def _triangle_area_array(triangles: np.ndarray) -> np.ndarray:
+ ax_values = triangles[:, 1, 0] - triangles[:, 0, 0]
+ ay_values = triangles[:, 1, 1] - triangles[:, 0, 1]
+ bx_values = triangles[:, 2, 0] - triangles[:, 0, 0]
+ by_values = triangles[:, 2, 1] - triangles[:, 0, 1]
+ return 0.5 * (ax_values * by_values - ay_values * bx_values)
+
+ @classmethod
+ def _segments_intersect(cls, a_start: np.ndarray, a_end: np.ndarray,
+ b_start: np.ndarray, b_end: np.ndarray,
+ tolerance: float = 1.0e-9) -> bool:
+ o1 = cls._orientation(a_start, a_end, b_start)
+ o2 = cls._orientation(a_start, a_end, b_end)
+ o3 = cls._orientation(b_start, b_end, a_start)
+ o4 = cls._orientation(b_start, b_end, a_end)
+
+ if (
+ (o1 > tolerance and o2 < -tolerance) or
+ (o1 < -tolerance and o2 > tolerance)
+ ) and (
+ (o3 > tolerance and o4 < -tolerance) or
+ (o3 < -tolerance and o4 > tolerance)
+ ):
+ return True
+
+ if abs(o1) <= tolerance and cls._point_on_segment(tuple(b_start), a_start, a_end):
+ return True
+ if abs(o2) <= tolerance and cls._point_on_segment(tuple(b_end), a_start, a_end):
+ return True
+ if abs(o3) <= tolerance and cls._point_on_segment(tuple(a_start), b_start, b_end):
+ return True
+ if abs(o4) <= tolerance and cls._point_on_segment(tuple(a_end), b_start, b_end):
+ return True
+ return False
+
+ @classmethod
+ def _segments_cross_proper(cls, a_start: np.ndarray, a_end: np.ndarray,
+ b_start: np.ndarray, b_end: np.ndarray,
+ tolerance: float = 1.0e-9) -> bool:
+ o1 = cls._orientation(a_start, a_end, b_start)
+ o2 = cls._orientation(a_start, a_end, b_end)
+ o3 = cls._orientation(b_start, b_end, a_start)
+ o4 = cls._orientation(b_start, b_end, a_end)
+
+ return (
+ ((o1 > tolerance and o2 < -tolerance) or
+ (o1 < -tolerance and o2 > tolerance)) and
+ ((o3 > tolerance and o4 < -tolerance) or
+ (o3 < -tolerance and o4 > tolerance))
+ )
+
+ @staticmethod
+ def _orientation(start: np.ndarray, end: np.ndarray, point: np.ndarray) -> float:
+ return float(
+ (end[0] - start[0]) * (point[1] - start[1]) -
+ (end[1] - start[1]) * (point[0] - start[0])
+ )
+
+ @staticmethod
+ def _sorted_edge(edge: tuple[int, int]) -> tuple[int, int]:
+ a_value, b_value = int(edge[0]), int(edge[1])
+ return (a_value, b_value) if a_value <= b_value else (b_value, a_value)
+
+
+class _VertexBuilder:
+ def __init__(self, tolerance: float = 1.0e-10):
+ self._tolerance = float(tolerance)
+ self._points: list[tuple[float, float]] = []
+ self._index_by_key: dict[tuple[int, int], int] = {}
+
+ def add(self, point: Iterable[float]) -> int:
+ x_value, y_value = float(point[0]), float(point[1])
+ key = self._key((x_value, y_value))
+ existing = self._index_by_key.get(key)
+ if existing is not None:
+ return existing
+ index = len(self._points)
+ self._points.append((x_value, y_value))
+ self._index_by_key[key] = index
+ return index
+
+ def as_array(self) -> PointArray:
+ return np.asarray(self._points, dtype=float)
+
+ def _key(self, point: tuple[float, float]) -> tuple[int, int]:
+ scale = 1.0 / self._tolerance
+ return int(round(point[0] * scale)), int(round(point[1] * scale))
diff --git a/src/OrthoLayers.py b/src/OrthoLayers.py
new file mode 100644
index 0000000..2428f1c
--- /dev/null
+++ b/src/OrthoLayers.py
@@ -0,0 +1,158 @@
+"""Exact-normal near-wall block for the Structured engine.
+
+Layer k sits at ``P(t_i) + h_k * n(t_i)`` where ``n`` is the exact analytic
+spline normal on contour nodes (finite differences elsewhere, e.g. wake
+cuts). At tangent-discontinuous corners (sharp TE, blunt base corners,
+contour/wake junctions) normals are blended toward the corner bisector in a
+small window; outside those windows perpendicularity is exact. Requested
+heights are capped against the local concave radius of curvature — the
+block thins with a logged warning instead of folding.
+"""
+from __future__ import annotations
+
+import logging
+
+import numpy as np
+
+logger = logging.getLogger(__name__)
+
+
+def _rotate_to_normals(tangents: np.ndarray) -> np.ndarray:
+ lengths = np.linalg.norm(tangents, axis=1)
+ lengths[lengths == 0.0] = 1.0
+ return np.column_stack(
+ (tangents[:, 1], -tangents[:, 0])) / lengths[:, None]
+
+
+def _finite_difference_tangents(wall: np.ndarray,
+ closed: bool) -> np.ndarray:
+ if closed:
+ unique = wall[:-1]
+ tangents = np.roll(unique, -1, axis=0) - np.roll(unique, 1, axis=0)
+ return np.vstack((tangents, tangents[:1]))
+ return np.gradient(wall, axis=0)
+
+
+def wall_normals(wall: np.ndarray, corner_indices,
+ spline_data=None, contour_slice=None,
+ closed: bool = False, blend_width: int = 4) -> np.ndarray:
+ wall = np.asarray(wall, dtype=float)
+ normals = _rotate_to_normals(_finite_difference_tangents(wall, closed))
+
+ if spline_data is not None and contour_slice is not None:
+ start, stop = contour_slice
+ parameters = np.asarray(spline_data.sample_parameters, dtype=float)
+ if stop - start == len(parameters):
+ exact = np.column_stack(
+ spline_data.evaluate(parameters, der=1))
+ normals[start:stop] = _rotate_to_normals(exact)
+
+ count = len(wall)
+ unique_count = count - 1 if closed else count
+ for corner in corner_indices:
+ window = np.arange(corner - blend_width, corner + blend_width + 1)
+ if closed:
+ window = np.mod(window, unique_count)
+ else:
+ window = window[(window >= 0) & (window < count)]
+ for _ in range(4):
+ blended = normals.copy()
+ for index in window:
+ prev_index = (index - 1) % unique_count if closed \
+ else max(index - 1, 0)
+ next_index = (index + 1) % unique_count if closed \
+ else min(index + 1, count - 1)
+ vector = (normals[prev_index] + 2.0 * normals[index] +
+ normals[next_index])
+ norm = np.linalg.norm(vector)
+ if norm > 0.0:
+ blended[index] = vector / norm
+ normals = blended
+ if closed:
+ normals[-1] = normals[0]
+ return normals
+
+
+def layer_heights(layer_count: int, first_height: float,
+ growth: float) -> np.ndarray:
+ if layer_count < 1:
+ raise ValueError('layer_heights needs at least one layer.')
+ if first_height <= 0.0 or growth < 1.0:
+ raise ValueError('first_height must be positive and growth >= 1.')
+ steps = first_height * growth ** np.arange(layer_count)
+ return np.cumsum(steps)
+
+
+def max_offset_heights(wall: np.ndarray, normals: np.ndarray,
+ spline_data=None, contour_slice=None,
+ safety: float = 0.5) -> np.ndarray:
+ """Per-node offset cap: safety * radius of curvature on the concave
+ side (curvature center along the offset direction), infinite elsewhere.
+ """
+ wall = np.asarray(wall, dtype=float)
+ first = np.gradient(wall, axis=0)
+ second = np.gradient(first, axis=0)
+ if spline_data is not None and contour_slice is not None:
+ start, stop = contour_slice
+ parameters = np.asarray(spline_data.sample_parameters, dtype=float)
+ if stop - start == len(parameters):
+ first[start:stop] = np.column_stack(
+ spline_data.evaluate(parameters, der=1))
+ second[start:stop] = np.column_stack(
+ spline_data.evaluate(parameters, der=2))
+
+ cross = first[:, 0] * second[:, 1] - first[:, 1] * second[:, 0]
+ speed_sq = np.sum(first ** 2, axis=1)
+ denominator = np.maximum(speed_sq, 1.0e-300) ** 1.5
+ curvature = np.abs(cross) / denominator
+
+ # left normal = rotate tangent by +90 deg: (-t_y, t_x); the curvature
+ # center lies along +left when cross > 0, along -left otherwise
+ lengths = np.sqrt(np.maximum(speed_sq, 1.0e-300))
+ left = np.column_stack((-first[:, 1], first[:, 0])) / lengths[:, None]
+ center_direction = np.sign(cross)[:, None] * left
+
+ concave = np.sum(np.asarray(normals) * center_direction, axis=1) > 0.0
+ caps = np.full(len(wall), np.inf)
+ curved = curvature > 1.0e-12
+ mask = concave & curved
+ caps[mask] = safety / curvature[mask]
+ return caps
+
+
+def _smooth_profile(values: np.ndarray, passes: int = 5,
+ window: int = 5) -> np.ndarray:
+ kernel = np.ones(window) / window
+ padded = values.copy()
+ for _ in range(passes):
+ padded = np.convolve(
+ np.pad(padded, window // 2, mode='edge'), kernel, mode='valid')
+ return padded
+
+
+def build_layers(wall: np.ndarray, normals: np.ndarray, layer_count: int,
+ first_height: float, growth: float,
+ max_heights=None) -> np.ndarray:
+ wall = np.asarray(wall, dtype=float)
+ normals = np.asarray(normals, dtype=float)
+ heights = layer_heights(layer_count, first_height, growth)
+ total = heights[-1]
+
+ scale = np.ones(len(wall))
+ if max_heights is not None:
+ caps = np.asarray(max_heights, dtype=float)
+ with np.errstate(invalid='ignore'):
+ cap_scale = np.where(np.isfinite(caps),
+ np.minimum(caps / total, 1.0), 1.0)
+ if np.any(cap_scale < 1.0):
+ scale = _smooth_profile(cap_scale)
+ scale = np.minimum(scale, cap_scale)
+ logger.warning(
+ 'Ortho block thinned at %d of %d wall nodes to respect the '
+ 'local radius of curvature.',
+ int(np.sum(cap_scale < 1.0)), len(wall))
+
+ offsets = heights[:, None, None] * (scale[:, None] * normals)[None, :, :]
+ rows = np.concatenate((wall[None, :, :], wall[None, :, :] + offsets))
+ rows[0] = wall
+ return rows
diff --git a/src/PrintLayout.py b/src/PrintLayout.py
new file mode 100644
index 0000000..14c0f08
--- /dev/null
+++ b/src/PrintLayout.py
@@ -0,0 +1,1093 @@
+from __future__ import annotations
+
+from dataclasses import dataclass, field
+from datetime import date
+import getpass
+
+import numpy as np
+from PySide6 import QtCore, QtGui, QtWidgets
+
+from CSTAirfoil import split_airfoil_surfaces
+
+
+FRAME_MODE_NONE = 'none'
+FRAME_MODE_FRAME = 'frame'
+FRAME_MODES = (
+ FRAME_MODE_NONE,
+ FRAME_MODE_FRAME,
+)
+
+PAPER_SIZE_A4 = 'A4'
+PAPER_SIZE_A3 = 'A3'
+PAPER_SIZES = (
+ PAPER_SIZE_A4,
+ PAPER_SIZE_A3,
+)
+
+FRAME_MARGIN_MM = 10.0
+FRAME_LINE_MM = 0.22
+DATA_BOX_WIDTH_MM = 54.0
+INNER_MARGIN_MM = 4.0
+FOOTER_WIDTH_MM = 54.0
+
+
+def normalize_frame_mode(value):
+ normalized = str(value or '').strip().lower()
+ legacy_map = {
+ 'simple': FRAME_MODE_FRAME,
+ 'frame': FRAME_MODE_FRAME,
+ 'iso': FRAME_MODE_FRAME,
+ }
+ normalized = legacy_map.get(normalized, normalized)
+ if normalized in FRAME_MODES:
+ return normalized
+ raise ValueError(
+ 'Frame mode must be one of: ' + ', '.join(FRAME_MODES)
+ )
+
+
+def normalize_paper_size(value):
+ normalized = str(value or '').strip().upper()
+ if normalized in PAPER_SIZES:
+ return normalized
+ raise ValueError(
+ 'Paper size must be one of: ' + ', '.join(PAPER_SIZES)
+ )
+
+
+def current_creator_name():
+ try:
+ creator = getpass.getuser().strip()
+ except (OSError, KeyError):
+ creator = ''
+ return creator or 'PyAero user'
+
+
+def mm_to_pixels(mm_value, resolution):
+ return float(mm_value) * float(resolution) / 25.4
+
+
+@dataclass(slots=True)
+class PrintFooterData:
+ show_author: bool = False
+ author: str = ''
+ show_date: bool = False
+ date_of_issue: str = ''
+
+
+@dataclass(slots=True)
+class PrintLayoutOptions:
+ frame_mode: str = FRAME_MODE_NONE
+ paper_size: str = PAPER_SIZE_A4
+ show_data_box: bool = True
+ show_position_line: bool = True
+ footer: PrintFooterData = field(default_factory=PrintFooterData)
+
+
+@dataclass(slots=True)
+class AirfoilPrintMetrics:
+ name: str
+ contour_label: str
+ contour_coordinates: tuple[np.ndarray, np.ndarray]
+ chord: float
+ minimum_x: float
+ maximum_x: float
+ minimum_y: float
+ maximum_y: float
+ max_thickness: float | None
+ max_thickness_position: float | None
+ max_thickness_marker_y: float | None
+ max_camber: float | None
+ max_camber_position: float | None
+ max_camber_marker_y: float | None
+
+
+@dataclass(slots=True)
+class PageGeometry:
+ page_rect: QtCore.QRectF
+ frame_rect: QtCore.QRectF
+ drawing_rect: QtCore.QRectF
+ data_box_rect: QtCore.QRectF
+ footer_rect: QtCore.QRectF
+
+
+def clone_print_layout_options(options):
+ return PrintLayoutOptions(
+ frame_mode=options.frame_mode,
+ paper_size=options.paper_size,
+ show_data_box=bool(options.show_data_box),
+ show_position_line=bool(options.show_position_line),
+ footer=PrintFooterData(
+ show_author=bool(options.footer.show_author),
+ author=options.footer.author,
+ show_date=bool(options.footer.show_date),
+ date_of_issue=options.footer.date_of_issue,
+ ),
+ )
+
+
+def default_print_layout_options(_airfoil=None, previous=None):
+ options = (
+ clone_print_layout_options(previous)
+ if previous is not None
+ else PrintLayoutOptions()
+ )
+ if previous is None:
+ options.frame_mode = FRAME_MODE_NONE
+ options.paper_size = PAPER_SIZE_A4
+ options.show_data_box = True
+ options.show_position_line = True
+ options.footer.show_author = False
+ options.footer.show_date = False
+
+ if not options.footer.author.strip():
+ options.footer.author = current_creator_name()
+ if not options.footer.date_of_issue.strip():
+ options.footer.date_of_issue = date.today().isoformat()
+ return options
+
+
+def _metric_value(values, stations, centerline, minimum_value):
+ finite = np.isfinite(values)
+ if not np.any(finite):
+ return None, None, None
+
+ masked = np.where(finite, values, -np.inf)
+ index = int(np.argmax(masked))
+ value = float(masked[index])
+ if not np.isfinite(value) or value <= minimum_value:
+ return None, None, None
+ return (
+ value,
+ float(stations[index]),
+ float(centerline[index]),
+ )
+
+
+def build_metrics_from_contour(coordinates, name='Airfoil', contour_label='contour'):
+ x_coordinates = np.asarray(coordinates[0], dtype=float)
+ y_coordinates = np.asarray(coordinates[1], dtype=float)
+ if x_coordinates.size == 0 or y_coordinates.size == 0:
+ raise ValueError('No contour coordinates available for printing.')
+
+ contour_coordinates = (
+ np.array(x_coordinates, copy=True, dtype=float),
+ np.array(y_coordinates, copy=True, dtype=float),
+ )
+ minimum_x = float(np.min(x_coordinates))
+ maximum_x = float(np.max(x_coordinates))
+ minimum_y = float(np.min(y_coordinates))
+ maximum_y = float(np.max(y_coordinates))
+ chord = float(maximum_x - minimum_x)
+
+ max_thickness = None
+ max_thickness_position = None
+ max_thickness_marker_y = None
+ max_camber = None
+ max_camber_position = None
+ max_camber_marker_y = None
+
+ try:
+ upper_surface, lower_surface = split_airfoil_surfaces(contour_coordinates)
+ x_start = max(
+ float(np.min(upper_surface[:, 0])),
+ float(np.min(lower_surface[:, 0])),
+ )
+ x_end = min(
+ float(np.max(upper_surface[:, 0])),
+ float(np.max(lower_surface[:, 0])),
+ )
+ if x_end > x_start:
+ stations = np.linspace(x_start, x_end, 401)
+ upper_y = np.interp(stations, upper_surface[:, 0], upper_surface[:, 1])
+ lower_y = np.interp(stations, lower_surface[:, 0], lower_surface[:, 1])
+ thickness = upper_y - lower_y
+ camber = 0.5 * (upper_y + lower_y)
+
+ max_thickness, max_thickness_position, max_thickness_marker_y = (
+ _metric_value(
+ thickness,
+ stations,
+ camber,
+ minimum_value=1.0e-9,
+ )
+ )
+ max_camber, max_camber_position, max_camber_marker_y = _metric_value(
+ camber,
+ stations,
+ camber,
+ minimum_value=1.0e-6,
+ )
+ except ValueError:
+ pass
+
+ return AirfoilPrintMetrics(
+ name=name,
+ contour_label=contour_label,
+ contour_coordinates=contour_coordinates,
+ chord=chord,
+ minimum_x=minimum_x,
+ maximum_x=maximum_x,
+ minimum_y=minimum_y,
+ maximum_y=maximum_y,
+ max_thickness=max_thickness,
+ max_thickness_position=max_thickness_position,
+ max_thickness_marker_y=max_thickness_marker_y,
+ max_camber=max_camber,
+ max_camber_position=max_camber_position,
+ max_camber_marker_y=max_camber_marker_y,
+ )
+
+
+def build_airfoil_print_metrics(airfoil, prefer_spline=True):
+ contour = airfoil.current_contour(prefer_spline=prefer_spline)
+ if contour is None and prefer_spline:
+ contour = airfoil.current_contour(prefer_spline=False)
+ if contour is None:
+ raise ValueError('No airfoil contour available for printing.')
+
+ contour_label = (
+ 'prepared contour'
+ if getattr(airfoil, 'has_spline', False)
+ else 'raw contour'
+ )
+ return build_metrics_from_contour(
+ contour,
+ name=getattr(airfoil, 'name', 'Airfoil'),
+ contour_label=contour_label,
+ )
+
+
+def configure_printer_for_layout(printer, options):
+ page_size_id = (
+ QtGui.QPageSize.A3
+ if options.paper_size == PAPER_SIZE_A3
+ else QtGui.QPageSize.A4
+ )
+ page_size = QtGui.QPageSize(page_size_id)
+ printer.setPageSize(page_size)
+ printer.setPageOrientation(QtGui.QPageLayout.Landscape)
+ layout = printer.pageLayout()
+ layout.setPageSize(page_size)
+ layout.setOrientation(QtGui.QPageLayout.Landscape)
+ printer.setPageLayout(layout)
+
+
+def _set_font_pixel_size(font, pixel_size):
+ font.setPixelSize(max(1, int(round(pixel_size))))
+ return font
+
+
+def _scaled_font(font, scale):
+ scaled = QtGui.QFont(font)
+ pixel_size = font.pixelSize()
+ if pixel_size > 0:
+ scaled.setPixelSize(max(1, int(round(pixel_size * float(scale)))))
+ return scaled
+
+ point_size = font.pointSizeF()
+ if point_size > 0.0:
+ scaled.setPointSizeF(max(1.0, point_size * float(scale)))
+ return scaled
+
+ return _set_font_pixel_size(scaled, 12.0 * float(scale))
+
+
+class PrintLayoutDialog(QtWidgets.QDialog):
+ def __init__(self, airfoil, options=None, parent=None):
+ super().__init__(parent)
+ self._airfoil = airfoil
+ self._options = default_print_layout_options(airfoil, previous=options)
+ self._control_height = 34
+ self._combo_width = 170
+ self._field_width = 320
+
+ self.setWindowTitle('Print Layout')
+ self.setModal(True)
+ self.resize(620, 390)
+ self.setMinimumSize(580, 360)
+ self.setWindowFlag(QtCore.Qt.WindowContextHelpButtonHint, False)
+
+ self._build_ui()
+ self._load_options()
+ self._update_footer_controls()
+
+ def _build_ui(self):
+ layout = QtWidgets.QVBoxLayout(self)
+ layout.setContentsMargins(16, 16, 16, 16)
+ layout.setSpacing(12)
+
+ heading = QtWidgets.QLabel('Print Layout')
+ heading.setStyleSheet('font-size: 18px; font-weight: 700; color: #1d3148;')
+ layout.addWidget(heading)
+
+ note = QtWidgets.QLabel(
+ 'Use the current viewer contents inside an optional A4 or A3 sheet layout.'
+ )
+ note.setWordWrap(True)
+ note.setStyleSheet('color: #506274;')
+ layout.addWidget(note)
+
+ layout_group = QtWidgets.QGroupBox('Layout')
+ layout_form = QtWidgets.QFormLayout(layout_group)
+ layout_form.setFieldGrowthPolicy(QtWidgets.QFormLayout.ExpandingFieldsGrow)
+ layout_form.setFormAlignment(QtCore.Qt.AlignLeft | QtCore.Qt.AlignTop)
+ layout_form.setLabelAlignment(QtCore.Qt.AlignRight | QtCore.Qt.AlignVCenter)
+ layout_form.setHorizontalSpacing(16)
+ layout_form.setVerticalSpacing(10)
+
+ self.frame_mode_combo = QtWidgets.QComboBox()
+ self.frame_mode_combo.addItem('No frame', FRAME_MODE_NONE)
+ self.frame_mode_combo.addItem('Frame', FRAME_MODE_FRAME)
+ self.frame_mode_combo.setMinimumHeight(self._control_height)
+ self.frame_mode_combo.setMinimumWidth(self._combo_width)
+ layout_form.addRow('Frame mode', self.frame_mode_combo)
+
+ self.paper_size_combo = QtWidgets.QComboBox()
+ self.paper_size_combo.addItem('A4', PAPER_SIZE_A4)
+ self.paper_size_combo.addItem('A3', PAPER_SIZE_A3)
+ self.paper_size_combo.setMinimumHeight(self._control_height)
+ self.paper_size_combo.setMinimumWidth(self._combo_width)
+ layout_form.addRow('Paper size', self.paper_size_combo)
+
+ self.show_data_box_checkbox = QtWidgets.QCheckBox('Show airfoil data box')
+ layout_form.addRow('', self.show_data_box_checkbox)
+
+ self.show_position_line_checkbox = QtWidgets.QCheckBox(
+ 'Show max-position guide line'
+ )
+ layout_form.addRow('', self.show_position_line_checkbox)
+
+ layout.addWidget(layout_group)
+
+ footer_group = QtWidgets.QGroupBox('Lower Right Info')
+ footer_form = QtWidgets.QFormLayout(footer_group)
+ footer_form.setFieldGrowthPolicy(QtWidgets.QFormLayout.ExpandingFieldsGrow)
+ footer_form.setFormAlignment(QtCore.Qt.AlignLeft | QtCore.Qt.AlignTop)
+ footer_form.setLabelAlignment(QtCore.Qt.AlignRight | QtCore.Qt.AlignVCenter)
+ footer_form.setHorizontalSpacing(16)
+ footer_form.setVerticalSpacing(10)
+
+ self.show_author_checkbox = QtWidgets.QCheckBox('Show')
+ self.show_author_checkbox.toggled.connect(self._update_footer_controls)
+ self.author_edit = QtWidgets.QLineEdit()
+ self.author_edit.setClearButtonEnabled(True)
+ self.author_edit.setMinimumHeight(self._control_height)
+ self.author_edit.setMinimumWidth(self._field_width)
+ author_row = QtWidgets.QHBoxLayout()
+ author_row.setContentsMargins(0, 0, 0, 0)
+ author_row.setSpacing(10)
+ author_row.addWidget(self.show_author_checkbox)
+ author_row.addWidget(self.author_edit, 1)
+ author_widget = QtWidgets.QWidget()
+ author_widget.setLayout(author_row)
+ footer_form.addRow('Author:', author_widget)
+
+ self.show_date_checkbox = QtWidgets.QCheckBox('Show')
+ self.show_date_checkbox.toggled.connect(self._update_footer_controls)
+ self.date_edit = QtWidgets.QDateEdit()
+ self.date_edit.setCalendarPopup(True)
+ self.date_edit.setDisplayFormat('yyyy-MM-dd')
+ self.date_edit.setMinimumHeight(self._control_height)
+ self.date_edit.setMinimumWidth(self._combo_width)
+ date_row = QtWidgets.QHBoxLayout()
+ date_row.setContentsMargins(0, 0, 0, 0)
+ date_row.setSpacing(10)
+ date_row.addWidget(self.show_date_checkbox)
+ date_row.addWidget(self.date_edit)
+ date_row.addStretch(1)
+ date_widget = QtWidgets.QWidget()
+ date_widget.setLayout(date_row)
+ footer_form.addRow('Date:', date_widget)
+
+ layout.addWidget(footer_group)
+ layout.addStretch(1)
+
+ self.button_box = QtWidgets.QDialogButtonBox(
+ QtWidgets.QDialogButtonBox.Ok | QtWidgets.QDialogButtonBox.Cancel
+ )
+ self.button_box.accepted.connect(self.accept)
+ self.button_box.rejected.connect(self.reject)
+ layout.addWidget(self.button_box)
+
+ def _load_options(self):
+ frame_index = self.frame_mode_combo.findData(
+ normalize_frame_mode(self._options.frame_mode)
+ )
+ self.frame_mode_combo.setCurrentIndex(max(frame_index, 0))
+
+ paper_index = self.paper_size_combo.findData(self._options.paper_size)
+ self.paper_size_combo.setCurrentIndex(max(paper_index, 0))
+
+ self.show_data_box_checkbox.setChecked(bool(self._options.show_data_box))
+ self.show_position_line_checkbox.setChecked(
+ bool(self._options.show_position_line)
+ )
+
+ self.show_author_checkbox.setChecked(bool(self._options.footer.show_author))
+ self.author_edit.setText(self._options.footer.author)
+
+ title_date = QtCore.QDate.fromString(
+ self._options.footer.date_of_issue,
+ 'yyyy-MM-dd',
+ )
+ if not title_date.isValid():
+ title_date = QtCore.QDate.currentDate()
+ self.date_edit.setDate(title_date)
+ self.show_date_checkbox.setChecked(bool(self._options.footer.show_date))
+
+ def _update_footer_controls(self):
+ self.author_edit.setEnabled(self.show_author_checkbox.isChecked())
+ self.date_edit.setEnabled(self.show_date_checkbox.isChecked())
+
+ def selected_options(self):
+ return PrintLayoutOptions(
+ frame_mode=normalize_frame_mode(self.frame_mode_combo.currentData()),
+ paper_size=normalize_paper_size(self.paper_size_combo.currentData()),
+ show_data_box=self.show_data_box_checkbox.isChecked(),
+ show_position_line=self.show_position_line_checkbox.isChecked(),
+ footer=PrintFooterData(
+ show_author=self.show_author_checkbox.isChecked(),
+ author=self.author_edit.text().strip() or current_creator_name(),
+ show_date=self.show_date_checkbox.isChecked(),
+ date_of_issue=self.date_edit.date().toString('yyyy-MM-dd'),
+ ),
+ )
+
+
+class AirfoilPrintRenderer:
+ def render(self, painter, printer, airfoil, view, options):
+ metrics = build_airfoil_print_metrics(airfoil)
+ resolution = printer.resolution()
+
+ page_rect = QtCore.QRectF(
+ printer.pageLayout().paintRectPixels(resolution)
+ )
+ if page_rect.isNull():
+ page_rect = QtCore.QRectF(
+ printer.pageLayout().fullRectPixels(resolution)
+ )
+ if page_rect.isNull():
+ raise ValueError('Printer page layout is not available.')
+
+ geometry = self._page_geometry(page_rect, resolution, options)
+
+ painter.save()
+ try:
+ painter.setRenderHint(QtGui.QPainter.Antialiasing, True)
+ painter.setRenderHint(QtGui.QPainter.TextAntialiasing, True)
+ painter.fillRect(page_rect, QtCore.Qt.white)
+
+ if options.frame_mode == FRAME_MODE_FRAME:
+ self._draw_frame(painter, geometry.frame_rect, resolution)
+
+ self._draw_view_plot(
+ painter,
+ geometry.drawing_rect,
+ metrics,
+ view,
+ resolution,
+ options,
+ )
+
+ if not geometry.data_box_rect.isNull():
+ self._draw_data_box(
+ painter,
+ geometry.data_box_rect,
+ metrics,
+ resolution,
+ )
+
+ if not geometry.footer_rect.isNull():
+ self._draw_footer(
+ painter,
+ geometry.footer_rect,
+ options,
+ resolution,
+ )
+ finally:
+ painter.restore()
+
+ def _page_geometry(self, page_rect, resolution, options):
+ inset = mm_to_pixels(12.0, resolution)
+ frame_rect = QtCore.QRectF()
+ content_rect = QtCore.QRectF(page_rect).adjusted(inset, inset, -inset, -inset)
+
+ if options.frame_mode == FRAME_MODE_FRAME:
+ margin = mm_to_pixels(FRAME_MARGIN_MM, resolution)
+ frame_rect = QtCore.QRectF(page_rect).adjusted(
+ margin,
+ margin,
+ -margin,
+ -margin,
+ )
+ content_rect = QtCore.QRectF(frame_rect)
+
+ padding = mm_to_pixels(INNER_MARGIN_MM, resolution)
+ drawing_rect = QtCore.QRectF(content_rect).adjusted(
+ padding,
+ padding,
+ -padding,
+ -padding,
+ )
+
+ data_box_rect = QtCore.QRectF()
+ if options.show_data_box:
+ data_box_width = min(
+ mm_to_pixels(DATA_BOX_WIDTH_MM, resolution),
+ drawing_rect.width() * 0.34,
+ )
+ data_box_height = min(
+ max(mm_to_pixels(38.0, resolution), drawing_rect.height() * 0.28),
+ drawing_rect.height() * 0.5,
+ )
+ if data_box_width > 0.0 and data_box_height > 0.0:
+ data_box_rect = QtCore.QRectF(
+ drawing_rect.right() - data_box_width,
+ drawing_rect.top(),
+ data_box_width,
+ data_box_height,
+ )
+ lowered_top = (
+ data_box_rect.bottom() +
+ padding +
+ mm_to_pixels(2.0, resolution)
+ )
+ if lowered_top < drawing_rect.bottom():
+ drawing_rect.setTop(lowered_top)
+
+ footer_rect = self._footer_rect(
+ content_rect,
+ drawing_rect,
+ data_box_rect,
+ resolution,
+ options,
+ )
+ if not footer_rect.isNull():
+ drawing_rect.setBottom(footer_rect.top() - padding)
+
+ return PageGeometry(
+ page_rect=QtCore.QRectF(page_rect),
+ frame_rect=frame_rect,
+ drawing_rect=drawing_rect,
+ data_box_rect=data_box_rect,
+ footer_rect=footer_rect,
+ )
+
+ def _footer_rect(self, content_rect, drawing_rect, data_box_rect, resolution, options):
+ lines = self._footer_lines(options)
+ if not lines:
+ return QtCore.QRectF()
+
+ footer_width = (
+ data_box_rect.width()
+ if not data_box_rect.isNull()
+ else min(
+ mm_to_pixels(FOOTER_WIDTH_MM, resolution),
+ drawing_rect.width() * 0.34,
+ )
+ )
+ line_height = mm_to_pixels(5.4, resolution)
+ footer_height = (
+ mm_to_pixels(2.0, resolution) +
+ len(lines) * line_height
+ )
+ left = (
+ data_box_rect.left()
+ if not data_box_rect.isNull()
+ else content_rect.right() - footer_width
+ )
+ right = left + footer_width
+ bottom = content_rect.bottom() - mm_to_pixels(2.0, resolution)
+ return QtCore.QRectF(
+ left,
+ bottom - footer_height,
+ right - left,
+ footer_height,
+ )
+
+ def _draw_frame(self, painter, rect, resolution):
+ if rect.isNull():
+ return
+
+ pen = QtGui.QPen(QtCore.Qt.black)
+ pen.setWidthF(mm_to_pixels(FRAME_LINE_MM, resolution))
+ painter.setPen(pen)
+ painter.setBrush(QtCore.Qt.NoBrush)
+ painter.drawRect(rect)
+
+ def _draw_data_box(self, painter, rect, metrics, resolution):
+ rows = [
+ ('Airfoil', metrics.name),
+ ('Contour', metrics.contour_label),
+ ('Chord', f'{metrics.chord:.3f}'),
+ ('Max thickness', self._percent_text(metrics.max_thickness)),
+ ('x/c @ max t', self._station_text(metrics, metrics.max_thickness_position)),
+ ('Max camber', self._percent_text(metrics.max_camber)),
+ ('x/c @ max c', self._station_text(metrics, metrics.max_camber_position)),
+ ('Units', 'normalized to chord'),
+ ]
+
+ border_pen = QtGui.QPen(QtCore.Qt.black)
+ border_pen.setWidthF(mm_to_pixels(0.25, resolution))
+ painter.setPen(border_pen)
+ painter.setBrush(QtCore.Qt.white)
+ painter.drawRect(rect)
+
+ header_height = rect.height() / (len(rows) + 1)
+ header_rect = QtCore.QRectF(rect.left(), rect.top(), rect.width(), header_height)
+ painter.fillRect(header_rect, QtGui.QColor(238, 241, 245))
+ painter.drawRect(header_rect)
+
+ header_font = QtGui.QFont('Helvetica')
+ _set_font_pixel_size(header_font, mm_to_pixels(3.8, resolution))
+ header_font.setBold(True)
+ painter.setFont(header_font)
+ self._draw_text(
+ painter,
+ header_rect.adjusted(
+ mm_to_pixels(1.2, resolution),
+ 0.0,
+ -mm_to_pixels(1.2, resolution),
+ 0.0,
+ ),
+ 'AIRFOIL DATA',
+ QtCore.Qt.AlignVCenter | QtCore.Qt.AlignLeft,
+ )
+
+ label_font = QtGui.QFont('Helvetica')
+ _set_font_pixel_size(label_font, mm_to_pixels(2.8, resolution))
+ value_font = QtGui.QFont('Helvetica')
+ _set_font_pixel_size(value_font, mm_to_pixels(3.3, resolution))
+
+ row_height = (rect.height() - header_height) / len(rows)
+ split_x = rect.left() + rect.width() * 0.46
+ for index, (label, value) in enumerate(rows):
+ top = header_rect.bottom() + index * row_height
+ row_rect = QtCore.QRectF(rect.left(), top, rect.width(), row_height)
+ painter.drawRect(row_rect)
+ painter.drawLine(
+ QtCore.QPointF(split_x, row_rect.top()),
+ QtCore.QPointF(split_x, row_rect.bottom()),
+ )
+
+ painter.setFont(label_font)
+ self._draw_text(
+ painter,
+ QtCore.QRectF(
+ row_rect.left() + mm_to_pixels(1.2, resolution),
+ row_rect.top(),
+ split_x - row_rect.left() - mm_to_pixels(2.0, resolution),
+ row_rect.height(),
+ ),
+ label,
+ QtCore.Qt.AlignVCenter | QtCore.Qt.AlignLeft,
+ )
+
+ painter.setFont(value_font)
+ self._draw_text(
+ painter,
+ QtCore.QRectF(
+ split_x + mm_to_pixels(1.2, resolution),
+ row_rect.top(),
+ row_rect.right() - split_x - mm_to_pixels(2.0, resolution),
+ row_rect.height(),
+ ),
+ value,
+ QtCore.Qt.AlignVCenter | QtCore.Qt.AlignLeft,
+ )
+
+ def _draw_footer(self, painter, rect, options, resolution):
+ lines = self._footer_lines(options)
+ if not lines:
+ return
+
+ label_font = QtGui.QFont('Helvetica')
+ _set_font_pixel_size(label_font, mm_to_pixels(2.7, resolution))
+ label_font.setBold(True)
+ value_font = QtGui.QFont('Helvetica')
+ _set_font_pixel_size(value_font, mm_to_pixels(3.0, resolution))
+
+ line_height = rect.height() / len(lines)
+ split_ratio = 0.28
+ split_x = rect.left() + rect.width() * split_ratio
+
+ for index, (label, value) in enumerate(lines):
+ row_rect = QtCore.QRectF(
+ rect.left(),
+ rect.top() + index * line_height,
+ rect.width(),
+ line_height,
+ )
+
+ painter.setFont(label_font)
+ self._draw_text(
+ painter,
+ QtCore.QRectF(
+ row_rect.left(),
+ row_rect.top(),
+ split_x - row_rect.left() - mm_to_pixels(2.0, resolution),
+ row_rect.height(),
+ ),
+ label,
+ QtCore.Qt.AlignLeft | QtCore.Qt.AlignVCenter,
+ )
+
+ painter.setFont(value_font)
+ self._draw_text(
+ painter,
+ QtCore.QRectF(
+ split_x,
+ row_rect.top(),
+ row_rect.right() - split_x,
+ row_rect.height(),
+ ),
+ value,
+ QtCore.Qt.AlignLeft | QtCore.Qt.AlignVCenter,
+ )
+
+ def _footer_lines(self, options):
+ lines = []
+ if options.footer.show_author and options.footer.author.strip():
+ lines.append(('Author', options.footer.author.strip()))
+ if options.footer.show_date and options.footer.date_of_issue.strip():
+ lines.append(('Date', options.footer.date_of_issue.strip()))
+ return lines
+
+ def _draw_view_plot(self, painter, rect, metrics, view, resolution, options):
+ if rect.isNull():
+ return
+
+ baseline_strip = (
+ mm_to_pixels(18.0, resolution)
+ if options.show_position_line
+ else mm_to_pixels(6.0, resolution)
+ )
+ plot_rect = QtCore.QRectF(rect).adjusted(
+ mm_to_pixels(2.0, resolution),
+ mm_to_pixels(2.0, resolution),
+ -mm_to_pixels(2.0, resolution),
+ -baseline_strip,
+ )
+ if plot_rect.width() <= 0.0 or plot_rect.height() <= 0.0:
+ return
+
+ source_rect = self._visible_view_source_rect(view, metrics)
+ target_size = QtCore.QSizeF(source_rect.size())
+ target_size.scale(plot_rect.size(), QtCore.Qt.KeepAspectRatio)
+ target_rect = QtCore.QRectF(
+ 0.0,
+ 0.0,
+ target_size.width(),
+ target_size.height(),
+ )
+ target_rect.moveCenter(
+ QtCore.QPointF(
+ plot_rect.left() + 0.5 * plot_rect.width(),
+ plot_rect.top() + 0.5 * plot_rect.height(),
+ )
+ )
+
+ view.render(
+ painter,
+ target=target_rect,
+ source=source_rect,
+ aspectRatioMode=QtCore.Qt.KeepAspectRatio,
+ )
+
+ if not options.show_position_line:
+ return
+
+ contour_bounds = self._map_scene_rect_to_target(
+ metrics,
+ view,
+ source_rect,
+ target_rect,
+ )
+ if contour_bounds.isNull():
+ contour_bounds = QtCore.QRectF(target_rect)
+
+ baseline_y = min(
+ rect.bottom() - mm_to_pixels(4.0, resolution),
+ contour_bounds.bottom() + mm_to_pixels(5.0, resolution),
+ )
+ chord_start_x = self._map_scene_x_to_target(
+ view,
+ source_rect,
+ target_rect,
+ metrics.minimum_x,
+ )
+ chord_end_x = self._map_scene_x_to_target(
+ view,
+ source_rect,
+ target_rect,
+ metrics.maximum_x,
+ )
+
+ baseline_pen = QtGui.QPen(QtGui.QColor(75, 75, 75))
+ baseline_pen.setWidthF(mm_to_pixels(0.18, resolution))
+ baseline_pen.setCapStyle(QtCore.Qt.RoundCap)
+ painter.setPen(baseline_pen)
+ painter.drawLine(
+ QtCore.QPointF(chord_start_x, baseline_y),
+ QtCore.QPointF(chord_end_x, baseline_y),
+ )
+ self._draw_baseline_tick(
+ painter,
+ resolution,
+ chord_start_x,
+ baseline_y,
+ )
+ self._draw_baseline_tick(
+ painter,
+ resolution,
+ chord_end_x,
+ baseline_y,
+ )
+
+ label_font = QtGui.QFont('Helvetica')
+ _set_font_pixel_size(label_font, mm_to_pixels(2.9, resolution))
+ painter.setFont(label_font)
+
+ self._draw_position_marker(
+ painter,
+ resolution,
+ metrics,
+ view,
+ source_rect,
+ target_rect,
+ baseline_y,
+ metrics.max_thickness_position,
+ metrics.max_thickness_marker_y,
+ 'T',
+ dash_pattern=[4.0, 2.0],
+ label_offset_mm=2.0,
+ )
+ self._draw_position_marker(
+ painter,
+ resolution,
+ metrics,
+ view,
+ source_rect,
+ target_rect,
+ baseline_y,
+ metrics.max_camber_position,
+ metrics.max_camber_marker_y,
+ 'C',
+ dash_pattern=[1.4, 2.2],
+ label_offset_mm=7.0,
+ )
+
+ def _draw_position_marker(
+ self,
+ painter,
+ resolution,
+ metrics,
+ view,
+ source_rect,
+ target_rect,
+ baseline_y,
+ x_position,
+ marker_y,
+ label,
+ dash_pattern,
+ label_offset_mm,
+ ):
+ if x_position is None or marker_y is None:
+ return
+
+ mapped_point = self._map_scene_point_to_target(
+ view,
+ source_rect,
+ target_rect,
+ QtCore.QPointF(float(x_position), float(marker_y)),
+ )
+ x_pixel = mapped_point.x()
+ y_pixel = mapped_point.y()
+
+ guide_pen = QtGui.QPen(QtGui.QColor(90, 90, 90))
+ guide_pen.setWidthF(mm_to_pixels(0.16, resolution))
+ guide_pen.setStyle(QtCore.Qt.CustomDashLine)
+ guide_pen.setDashPattern(dash_pattern)
+ guide_pen.setCapStyle(QtCore.Qt.RoundCap)
+ painter.setPen(guide_pen)
+ painter.drawLine(
+ QtCore.QPointF(x_pixel, baseline_y),
+ QtCore.QPointF(x_pixel, y_pixel),
+ )
+
+ self._draw_baseline_tick(
+ painter,
+ resolution,
+ x_pixel,
+ baseline_y,
+ )
+ marker_pen = QtGui.QPen(QtGui.QColor(55, 55, 55))
+ marker_pen.setWidthF(mm_to_pixels(0.22, resolution))
+ painter.setPen(marker_pen)
+ painter.drawEllipse(
+ QtCore.QPointF(x_pixel, y_pixel),
+ mm_to_pixels(0.62, resolution),
+ mm_to_pixels(0.62, resolution),
+ )
+
+ text_rect = QtCore.QRectF(
+ x_pixel - mm_to_pixels(12.0, resolution),
+ baseline_y + mm_to_pixels(label_offset_mm, resolution),
+ mm_to_pixels(24.0, resolution),
+ mm_to_pixels(4.5, resolution),
+ )
+ self._draw_position_label(
+ painter,
+ text_rect,
+ label,
+ self._station_text(metrics, x_position),
+ )
+
+ def _draw_text(self, painter, rect, text, flags):
+ painter.drawText(rect, int(flags), text)
+
+ def _draw_position_label(self, painter, rect, label, station_text):
+ painter.save()
+
+ base_font = QtGui.QFont(painter.font())
+ base_metrics = QtGui.QFontMetricsF(base_font)
+ sub_font = _scaled_font(base_font, 0.62)
+ sub_metrics = QtGui.QFontMetricsF(sub_font)
+
+ subscript = 'max'
+ gap_width = base_metrics.horizontalAdvance(' ')
+ label_width = base_metrics.horizontalAdvance(label)
+ subscript_width = sub_metrics.horizontalAdvance(subscript)
+ value_width = base_metrics.horizontalAdvance(station_text)
+ total_width = label_width + subscript_width + gap_width + value_width
+
+ origin_x = rect.left() + max(0.0, 0.5 * (rect.width() - total_width))
+ baseline_y = rect.top() + base_metrics.ascent()
+ subscript_baseline = baseline_y + base_metrics.height() * 0.22
+
+ painter.setFont(base_font)
+ painter.drawText(QtCore.QPointF(origin_x, baseline_y), label)
+
+ painter.setFont(sub_font)
+ painter.drawText(
+ QtCore.QPointF(origin_x + label_width, subscript_baseline),
+ subscript,
+ )
+
+ painter.setFont(base_font)
+ painter.drawText(
+ QtCore.QPointF(
+ origin_x + label_width + subscript_width + gap_width,
+ baseline_y,
+ ),
+ station_text,
+ )
+ painter.restore()
+
+ def _percent_text(self, value):
+ if value is None:
+ return 'n/a'
+ return f'{value * 100.0:.2f} %'
+
+ def _station_text(self, metrics, x_position):
+ if x_position is None or metrics.chord <= 0.0:
+ return 'n/a'
+ station = (float(x_position) - metrics.minimum_x) / metrics.chord
+ return f'{station * 100.0:.2f} %'
+
+ def _draw_baseline_tick(self, painter, resolution, x_value, baseline_y):
+ tick_pen = QtGui.QPen(QtGui.QColor(70, 70, 70))
+ tick_pen.setWidthF(mm_to_pixels(0.22, resolution))
+ tick_pen.setCapStyle(QtCore.Qt.RoundCap)
+ painter.setPen(tick_pen)
+ tick_half = mm_to_pixels(1.5, resolution)
+ painter.drawLine(
+ QtCore.QPointF(x_value, baseline_y - tick_half),
+ QtCore.QPointF(x_value, baseline_y + tick_half),
+ )
+
+ def _map_scene_rect_to_target(self, metrics, view, source_rect, target_rect):
+ x_values, y_values = metrics.contour_coordinates
+ if len(x_values) == 0:
+ return QtCore.QRectF()
+
+ mapped_points = [
+ self._map_scene_point_to_target(
+ view,
+ source_rect,
+ target_rect,
+ QtCore.QPointF(float(x_value), float(y_value)),
+ )
+ for x_value, y_value in zip(x_values, y_values)
+ ]
+ polygon = QtGui.QPolygonF(mapped_points)
+ return polygon.boundingRect()
+
+ def _map_scene_point_to_target(self, view, source_rect, target_rect, scene_point):
+ viewport_point = view.mapFromScene(scene_point)
+ if source_rect.width() == 0 or source_rect.height() == 0:
+ return QtCore.QPointF(target_rect.left(), target_rect.top())
+
+ scale_x = target_rect.width() / source_rect.width()
+ scale_y = target_rect.height() / source_rect.height()
+ return QtCore.QPointF(
+ target_rect.left() + (viewport_point.x() - source_rect.left()) * scale_x,
+ target_rect.top() + (viewport_point.y() - source_rect.top()) * scale_y,
+ )
+
+ def _map_scene_x_to_target(self, view, source_rect, target_rect, x_value):
+ point = self._map_scene_point_to_target(
+ view,
+ source_rect,
+ target_rect,
+ QtCore.QPointF(float(x_value), 0.0),
+ )
+ return point.x()
+
+ def _visible_view_source_rect(self, view, metrics):
+ viewport_rect = view.viewport().rect()
+ scene = view.scene()
+ if scene is None:
+ return QtCore.QRect(viewport_rect)
+
+ visible_scene_rect = QtCore.QRectF()
+ for item in scene.items():
+ if not item.isVisible():
+ continue
+ item_rect = item.sceneBoundingRect()
+ if item_rect.isNull() or not item_rect.isValid():
+ continue
+ if visible_scene_rect.isNull():
+ visible_scene_rect = QtCore.QRectF(item_rect)
+ else:
+ visible_scene_rect = visible_scene_rect.united(item_rect)
+
+ if visible_scene_rect.isNull():
+ x_values, y_values = metrics.contour_coordinates
+ if len(x_values):
+ polygon = QtGui.QPolygonF(
+ [QtCore.QPointF(float(x), float(y)) for x, y in zip(x_values, y_values)]
+ )
+ visible_scene_rect = polygon.boundingRect()
+
+ if visible_scene_rect.isNull():
+ return QtCore.QRect(viewport_rect)
+
+ mapped_rect = view.mapFromScene(visible_scene_rect).boundingRect()
+ mapped_rect = mapped_rect.intersected(viewport_rect)
+ if mapped_rect.isNull() or not mapped_rect.isValid():
+ return QtCore.QRect(viewport_rect)
+
+ horizontal_padding = max(8, int(round(viewport_rect.width() * 0.01)))
+ vertical_padding = max(8, int(round(viewport_rect.height() * 0.02)))
+ padded_rect = mapped_rect.adjusted(
+ -horizontal_padding,
+ -vertical_padding,
+ horizontal_padding,
+ vertical_padding,
+ )
+ padded_rect = padded_rect.intersected(viewport_rect)
+ if padded_rect.isNull() or not padded_rect.isValid():
+ return QtCore.QRect(viewport_rect)
+ return QtCore.QRect(padded_rect)
diff --git a/src/PyAero.py b/src/PyAero.py
index e1945e4..be1f711 100644
--- a/src/PyAero.py
+++ b/src/PyAero.py
@@ -15,79 +15,87 @@
import os
import sys
import platform
-
-path_of_this_file = os.path.dirname(__file__)
-sys.path.append(path_of_this_file)
-
import datetime
+import logging
+
+# Add the directory containing the script to the sys.path
+sys.path.append(os.path.dirname(os.path.abspath(__file__)))
from PySide6 import QtGui, QtCore, QtWidgets
+import Settings
import MenusTools
+import ActionRegistry
import GraphicsView
import GraphicsScene
import GuiSlots
import ContourAnalysis
import ToolBox
-from Settings import ICONS, LOCALE, EXITONESCAPE, \
- OUTPUTDATA, MENUDATA, VIEWSTYLE, LOGDATA
-import Logger
-import ShortCuts
import BatchMode
+import Logger
+import Icons
+
+
+logger = logging.getLogger(__name__)
__appname__ = 'PyAero'
__author__ = 'Andreas Ennemoser'
-__credits__ = 'Internet and open source'
year = str(datetime.date.today().strftime("%Y"))
__copyright__ = '2014-' + year + ' ' + __author__
__license__ = 'MIT'
-__version__ = '2.1.7'
+__version__ = '3.0.0'
__email__ = 'andreas.ennemoser@aon.at'
-__status__ = 'Release'
+
+WINDOW_PRESET_ACTION_IDS = (
+ ('view.window_size_preset_1', 1),
+ ('view.window_size_preset_2', 2),
+ ('view.window_size_preset_3', 3),
+)
class MainWindow(QtWidgets.QMainWindow):
"""PyAero's main QT window"""
- # constructor of MainWindow
+
+ # Initialize the MainWindow
def __init__(self, app):
super().__init__()
self.app = app
self.app.mainwindow = self
self.platform = platform.system()
+ self.config = Settings.Config(self)
self.airfoil = None
self.airfoils = []
+ self._active_window_preset = None
self.scene = GraphicsScene.GraphicsScene(self)
- self.view = GraphicsView.GraphicsView(self, self.scene)
- self.view.viewstyle = VIEWSTYLE
+ self.view = GraphicsView.GraphicsView(self.scene)
+ self.view.viewstyle = self.VIEW_STYLE
self.contourview = ContourAnalysis.ContourAnalysis(canvas=True)
self.slots = GuiSlots.Slots(self)
- self.centralwidget = CentralWidget(self)
- self.setCentralWidget(self.centralwidget)
- self._setupShortcuts()
- self.testitems = False
+ # The QMainWindow class is designed around a specific architecture that includes
+ # dedicated areas for menus, toolbars, dock widgets, a status bar, and a main content area.
+ # The central widget is the widget that occupies this main content area.
+ self.mainArea = MainContentArea(self)
+ self.setCentralWidget(self.mainArea)
+ self.action_registry = ActionRegistry.ActionRegistry(self)
+ self.action_registry.install()
self.checkEnvironment()
self.init_GUI()
- Logger.log(self)
- def _setupShortcuts(self):
- sc = ShortCuts.ShortCuts(self)
- sc.addShortcut('ALT+m', 'toggleLogDock', 'shortcut')
- sc.addShortcut('ALT+t', 'toggleTestObjects')
+ Logger.log(self)
def init_GUI(self):
-
# window size, position and title
- # self.setGeometry(700, 100, 1200, 900)
- self.showMaximized()
- title = __appname__ + ' - Airfoil Contour Analysis and CFD Meshing'
- self.setWindowTitle = title
+ title = __appname__ + ' - Airfoil CFD Meshing and Contour Analysis'
+ self.setWindowTitle(title)
+
+ self.applyRuntimeSettings(apply_window_mode=True)
# create menus and tools of main window
menusTools = MenusTools.MenusTools(self)
@@ -111,210 +119,650 @@ def init_GUI(self):
# show the GUI
self.show()
+ def applyRuntimeSettings(self, apply_window_mode=False):
+ # decimal separator used in spin boxes, etc.
+ if self.DECIMAL_SEPARATOR == '.':
+ QtCore.QLocale.setDefault(QtCore.QLocale.c())
+ elif self.DECIMAL_SEPARATOR == ',':
+ QtCore.QLocale.setDefault(
+ QtCore.QLocale.German,
+ QtCore.QLocale.Germany,
+ )
+
+ if hasattr(self, 'view'):
+ self.view.applyViewSettings()
+
+ if hasattr(self, 'action_registry'):
+ self.action_registry.apply_shortcuts()
+ self.refreshWindowSizeActionLabels()
+
+ if apply_window_mode:
+ self.applyConfiguredWindowStartupMode()
+
+ def configuredWindowPreset(self, preset_index):
+ preset_text = getattr(self, f'WINDOW_PRESET_{int(preset_index)}', '')
+ return Settings.parse_window_geometry(preset_text)
+
+ def windowPresetActionLabel(self, preset_index):
+ try:
+ _x, _y, width, height = self.configuredWindowPreset(preset_index)
+ except ValueError:
+ return f'Preset {preset_index}'
+ return f'Preset {preset_index} ({width} x {height})'
+
+ def refreshWindowSizeActionLabels(self):
+ if not hasattr(self, 'action_registry'):
+ return
+
+ for action_id, preset_index in WINDOW_PRESET_ACTION_IDS:
+ action = self.action_registry.action(action_id)
+ if action is None:
+ continue
+
+ action.setText(self.windowPresetActionLabel(preset_index))
+ try:
+ x_pos, y_pos, width, height = self.configuredWindowPreset(
+ preset_index
+ )
+ action.setToolTip(
+ 'Resize the main window to '
+ f'x={x_pos}, y={y_pos}, width={width}, height={height}'
+ )
+ action.setStatusTip(action.toolTip())
+ except ValueError as error:
+ action.setToolTip(
+ f'Preset {preset_index} is invalid in config/config.ini: {error}'
+ )
+ action.setStatusTip(action.toolTip())
+
+ def applyConfiguredWindowStartupMode(self):
+ try:
+ mode = Settings.normalize_window_startup_mode(
+ getattr(self, 'WINDOW_STARTUP_MODE', 'maximized')
+ )
+ except ValueError as error:
+ logger.warning('Invalid window startup mode: %s', error)
+ self._active_window_preset = None
+ self.showMaximized()
+ return False
+
+ if mode == 'maximized':
+ self._active_window_preset = None
+ self.showMaximized()
+ return True
+
+ preset_index = int(mode.rsplit('_', 1)[-1])
+ return self.applyWindowSizePreset(preset_index)
+
+ def applyWindowSizePreset(self, preset_index):
+ try:
+ x_pos, y_pos, width, height = self.configuredWindowPreset(
+ preset_index
+ )
+ except ValueError as error:
+ message = (
+ f'Window preset {preset_index} is invalid:\n\n{error}'
+ )
+ logger.warning(message)
+ if hasattr(self, 'slots'):
+ self.slots.messageBox(message)
+ return False
+
+ self.showNormal()
+ self.setGeometry(x_pos, y_pos, width, height)
+ self.raise_()
+ self.activateWindow()
+ self._active_window_preset = int(preset_index)
+ return True
+
+ def cycleWindowSizePreset(self):
+ presets = [preset_index for _action_id, preset_index in WINDOW_PRESET_ACTION_IDS]
+ if self._active_window_preset not in presets:
+ next_preset = presets[0]
+ else:
+ current_index = presets.index(self._active_window_preset)
+ next_preset = presets[(current_index + 1) % len(presets)]
+ return self.applyWindowSizePreset(next_preset)
+
def checkEnvironment(self):
+ """Check if the environment is set up correctly"""
# check if path is correct
- if not os.path.exists(MENUDATA):
+ if not os.path.exists('resources/Menus'):
error_message = (
- f'\n PyAero ERROR: Folder {MENUDATA} does not exist.\n'
- ' PyAero ERROR: Maybe you are starting PyAero from the wrong location.\n'
+ f'\n PyAero-ERROR: Folder "resources/Menus" does not exist.\n'
+ ' PyAero-ERROR: Either the installation is incomplete or you are starting from the wrong location.\n'
)
print(error_message)
sys.exit()
- # check if output folder does exist
- if not os.path.exists(OUTPUTDATA):
- os.mkdir(OUTPUTDATA, mode=0o777)
- print('Folder %s created.' % (OUTPUTDATA))
+ # Ensure output folder exists
+ os.makedirs(self.OUTPUT, mode=0o777, exist_ok=True)
- # check if logs folder does exist
- if not os.path.exists(LOGDATA):
- os.mkdir(LOGDATA, mode=0o777)
- print('Folder %s created.' % (LOGDATA))
+ # Ensure logs folder exists
+ os.makedirs(self.LOGS, mode=0o777, exist_ok=True)
def keyPressEvent(self, event):
- """Catch keypress events in main window
+ """Forward keypress events to Qt's action system."""
+ super().keyPressEvent(event)
- Args:
- event (QKeyEvent): key event sent to the widget with
- keyboard input focus
- """
- key = event.key()
- if key == QtCore.Qt.Key_Escape and EXITONESCAPE:
- sys.exit(self.app.exit(retcode=0))
- elif key == QtCore.Qt.Key_Home:
- self.slots.onViewAll()
- else:
- # progress event
- super().keyPressEvent(event)
+class MainContentArea(QtWidgets.QWidget):
+ """
+ MainContentArea is a custom QWidget that serves as the central widget for the main window.
+ It contains a splitter that divides the window into two panes:
+ - a workflow sidebar on the left
+ - a viewer workspace on the right.
-class CentralWidget(QtWidgets.QWidget):
- """
- CentralWidget is a custom QWidget that serves as the central widget for the main window.
- It contains a splitter that divides the window into two panes: a toolbox and viewing options pane on the left,
- and a tabbed widget for different views on the right.
-
- Attributes:
- parent (QWidget): The parent widget.
- splitter (QSplitter): The main splitter dividing the window horizontally.
- toolbox (ToolBox.Toolbox): A toolbox widget for various tools.
- viewing_options (QGroupBox): A group box containing viewing options checkboxes.
- left_pane (QWidget): The left pane containing the toolbox and viewing options.
- tabs (QTabWidget): The tabbed widget containing different views.
-
- Methods:
- __init__(self, parent=None):
- Initializes the CentralWidget, sets up the layout, and connects signals.
-
- viewingOptions(self):
- Creates and configures the viewing options group box with checkboxes.
"""
- # call constructor of CentralWidget
+
+ WORKSPACE_VIEWER_INDEX = 0
+ WORKSPACE_ANALYSIS_INDEX = 1
+
def __init__(self, parent=None):
# call constructor of QWidget
super().__init__(parent)
self.parent = parent
+ self._applyWorkspaceStyles()
# split main window horizontally into two panes
self.splitter = QtWidgets.QSplitter(QtCore.Qt.Horizontal)
- # create QToolBox widget
- self.toolbox = ToolBox.Toolbox(self.parent)
-
- # create box where viewing options are placed
- self.viewingOptions()
-
- horizontal_line = QtWidgets.QFrame()
- horizontal_line.setFrameShape(QtWidgets.QFrame.HLine)
- horizontal_line.setFrameShadow(QtWidgets.QFrame.Sunken)
+ # create workflow sidebar widget
+ self.toolbox = ToolBox.Toolbox()
self.left_pane = QtWidgets.QWidget()
vbox = QtWidgets.QVBoxLayout()
+ vbox.setContentsMargins(0, 0, 0, 0)
+ vbox.setSpacing(0)
vbox.addWidget(self.toolbox)
- vbox.addStretch(5)
- vbox.addWidget(horizontal_line)
- vbox.addSpacing(15)
- vbox.addWidget(self.viewing_options)
self.left_pane.setLayout(vbox)
-
- # create tabbed windows for viewing
- self.tabs = QtWidgets.QTabWidget()
- self.tabs.addTab(self.parent.view, 'Airfoil Viewer')
- self.tabs.addTab(self.parent.contourview, 'Contour Analysis')
-
- # connect tab changed signal to slot
- self.tabs.currentChanged.connect(self.parent.slots.onTabChanged)
+ self.left_pane.setMinimumWidth(420)
+
+ self.createWorkspacePanel()
+ self.createMessagePanel()
+ self.createViewerControlsPanel()
+
+ self.utility_splitter = QtWidgets.QSplitter(QtCore.Qt.Horizontal)
+ self.utility_splitter.setChildrenCollapsible(False)
+ self.utility_splitter.addWidget(self.message_panel)
+ self.utility_splitter.addWidget(self.viewer_controls_panel)
+ self.utility_splitter.setStretchFactor(0, 1)
+ self.utility_splitter.setStretchFactor(1, 1)
+ self.utility_splitter.setSizes([560, 560])
+
+ self.right_splitter = QtWidgets.QSplitter(QtCore.Qt.Vertical)
+ self.right_splitter.setChildrenCollapsible(True)
+ self.right_splitter.addWidget(self.viewer_panel)
+ self.right_splitter.addWidget(self.utility_splitter)
+ self.right_splitter.setStretchFactor(0, 1)
+ self.right_splitter.setStretchFactor(1, 0)
+ self.right_splitter.setSizes([900, 170])
+
+ self.right_pane = QtWidgets.QWidget()
+ right_layout = QtWidgets.QVBoxLayout()
+ right_layout.setContentsMargins(0, 0, 0, 0)
+ right_layout.setSpacing(0)
+ right_layout.addWidget(self.right_splitter)
+ self.right_pane.setLayout(right_layout)
# add splitter panes
self.splitter.addWidget(self.left_pane)
- self.splitter.addWidget(self.tabs)
- self.splitter.setSizes([100, 1300]) # initial hint for splitter spacing
+ self.splitter.addWidget(self.right_pane)
+ self.splitter.setStretchFactor(1, 1)
+ self.splitter.setSizes([420, 1180])
# put splitter in a layout box
hbox = QtWidgets.QHBoxLayout()
+ hbox.setContentsMargins(0, 0, 0, 0)
hbox.addWidget(self.splitter)
self.setLayout(hbox)
- def viewingOptions(self):
- self.viewing_options = QtWidgets.QGroupBox('Viewing Options')
+ self.updateWorkspaceChrome(self.tabs.currentIndex())
- # Set font size via CSS workaround
- font = self.viewing_options.font()
- font.setPointSize(13)
- self.viewing_options.setFont(font)
-
- # Layouts for organizing checkboxes
- hbox = QtWidgets.QHBoxLayout()
- vbox1 = QtWidgets.QVBoxLayout()
- vbox2 = QtWidgets.QVBoxLayout()
- self.viewing_options.setLayout(hbox)
-
- # Checkboxes for viewing options
- checkboxes = [
- ('Message Window', True, True, self.parent.slots.toggleLogDock, 'tick'),
- ('Airfoil Points', False, False, self.toolbox.toggleRawPoints),
- ('Airfoil Raw Contour', False, False, self.toolbox.toggleRawContour),
- ('Airfoil Spline Points', False, False, self.toolbox.toggleSplinePoints),
- ('Airfoil Spline Contour', False, False, self.toolbox.toggleSpline),
- ('Airfoil Chord', False, False, self.toolbox.toggleChord),
- ('Mesh', False, False, self.toolbox.toggleMesh),
- ('Leading Edge Circle', False, False, self.toolbox.toggleLeCircle),
- ('Mesh Blocks', False, False, self.toolbox.toggleMeshBlocks),
- ('Airfoil Camber Line', False, False, self.toolbox.toggleCamberLine)
+ def _applyWorkspaceStyles(self):
+ self.setStyleSheet(
+ """
+ QFrame[chromePanel="true"] {
+ background: #f7f8fa;
+ border: 1px solid #dbe3ee;
+ border-radius: 8px;
+ }
+ QLabel[chromeLabel="true"] {
+ color: #6b7788;
+ font-size: 14px;
+ font-weight: 700;
+ letter-spacing: 0.08em;
+ }
+ QFrame[chromeInner="true"] {
+ background: #ffffff;
+ border: 1px solid #d6dfeb;
+ border-radius: 6px;
+ }
+ QLabel[workspaceHint="true"] {
+ color: #6b7788;
+ font-size: 12px;
+ line-height: 1.4em;
+ }
+ QLabel[viewerSectionLabel="true"] {
+ color: #6b7788;
+ font-size: 11px;
+ font-weight: 700;
+ letter-spacing: 0.08em;
+ }
+ QTextEdit#messageTextEdit {
+ background: transparent;
+ border: none;
+ font-family: "Menlo", "Monaco", "Courier New";
+ font-size: 12px;
+ padding: 0px;
+ }
+ QToolButton[workspaceMode="true"] {
+ background: transparent;
+ border: 1px solid transparent;
+ border-radius: 4px;
+ color: #425468;
+ font-weight: 600;
+ padding: 6px 10px;
+ }
+ QToolButton[workspaceMode="true"]:hover {
+ background: #eef3f8;
+ border-color: #d6dfeb;
+ }
+ QToolButton[workspaceMode="true"]:checked {
+ background: #e8eef5;
+ border-color: #b8c8db;
+ color: #223041;
+ }
+ QToolButton[viewToggle="true"] {
+ background: #ffffff;
+ border: 1px solid #d6dfeb;
+ border-radius: 4px;
+ color: #223041;
+ font-weight: 600;
+ padding: 7px 10px;
+ }
+ QToolButton[viewToggle="true"]:hover {
+ background: #f3f6fa;
+ border-color: #9fb7d7;
+ }
+ QToolButton[viewToggle="true"]:checked {
+ background: #e7edf4;
+ border-color: #7e95b4;
+ color: #1c2c40;
+ }
+ QToolButton[viewAction="true"] {
+ background: #f8fafc;
+ border: 1px solid #d6dfeb;
+ border-radius: 4px;
+ color: #425468;
+ font-weight: 600;
+ padding: 7px 10px;
+ }
+ QToolButton[viewAction="true"]:hover {
+ background: #f3f6fa;
+ border-color: #b8c8db;
+ }
+ QFrame[viewerActionGroup="true"] {
+ background: #f6f9fc;
+ border: 1px solid #dde6ef;
+ border-radius: 6px;
+ }
+ """
+ )
+
+ def _createChromePanel(self, title, object_name):
+ panel = QtWidgets.QFrame()
+ panel.setObjectName(object_name)
+ panel.setProperty('chromePanel', 'true')
+
+ panel_layout = QtWidgets.QVBoxLayout()
+ panel_layout.setContentsMargins(14, 12, 14, 12)
+ panel_layout.setSpacing(8)
+ panel.setLayout(panel_layout)
+
+ header = QtWidgets.QWidget()
+ header_layout = QtWidgets.QHBoxLayout()
+ header_layout.setContentsMargins(0, 0, 0, 0)
+ header_layout.setSpacing(8)
+ header.setLayout(header_layout)
+
+ label = QtWidgets.QLabel(title)
+ label.setProperty('chromeLabel', 'true')
+ header_layout.addWidget(label)
+ panel_layout.addWidget(header)
+
+ inner = QtWidgets.QFrame()
+ inner.setProperty('chromeInner', 'true')
+ inner_layout = QtWidgets.QVBoxLayout()
+ inner_layout.setContentsMargins(12, 12, 12, 12)
+ inner_layout.setSpacing(10)
+ inner.setLayout(inner_layout)
+ panel_layout.addWidget(inner, stretch=1)
+
+ return panel, header_layout, inner_layout
+
+ def createWorkspacePanel(self):
+ self.viewer_panel, header_layout, inner_layout = self._createChromePanel(
+ 'VIEWER',
+ 'viewerPanel',
+ )
+
+ self.viewer_workspace_button = self._makeWorkspaceModeButton('Viewer')
+ self.analysis_workspace_button = self._makeWorkspaceModeButton(
+ 'Contour Analysis'
+ )
+ self.viewer_workspace_button.clicked.connect(
+ lambda: self.tabs.setCurrentIndex(0)
+ )
+ self.analysis_workspace_button.clicked.connect(
+ lambda: self.tabs.setCurrentIndex(1)
+ )
+ header_layout.addSpacing(10)
+ header_layout.addWidget(self.viewer_workspace_button)
+ header_layout.addWidget(self.analysis_workspace_button)
+ header_layout.addStretch(1)
+
+ self.tabs = QtWidgets.QStackedWidget()
+ self.tabs.setObjectName('workspaceStack')
+ self.tabs.addWidget(self.parent.view)
+ self.tabs.addWidget(self.parent.contourview)
+ self.tabs.currentChanged.connect(self.parent.slots.onTabChanged)
+ inner_layout.addWidget(self.tabs, stretch=1)
+
+ def createMessagePanel(self):
+ self.message_panel, header_layout, inner_layout = self._createChromePanel(
+ 'MESSAGES',
+ 'messagePanel',
+ )
+ self.message_panel.setMinimumHeight(120)
+ self.message_panel.setMinimumWidth(320)
+ header_layout.addStretch(1)
+
+ self.parent.messages = QtWidgets.QTextEdit(self.parent)
+ self.parent.messages.setObjectName('messageTextEdit')
+ self.parent.messages.setTextInteractionFlags(
+ QtCore.Qt.TextSelectableByMouse | QtCore.Qt.TextSelectableByKeyboard
+ )
+ self.parent.messages.setAcceptRichText(True)
+ self.parent.messages.textChanged.connect(self.parent.slots.onTextChanged)
+ inner_layout.addWidget(self.parent.messages, stretch=1)
+
+ self.parent.messagedock = self.message_panel
+
+ def createViewerControlsPanel(self):
+ self.viewer_controls_panel, header_layout, inner_layout = self._createChromePanel(
+ 'VIEWER CONTROLS',
+ 'viewerControlsPanel',
+ )
+ self.viewer_controls_panel.setMinimumWidth(360)
+ header_layout.addStretch(1)
+
+ self.viewer_controls_stack = QtWidgets.QStackedWidget()
+ inner_layout.addWidget(self.viewer_controls_stack, stretch=1)
+
+ controls_page = QtWidgets.QWidget()
+ controls_page_layout = QtWidgets.QVBoxLayout()
+ controls_page_layout.setContentsMargins(0, 0, 0, 0)
+ controls_page_layout.setSpacing(0)
+
+ controls_content = QtWidgets.QWidget()
+ controls_content.setSizePolicy(
+ QtWidgets.QSizePolicy.Maximum,
+ QtWidgets.QSizePolicy.Maximum,
+ )
+ controls_content_layout = QtWidgets.QVBoxLayout()
+ controls_content_layout.setContentsMargins(0, 0, 0, 0)
+ controls_content_layout.setSpacing(12)
+ controls_content.setLayout(controls_content_layout)
+
+ controls_group = QtWidgets.QWidget()
+ controls_group.setSizePolicy(
+ QtWidgets.QSizePolicy.Maximum,
+ QtWidgets.QSizePolicy.Maximum,
+ )
+ controls_grid = QtWidgets.QGridLayout()
+ controls_grid.setContentsMargins(0, 0, 0, 0)
+ controls_grid.setHorizontalSpacing(8)
+ controls_grid.setVerticalSpacing(8)
+ controls_group.setLayout(controls_grid)
+ controls_content_layout.addWidget(controls_group)
+ controls_page.setLayout(controls_page_layout)
+ controls_columns = 4
+
+ controls = [
+ ('Messages', 'message_window_checkbox', True, True,
+ self.parent.slots.toggleLogDock, 'Message Window', 'tick'),
+ ('Raw Pts', 'airfoil_points_checkbox', False, False,
+ self.toolbox.toggleRawPoints, 'Airfoil Points'),
+ ('Raw', 'airfoil_raw_contour_checkbox', False, False,
+ self.toolbox.toggleRawContour, 'Airfoil Raw Contour'),
+ ('Spline Pts', 'airfoil_spline_points_checkbox', False, False,
+ self.toolbox.toggleSplinePoints, 'Airfoil Spline Points'),
+ ('Spline', 'airfoil_spline_contour_checkbox', False, False,
+ self.toolbox.toggleSpline, 'Airfoil Spline Contour'),
+ ('Fill', 'airfoil_spline_fill_checkbox', False, False,
+ self.toolbox.toggleSplineFill, 'Spline Preview Fill'),
+ ('Chord', 'airfoil_chord_checkbox', False, False,
+ self.toolbox.toggleChord, 'Airfoil Chord'),
+ ('Mesh', 'mesh_checkbox', False, False,
+ self.toolbox.toggleMesh, 'Mesh'),
+ ('LE Circle', 'leading_edge_circle_checkbox', False, False,
+ self.toolbox.toggleLeCircle, 'Leading Edge Circle'),
+ ('Blocks', 'mesh_blocks_checkbox', False, False,
+ self.toolbox.toggleMeshBlocks, 'Mesh Blocks'),
+ ('Camber', 'airfoil_camber_line_checkbox', False, False,
+ self.toolbox.toggleCamberLine, 'Airfoil Camber Line'),
+ ('C Circles', 'airfoil_camber_circles_checkbox', False, False,
+ self.toolbox.toggleCamberCircles, 'Airfoil Camber Circles'),
+ ('Max Thick', 'airfoil_max_thickness_checkbox', False, False,
+ self.toolbox.toggleMaxThicknessMarker, 'Maximum Thickness Marker'),
+ ('Max Camber', 'airfoil_max_camber_checkbox', False, False,
+ self.toolbox.toggleMaxCamberMarker, 'Maximum Camber Marker'),
]
- # Create and add checkboxes to layouts
- for i, (label, checked, enabled, slot, *args) in enumerate(checkboxes):
- checkbox = QtWidgets.QCheckBox(label)
- checkbox.setChecked(checked)
- checkbox.setEnabled(enabled)
- if args:
- checkbox.clicked.connect(lambda _, s=slot, a=args[0]: s(a))
- else:
- checkbox.clicked.connect(slot)
- if i == 0:
- vbox2.addWidget(checkbox)
- else:
- vbox1.addWidget(checkbox)
-
- # Set attribute for each checkbox with a meaningful name
- attribute_name = label.lower().replace(' ', '_') + '_checkbox'
- setattr(self, attribute_name, checkbox)
-
- hbox.addLayout(vbox1)
- hbox.addLayout(vbox2)
- hbox.setAlignment(QtCore.Qt.AlignTop)
+ for index, control in enumerate(controls):
+ short_label, attribute_name, checked, enabled, slot, tooltip, *args = control
+ button = self._makeViewerToggleButton(
+ short_label,
+ checked=checked,
+ enabled=enabled,
+ slot=slot,
+ tooltip=tooltip,
+ argument=args[0] if args else None,
+ )
+ setattr(self, attribute_name, button)
+ controls_grid.addWidget(button, index // controls_columns, index % controls_columns)
+
+ fit_airfoil_button = self._makeViewerActionButton(
+ 'Fit Airfoil',
+ self.parent.slots.fitAirfoilInView,
+ )
+ fit_button = self._makeViewerActionButton(
+ 'Fit View',
+ self.parent.slots.onViewAll,
+ )
+ background_button = self._makeViewerActionButton(
+ 'Background',
+ self.parent.slots.onBackground,
+ )
+ action_group = QtWidgets.QFrame()
+ action_group.setProperty('viewerActionGroup', 'true')
+ action_group.setSizePolicy(
+ QtWidgets.QSizePolicy.Maximum,
+ QtWidgets.QSizePolicy.Maximum,
+ )
+ action_group_layout = QtWidgets.QVBoxLayout()
+ action_group_layout.setContentsMargins(10, 10, 10, 10)
+ action_group_layout.setSpacing(8)
+ action_group.setLayout(action_group_layout)
+
+ action_label = QtWidgets.QLabel('VIEW ACTIONS')
+ action_label.setProperty('viewerSectionLabel', 'true')
+ action_group_layout.addWidget(action_label)
+
+ action_row = QtWidgets.QHBoxLayout()
+ action_row.setContentsMargins(0, 0, 0, 0)
+ action_row.setSpacing(8)
+ action_row.addWidget(fit_airfoil_button)
+ action_row.addWidget(fit_button)
+ action_row.addWidget(background_button)
+ action_group_layout.addLayout(action_row)
+
+ controls_content_layout.addWidget(action_group)
+ controls_page_layout.addWidget(
+ controls_content,
+ 0,
+ QtCore.Qt.AlignTop | QtCore.Qt.AlignLeft,
+ )
+ controls_page_layout.addStretch(1)
+
+ placeholder_page = QtWidgets.QWidget()
+ placeholder_layout = QtWidgets.QVBoxLayout()
+ placeholder_layout.setContentsMargins(0, 0, 0, 0)
+ placeholder_layout.setSpacing(8)
+ placeholder_page.setLayout(placeholder_layout)
+
+ placeholder_label = QtWidgets.QLabel(
+ 'Switch back to Airfoil Viewer to adjust contour, mesh, and view overlays.'
+ )
+ placeholder_label.setProperty('workspaceHint', 'true')
+ placeholder_label.setWordWrap(True)
+ placeholder_layout.addWidget(placeholder_label)
+ placeholder_layout.addStretch(1)
+
+ self.viewer_controls_stack.addWidget(controls_page)
+ self.viewer_controls_stack.addWidget(placeholder_page)
+
+ def resetAirfoilViewControls(self):
+ toggle_names = (
+ 'airfoil_points_checkbox',
+ 'airfoil_raw_contour_checkbox',
+ 'airfoil_spline_points_checkbox',
+ 'airfoil_spline_contour_checkbox',
+ 'airfoil_spline_fill_checkbox',
+ 'airfoil_chord_checkbox',
+ 'mesh_checkbox',
+ 'leading_edge_circle_checkbox',
+ 'mesh_blocks_checkbox',
+ 'airfoil_camber_line_checkbox',
+ 'airfoil_camber_circles_checkbox',
+ 'airfoil_max_thickness_checkbox',
+ 'airfoil_max_camber_checkbox',
+ )
+ for name in toggle_names:
+ button = getattr(self, name, None)
+ if button is None:
+ continue
+ blocker = QtCore.QSignalBlocker(button)
+ button.setChecked(False)
+ button.setEnabled(False)
+ del blocker
+
+ def _makeWorkspaceModeButton(self, text):
+ button = QtWidgets.QToolButton()
+ button.setText(text)
+ button.setCheckable(True)
+ button.setProperty('workspaceMode', 'true')
+ button.setCursor(QtCore.Qt.PointingHandCursor)
+ return button
+
+ def _makeViewerToggleButton(self, text, checked, enabled, slot, tooltip, argument=None):
+ button = QtWidgets.QToolButton()
+ button.setText(text)
+ button.setToolTip(tooltip)
+ button.setCheckable(True)
+ button.setChecked(checked)
+ button.setEnabled(enabled)
+ button.setProperty('viewToggle', 'true')
+ button.setCursor(QtCore.Qt.PointingHandCursor)
+ button.setSizePolicy(
+ QtWidgets.QSizePolicy.Fixed,
+ QtWidgets.QSizePolicy.Fixed,
+ )
+ button.setMinimumWidth(94)
+ button.setMaximumWidth(116)
+ if argument is None:
+ button.clicked.connect(slot)
+ else:
+ button.clicked.connect(lambda _, s=slot, a=argument: s(a))
+ return button
+
+ def _makeViewerActionButton(self, text, slot):
+ button = QtWidgets.QToolButton()
+ button.setText(text)
+ button.setProperty('viewAction', 'true')
+ button.setCursor(QtCore.Qt.PointingHandCursor)
+ button.setSizePolicy(
+ QtWidgets.QSizePolicy.Fixed,
+ QtWidgets.QSizePolicy.Fixed,
+ )
+ button.setMinimumWidth(94)
+ button.setMaximumWidth(116)
+ button.clicked.connect(slot)
+ return button
+
+ def updateWorkspaceChrome(self, tab_index=None):
+ if tab_index is None:
+ tab_index = self.tabs.currentIndex()
+ viewer_tab = tab_index == self.WORKSPACE_VIEWER_INDEX
+
+ for index, button in enumerate(
+ (self.viewer_workspace_button, self.analysis_workspace_button)
+ ):
+ blocker = QtCore.QSignalBlocker(button)
+ button.setChecked(index == tab_index)
+ del blocker
+
+ self.viewer_controls_stack.setCurrentIndex(0 if viewer_tab else 1)
+
+ def setMessagePanelVisible(self, visible):
+ self.message_panel.setVisible(visible)
+ utility_width = max(self.utility_splitter.width(), 1)
+ if visible:
+ self.utility_splitter.setSizes([utility_width // 2, utility_width // 2])
+ else:
+ self.utility_splitter.setSizes([0, utility_width])
+
+ total_height = max(self.right_splitter.height(), 1)
+ utility_height = min(170, max(128, total_height // 5))
+ self.right_splitter.setSizes([total_height - utility_height, utility_height])
-def main():
+class MessagePanel(QtWidgets.QFrame):
+ def isFloating(self):
+ return False
- # check if the user is running the program in batch mode
- batchmode = '-no-gui' in sys.argv
- # run PyAero in batch mode
- if batchmode:
+def main():
+ # Check if running in batch mode
+ if '-no-gui' in sys.argv:
app = QtCore.QCoreApplication(sys.argv)
- # FIXME
- # FIXME check for proper batch control file
- # FIXME
if sys.argv[-1] == '-no-gui':
print('No batch control file specified.')
sys.exit()
- # prepare logger
- Logger.log('file_only')
+ # Prepare logger
+ Logger.log('console')
batch_controlfile = sys.argv[-1]
batchmode = BatchMode.Batch(app, batch_controlfile, __version__)
batchmode.run_batch()
-
return
- # main application (contains the main event loop)
- # run PyAero in GUI mode
+ # Run in GUI mode
app = QtWidgets.QApplication(sys.argv)
- # set icon for the application ( upper left window icon and taskbar icon)
- # and add specialization icons per size
- # (needed depending on the operating system)
- app_icon = QtGui.QIcon(os.path.join(ICONS, 'app_image.png'))
- icon_sizes = [16, 24, 32, 48, 256]
- for size in icon_sizes:
- app_icon.addFile(os.path.join(ICONS, f'app_image_{size}x{size}.png'), QtCore.QSize(size, size))
-
+ # Set icon for the application
+ app_icon = Icons.app_icon()
app.setWindowIcon(app_icon)
- if LOCALE == 'C':
- # set default locale to C, so that decimal separator is a
- # dot in spin boxes, etc.
- QtCore.QLocale.setDefault(QtCore.QLocale.c())
-
- # window style set in Settings.py
+ # Window style set in Settings.py
window = MainWindow(app)
+ window.setWindowIcon(app_icon)
window.show()
sys.exit(app.exec())
diff --git a/src/QuadLayout.py b/src/QuadLayout.py
new file mode 100644
index 0000000..8890056
--- /dev/null
+++ b/src/QuadLayout.py
@@ -0,0 +1,1160 @@
+from __future__ import annotations
+
+from dataclasses import dataclass, field
+from typing import Any, Iterable, Sequence
+
+import numpy as np
+
+from ExperimentalCGrid import ExperimentalCGridGenerator
+from ExperimentalOGrid import ExperimentalOGridGenerator
+
+
+MINIMUM_LOOP_POINTS = 13
+MINIMUM_SPACING = 1.0e-9
+SPLIT_LE_FRACTION = 0.10
+SPLIT_TE_FRACTION = 0.90
+
+
+def _as_points(points: Sequence[Sequence[float]]) -> np.ndarray:
+ array = np.asarray(points, dtype=float)
+ if array.ndim != 2 or array.shape[1] != 2:
+ raise ValueError('Layout points must be an N x 2 array.')
+ return array
+
+
+def _as_loop_points(loop) -> np.ndarray:
+ if isinstance(loop, np.ndarray):
+ return _as_points(loop)
+
+ if (
+ isinstance(loop, (tuple, list)) and
+ len(loop) == 2 and
+ np.asarray(loop[0]).ndim == 1 and
+ np.asarray(loop[1]).ndim == 1):
+ return _as_points(np.column_stack(loop))
+
+ return _as_points(loop)
+
+
+def _close_loop(points: Sequence[Sequence[float]]) -> np.ndarray:
+ array = _as_points(points)
+ if len(array) == 0:
+ return np.empty((0, 2), dtype=float)
+ if np.allclose(array[0], array[-1], atol=1.0e-10):
+ closed = np.array(array, copy=True, dtype=float)
+ closed[-1] = closed[0]
+ return closed
+ return np.vstack((array, array[0]))
+
+
+def _open_loop(points: Sequence[Sequence[float]]) -> np.ndarray:
+ closed = _close_loop(points)
+ if len(closed) <= 1:
+ return np.empty((0, 2), dtype=float)
+ return np.asarray(closed[:-1], dtype=float)
+
+
+def _signed_area(points: Sequence[Sequence[float]]) -> float:
+ closed = _close_loop(points)
+ if len(closed) < 4:
+ return 0.0
+ x_values = closed[:, 0]
+ y_values = closed[:, 1]
+ return 0.5 * float(
+ np.dot(x_values[:-1], y_values[1:]) -
+ np.dot(y_values[:-1], x_values[1:])
+ )
+
+
+def _ensure_counter_clockwise(points: Sequence[Sequence[float]]) -> np.ndarray:
+ closed = _close_loop(points)
+ if _signed_area(closed) < 0.0:
+ closed = closed[::-1]
+ closed[-1] = closed[0]
+ return closed
+
+
+def _polyline_cumulative(points: Sequence[Sequence[float]]) -> np.ndarray:
+ array = _as_points(points)
+ if len(array) == 0:
+ return np.zeros(0, dtype=float)
+ deltas = np.diff(array, axis=0)
+ segment_lengths = np.linalg.norm(deltas, axis=1)
+ return np.concatenate(([0.0], np.cumsum(segment_lengths)))
+
+
+def _polyline_length(points: Sequence[Sequence[float]]) -> float:
+ cumulative = _polyline_cumulative(points)
+ return float(cumulative[-1]) if len(cumulative) else 0.0
+
+
+def _sample_polyline_distances(points: Sequence[Sequence[float]],
+ distances) -> np.ndarray:
+ array = _as_points(points)
+ cumulative = _polyline_cumulative(array)
+ if len(cumulative) == 0 or np.isclose(cumulative[-1], 0.0):
+ if len(array) == 0:
+ return np.empty((0, 2), dtype=float)
+ return np.repeat(array[:1], len(distances), axis=0)
+
+ distances = np.asarray(distances, dtype=float)
+ distances = np.clip(distances, 0.0, cumulative[-1])
+ x_values = np.interp(distances, cumulative, array[:, 0])
+ y_values = np.interp(distances, cumulative, array[:, 1])
+ return np.column_stack((x_values, y_values))
+
+
+def _resample_polyline_count(points: Sequence[Sequence[float]], count: int
+ ) -> np.ndarray:
+ count = max(1, int(count))
+ array = _as_points(points)
+ cumulative = _polyline_cumulative(array)
+ if len(cumulative) == 0 or np.isclose(cumulative[-1], 0.0):
+ if len(array) == 0:
+ return np.zeros((count, 2), dtype=float)
+ return np.repeat(array[:1], count, axis=0)
+ targets = np.linspace(0.0, cumulative[-1], count)
+ return _sample_polyline_distances(array, targets)
+
+
+def _sample_segment_count(start: Sequence[float], end: Sequence[float], count: int
+ ) -> np.ndarray:
+ start = np.asarray(start, dtype=float)
+ end = np.asarray(end, dtype=float)
+ count = max(1, int(count))
+ if count == 1:
+ return start[np.newaxis, :]
+ distances = np.linspace(0.0, 1.0, count)
+ return start[np.newaxis, :] + distances[:, None] * (end - start)[None, :]
+
+
+def _sample_quadratic_bezier(start: Sequence[float], control: Sequence[float],
+ end: Sequence[float], count: int) -> np.ndarray:
+ start = np.asarray(start, dtype=float)
+ control = np.asarray(control, dtype=float)
+ end = np.asarray(end, dtype=float)
+ count = max(2, int(count))
+ parameters = np.linspace(0.0, 1.0, count)
+ one_minus = 1.0 - parameters
+ return (
+ (one_minus ** 2)[:, None] * start[None, :] +
+ (2.0 * one_minus * parameters)[:, None] * control[None, :] +
+ (parameters ** 2)[:, None] * end[None, :]
+ )
+
+
+def _compose_segments(*segments: Iterable[Sequence[float]]) -> np.ndarray:
+ combined = []
+ for segment in segments:
+ points = _as_points(segment)
+ if len(points) == 0:
+ continue
+ if not combined:
+ combined.append(points)
+ continue
+ if np.allclose(combined[-1][-1], points[0], atol=1.0e-10):
+ combined.append(points[1:])
+ else:
+ combined.append(points)
+
+ if not combined:
+ return np.empty((0, 2), dtype=float)
+ return np.vstack(combined)
+
+
+def _point_count_from_length(length: float, target_spacing: float, *,
+ minimum: int = 2, maximum: int | None = None) -> int:
+ spacing = max(float(target_spacing), MINIMUM_SPACING)
+ count = max(int(minimum), int(np.ceil(max(0.0, float(length)) / spacing)) + 1)
+ if maximum is not None:
+ count = min(count, int(maximum))
+ return count
+
+
+def _leading_edge_index(points: np.ndarray) -> int:
+ return int(np.argmin(points[:, 0]))
+
+
+def _te_upper_index(points: np.ndarray) -> int:
+ x_values = points[:, 0]
+ xmax = float(np.max(x_values))
+ candidates = np.flatnonzero(np.abs(x_values - xmax) <= 1.0e-8)
+ if not candidates.size:
+ return int(np.argmax(x_values))
+ y_values = points[candidates, 1]
+ return int(candidates[int(np.argmax(y_values))])
+
+
+def _rotate_open_loop(points: Sequence[Sequence[float]]) -> np.ndarray:
+ open_points = _open_loop(points)
+ if len(open_points) < 3:
+ raise ValueError('An element loop requires at least three unique points.')
+ start_index = _te_upper_index(open_points)
+ return np.vstack((open_points[start_index:], open_points[:start_index]))
+
+
+def _centered_normals(points: Sequence[Sequence[float]]) -> np.ndarray:
+ open_points = _open_loop(points)
+ if len(open_points) < 3:
+ raise ValueError('Loop normals require at least three unique points.')
+
+ previous_points = np.roll(open_points, 1, axis=0)
+ next_points = np.roll(open_points, -1, axis=0)
+ tangents = next_points - previous_points
+ lengths = np.linalg.norm(tangents, axis=1)
+ lengths = np.maximum(lengths, MINIMUM_SPACING)
+ unit_tangents = tangents / lengths[:, None]
+ normals = np.column_stack((unit_tangents[:, 1], -unit_tangents[:, 0]))
+ return normals
+
+
+def _offset_loop(points: Sequence[Sequence[float]], distance: float) -> np.ndarray:
+ if distance <= 0.0:
+ return _close_loop(points)
+ open_points = _open_loop(points)
+ normals = _centered_normals(points)
+ offset = open_points + float(distance) * normals
+ return _close_loop(offset)
+
+
+def _layer_offsets(first_layer: float, growth: float, divisions: int) -> np.ndarray:
+ divisions = max(1, int(divisions))
+ thickness = max(float(first_layer), MINIMUM_SPACING)
+ growth = max(float(growth), 1.0)
+
+ offsets = np.zeros(divisions + 1, dtype=float)
+ spacing = thickness
+ for index in range(1, divisions + 1):
+ offsets[index] = offsets[index - 1] + spacing
+ spacing *= growth
+ return offsets
+
+
+def _minimum_loop_gap(boundary_loops: Sequence[np.ndarray]) -> float:
+ if len(boundary_loops) < 2:
+ return float('inf')
+
+ sampled_loops = [
+ _resample_polyline_count(_close_loop(loop), min(200, max(40, len(loop))))[:-1]
+ for loop in boundary_loops
+ ]
+
+ minimum_gap = float('inf')
+ for index, left in enumerate(sampled_loops[:-1]):
+ for right in sampled_loops[index + 1:]:
+ deltas = left[:, None, :] - right[None, :, :]
+ distances = np.linalg.norm(deltas, axis=2)
+ minimum_gap = min(minimum_gap, float(np.min(distances)))
+ return minimum_gap
+
+
+def _sort_loops_by_streamwise_position(boundary_loops: Sequence[np.ndarray]
+ ) -> list[np.ndarray]:
+ return sorted(
+ [_close_loop(loop) for loop in boundary_loops],
+ key=lambda loop: (
+ float(np.mean(loop[:-1, 0])),
+ float(np.mean(loop[:-1, 1])),
+ ),
+ )
+
+
+def _prepare_boundary_loop(loop, *, surface_points: int | None, minimum_points: int,
+ trailing_edge_divisions: int) -> np.ndarray:
+ points = _as_loop_points(loop)
+ if len(points) < 3:
+ raise ValueError('Each boundary loop requires at least three points.')
+
+ if np.allclose(points[0], points[-1], atol=1.0e-10):
+ closed = np.array(points, copy=True, dtype=float)
+ closed[-1] = closed[0]
+ else:
+ closure = _sample_segment_count(
+ points[-1],
+ points[0],
+ max(2, int(trailing_edge_divisions) + 1),
+ )
+ closed = _compose_segments(points, closure)
+
+ target_points = len(closed)
+ if surface_points is not None and int(surface_points) > 0:
+ target_points = int(surface_points)
+ target_points = max(int(minimum_points), target_points)
+ if target_points != len(closed):
+ closed = _resample_polyline_count(closed, target_points)
+ closed[-1] = closed[0]
+
+ if len(closed) < minimum_points:
+ closed = _resample_polyline_count(closed, minimum_points)
+ closed[-1] = closed[0]
+
+ return _ensure_counter_clockwise(closed)
+
+
+def _first_index_leq(values: np.ndarray, target: float) -> int:
+ matches = np.flatnonzero(values <= target)
+ if matches.size:
+ return int(matches[0])
+ return len(values) - 1
+
+
+def _first_index_geq(values: np.ndarray, target: float) -> int:
+ matches = np.flatnonzero(values >= target)
+ if matches.size:
+ return int(matches[0])
+ return len(values) - 1
+
+
+def _segment_split_indices(open_loop: np.ndarray) -> dict[str, int]:
+ leading_index = _leading_edge_index(open_loop)
+ upper = open_loop[:leading_index + 1]
+ lower = open_loop[leading_index:]
+
+ if len(upper) < 2 or len(lower) < 2:
+ raise ValueError('Element loop could not be split into upper and lower surfaces.')
+
+ x_min = float(np.min(open_loop[:, 0]))
+ x_max = float(np.max(open_loop[:, 0]))
+ chord = max(x_max - x_min, MINIMUM_SPACING)
+ x_le_limit = x_min + SPLIT_LE_FRACTION * chord
+ x_te_limit = x_min + SPLIT_TE_FRACTION * chord
+
+ upper_te = _first_index_leq(upper[:, 0], x_te_limit)
+ upper_le = _first_index_leq(upper[:, 0], x_le_limit)
+ if upper_le <= upper_te:
+ upper_te = max(0, min(upper_te, len(upper) - 3))
+ upper_le = min(len(upper) - 2, upper_te + 1)
+
+ lower_le = _first_index_geq(lower[:, 0], x_le_limit)
+ lower_te = _first_index_geq(lower[:, 0], x_te_limit)
+ if lower_te <= lower_le:
+ lower_le = max(0, min(lower_le, len(lower) - 3))
+ lower_te = min(len(lower) - 2, lower_le + 1)
+
+ return {
+ 'leading_index': leading_index,
+ 'upper_te': upper_te,
+ 'upper_le': upper_le,
+ 'lower_le': lower_le,
+ 'lower_te': lower_te,
+ }
+
+
+def _segment_split_indices_for_stations(open_loop: np.ndarray, *,
+ leading_station: float,
+ trailing_station: float
+ ) -> dict[str, int]:
+ leading_station = float(np.clip(leading_station, 0.01, 0.45))
+ trailing_station = float(
+ np.clip(trailing_station, leading_station + 0.05, 0.99)
+ )
+
+ leading_index = _leading_edge_index(open_loop)
+ upper = open_loop[:leading_index + 1]
+ lower = open_loop[leading_index:]
+
+ if len(upper) < 2 or len(lower) < 2:
+ raise ValueError('Element loop could not be split into upper and lower surfaces.')
+
+ x_min = float(np.min(open_loop[:, 0]))
+ x_max = float(np.max(open_loop[:, 0]))
+ chord = max(x_max - x_min, MINIMUM_SPACING)
+ x_le_limit = x_min + leading_station * chord
+ x_te_limit = x_min + trailing_station * chord
+
+ upper_te = _first_index_leq(upper[:, 0], x_te_limit)
+ upper_le = _first_index_leq(upper[:, 0], x_le_limit)
+ if upper_le <= upper_te:
+ upper_te = max(0, min(upper_te, len(upper) - 3))
+ upper_le = min(len(upper) - 2, upper_te + 1)
+
+ lower_le = _first_index_geq(lower[:, 0], x_le_limit)
+ lower_te = _first_index_geq(lower[:, 0], x_te_limit)
+ if lower_te <= lower_le:
+ lower_le = max(0, min(lower_le, len(lower) - 3))
+ lower_te = min(len(lower) - 2, lower_le + 1)
+
+ return {
+ 'leading_index': leading_index,
+ 'upper_te': upper_te,
+ 'upper_le': upper_le,
+ 'lower_le': lower_le,
+ 'lower_te': lower_te,
+ }
+
+
+def _raw_segments_from_indices(open_loop: np.ndarray, indices: dict[str, int], *,
+ trailing_edge_divisions: int) -> dict[str, np.ndarray]:
+ leading_index = indices['leading_index']
+ upper = open_loop[:leading_index + 1]
+ lower = open_loop[leading_index:]
+
+ closure = _sample_segment_count(
+ lower[-1],
+ upper[0],
+ max(2, int(trailing_edge_divisions) + 1),
+ )
+
+ top = upper[indices['upper_te']:indices['upper_le'] + 1]
+ left = _compose_segments(
+ upper[indices['upper_le']:],
+ lower[:indices['lower_le'] + 1],
+ )
+ bottom = lower[indices['lower_le']:indices['lower_te'] + 1]
+ right = _compose_segments(
+ lower[indices['lower_te']:],
+ closure,
+ upper[:indices['upper_te'] + 1],
+ )
+
+ return {
+ 'top': top,
+ 'left': left,
+ 'bottom': bottom,
+ 'right': right,
+ }
+
+
+@dataclass(slots=True)
+class QuadSingularity:
+ position: np.ndarray | Sequence[float]
+ charge: int = 0
+ kind: str = 'regular'
+ metadata: dict[str, Any] = field(default_factory=dict)
+
+ def __post_init__(self):
+ position = np.asarray(self.position, dtype=float)
+ if position.shape != (2,):
+ raise ValueError('Singularity position must be a 2D point.')
+ self.position = position
+ self.charge = int(self.charge)
+ self.kind = str(self.kind).strip() or 'regular'
+
+
+@dataclass(slots=True)
+class QuadBlockSpec:
+ name: str
+ lower_boundary: np.ndarray | Sequence[Sequence[float]]
+ upper_boundary: np.ndarray | Sequence[Sequence[float]]
+ left_boundary: np.ndarray | Sequence[Sequence[float]]
+ right_boundary: np.ndarray | Sequence[Sequence[float]]
+ role: str = 'connector'
+ element_index: int | None = None
+ protected: bool = False
+ metadata: dict[str, Any] = field(default_factory=dict)
+
+ def __post_init__(self):
+ self.lower_boundary = _as_points(self.lower_boundary)
+ self.upper_boundary = _as_points(self.upper_boundary)
+ self.left_boundary = _as_points(self.left_boundary)
+ self.right_boundary = _as_points(self.right_boundary)
+ self.name = str(self.name).strip() or 'block'
+ self.role = str(self.role).strip() or 'connector'
+ self.protected = bool(self.protected)
+
+
+@dataclass(slots=True)
+class QuadLayoutPlan:
+ boundary_points: np.ndarray | Sequence[Sequence[float]]
+ boundary_loops: list[np.ndarray | Sequence[Sequence[float]]] = field(default_factory=list)
+ near_wall_loops: list[np.ndarray | Sequence[Sequence[float]]] = field(default_factory=list)
+ outer_boundary: np.ndarray | Sequence[Sequence[float]] | None = None
+ block_specs: list[QuadBlockSpec] = field(default_factory=list)
+ singularities: list[QuadSingularity] = field(default_factory=list)
+ separatrices: list[np.ndarray] = field(default_factory=list)
+ metadata: dict[str, Any] = field(default_factory=dict)
+
+ def __post_init__(self):
+ self.boundary_points = _as_points(self.boundary_points)
+ self.boundary_loops = [
+ _close_loop(loop) for loop in (self.boundary_loops or [self.boundary_points])
+ ]
+ self.near_wall_loops = [_close_loop(loop) for loop in self.near_wall_loops]
+ self.outer_boundary = (
+ _close_loop(self.outer_boundary)
+ if self.outer_boundary is not None and len(np.asarray(self.outer_boundary)) != 0
+ else _close_loop(self.boundary_points)
+ )
+ self.block_specs = [
+ spec if isinstance(spec, QuadBlockSpec) else QuadBlockSpec(**dict(spec))
+ for spec in self.block_specs
+ ]
+ self.separatrices = [_as_points(separatrix) for separatrix in self.separatrices]
+
+
+class QuadLayoutGenerator:
+ """Stage 4 layout scaffold for multi-element structured quad meshes."""
+
+ name = 'multi_element_oc'
+
+ @staticmethod
+ def create_plan(boundary_points: Sequence[Sequence[float]], *,
+ boundary_loops=None, near_wall_loops=None,
+ outer_boundary=None, block_specs=None,
+ singularities=None, separatrices=None, metadata=None):
+ return QuadLayoutPlan(
+ boundary_points=boundary_points,
+ boundary_loops=list(boundary_loops or [boundary_points]),
+ near_wall_loops=list(near_wall_loops or []),
+ outer_boundary=outer_boundary if outer_boundary is not None else boundary_points,
+ block_specs=list(block_specs or []),
+ singularities=list(singularities or []),
+ separatrices=list(separatrices or []),
+ metadata=dict(metadata or {}),
+ )
+
+ @staticmethod
+ def _loop_surface_point_targets(loop_inputs, surface_points):
+ if surface_points is None:
+ return [None] * len(loop_inputs)
+
+ if isinstance(surface_points, Sequence) and not isinstance(surface_points, (str, bytes)):
+ values = list(surface_points)
+ if len(values) != len(loop_inputs):
+ raise ValueError(
+ 'Per-loop surface point targets must match the number of boundary loops.'
+ )
+ return [
+ None if value is None else int(value)
+ for value in values
+ ]
+
+ target = int(surface_points)
+ if target <= 0:
+ return [None] * len(loop_inputs)
+ return [target] * len(loop_inputs)
+
+ @classmethod
+ def _boundary_loops_from_input(cls, boundary_points=None, *, boundary_loops=None,
+ metadata=None, surface_points: int | Sequence[int] | None,
+ trailing_edge_divisions: int) -> list[np.ndarray]:
+ metadata = dict(metadata or {})
+ loop_inputs = boundary_loops
+ if loop_inputs is None:
+ loop_inputs = (
+ metadata.get('hybrid_boundary_loops') or
+ metadata.get('boundary_loops') or
+ metadata.get('element_loops')
+ )
+ if loop_inputs is None:
+ if boundary_points is None:
+ raise ValueError('A hybrid layout requires at least one boundary loop.')
+ loop_inputs = [boundary_points]
+
+ surface_targets = cls._loop_surface_point_targets(
+ loop_inputs,
+ surface_points,
+ )
+ prepared = [
+ _prepare_boundary_loop(
+ loop,
+ surface_points=target_points,
+ minimum_points=MINIMUM_LOOP_POINTS,
+ trailing_edge_divisions=trailing_edge_divisions,
+ )
+ for loop, target_points in zip(loop_inputs, surface_targets)
+ ]
+ return _sort_loops_by_streamwise_position(prepared)
+
+ @classmethod
+ def generate(cls, boundary_points=None, *, boundary_loops=None,
+ metadata=None, tunnel_height: float,
+ wake_length: float, surface_points: int | Sequence[int] | None = 0,
+ normal_divisions: int = 20,
+ first_layer_thickness: float = 0.004,
+ layer_growth: float = 1.05,
+ connector_layers: int = 80,
+ trailing_edge_divisions: int = 3):
+ boundary_loops = cls._boundary_loops_from_input(
+ boundary_points,
+ boundary_loops=boundary_loops,
+ metadata=metadata,
+ surface_points=surface_points,
+ trailing_edge_divisions=trailing_edge_divisions,
+ )
+
+ layer_offsets = _layer_offsets(
+ first_layer=first_layer_thickness,
+ growth=layer_growth,
+ divisions=normal_divisions,
+ )
+ nominal_ring_thickness = float(layer_offsets[-1])
+ minimum_gap = _minimum_loop_gap(boundary_loops)
+ clearance_limit = float('inf')
+ if np.isfinite(minimum_gap):
+ clearance_limit = 0.35 * minimum_gap
+
+ ring_thickness = min(
+ nominal_ring_thickness,
+ clearance_limit if np.isfinite(clearance_limit) else nominal_ring_thickness,
+ 0.45 * max(float(tunnel_height), MINIMUM_SPACING),
+ )
+ ring_thickness = max(float(first_layer_thickness), ring_thickness)
+
+ all_points = np.vstack([loop[:-1] for loop in boundary_loops])
+ x_min = float(np.min(all_points[:, 0]))
+ x_max = float(np.max(all_points[:, 0]))
+ y_min = float(np.min(all_points[:, 1]))
+ y_max = float(np.max(all_points[:, 1]))
+ y_center = 0.5 * (y_min + y_max)
+
+ farfield_left = x_min - float(tunnel_height)
+ farfield_right = x_max + float(wake_length)
+ farfield_top = y_center + float(tunnel_height)
+ farfield_bottom = y_center - float(tunnel_height)
+ outer_boundary = np.array(
+ [
+ (farfield_left, farfield_bottom),
+ (farfield_right, farfield_bottom),
+ (farfield_right, farfield_top),
+ (farfield_left, farfield_top),
+ (farfield_left, farfield_bottom),
+ ],
+ dtype=float,
+ )
+
+ perimeter_spacings = []
+ for loop in boundary_loops:
+ open_loop = _rotate_open_loop(loop)
+ perimeter_spacings.append(
+ _polyline_length(_close_loop(open_loop)) / max(1, len(open_loop))
+ )
+ target_spacing = max(float(np.mean(perimeter_spacings)), MINIMUM_SPACING)
+
+ connector_layers = max(4, int(connector_layers))
+ connector_point_count = connector_layers + 1
+
+ raw_elements = []
+ side_lengths = []
+ for element_index, loop in enumerate(boundary_loops):
+ inner_open = _rotate_open_loop(loop)
+ outer_open = _rotate_open_loop(_offset_loop(loop, ring_thickness))
+ indices = _segment_split_indices(inner_open)
+ inner_raw = _raw_segments_from_indices(
+ inner_open,
+ indices,
+ trailing_edge_divisions=trailing_edge_divisions,
+ )
+ outer_raw = _raw_segments_from_indices(
+ outer_open,
+ indices,
+ trailing_edge_divisions=trailing_edge_divisions,
+ )
+ side_lengths.extend((
+ _polyline_length(inner_raw['left']),
+ _polyline_length(inner_raw['right']),
+ ))
+ raw_elements.append({
+ 'index': element_index,
+ 'inner_loop': _close_loop(inner_open),
+ 'outer_loop': _close_loop(outer_open),
+ 'inner_raw': inner_raw,
+ 'outer_raw': outer_raw,
+ })
+
+ average_side_length = float(np.mean(side_lengths)) if side_lengths else target_spacing
+ side_point_count = _point_count_from_length(
+ average_side_length,
+ target_spacing,
+ minimum=6,
+ maximum=160,
+ )
+
+ block_specs = []
+ top_chain_segments = []
+ bottom_chain_segments = []
+ element_segments = []
+
+ for element in raw_elements:
+ inner_raw = element['inner_raw']
+ outer_raw = element['outer_raw']
+ element_index = element['index']
+
+ top_count = _point_count_from_length(
+ _polyline_length(inner_raw['top']),
+ target_spacing,
+ minimum=3,
+ maximum=240,
+ )
+ bottom_count = _point_count_from_length(
+ _polyline_length(inner_raw['bottom']),
+ target_spacing,
+ minimum=3,
+ maximum=240,
+ )
+
+ segments = {
+ 'top': _resample_polyline_count(inner_raw['top'], top_count),
+ 'left': _resample_polyline_count(inner_raw['left'], side_point_count),
+ 'bottom': _resample_polyline_count(inner_raw['bottom'], bottom_count),
+ 'right': _resample_polyline_count(inner_raw['right'], side_point_count),
+ }
+ outer_segments = {
+ 'top': _resample_polyline_count(outer_raw['top'], top_count),
+ 'left': _resample_polyline_count(outer_raw['left'], side_point_count),
+ 'bottom': _resample_polyline_count(outer_raw['bottom'], bottom_count),
+ 'right': _resample_polyline_count(outer_raw['right'], side_point_count),
+ }
+
+ element_segments.append({
+ 'inner': segments,
+ 'outer': outer_segments,
+ 'inner_loop': element['inner_loop'],
+ 'outer_loop': element['outer_loop'],
+ })
+
+ for side in ('top', 'left', 'bottom', 'right'):
+ block_specs.append(
+ QuadBlockSpec(
+ name=f'block_hybrid_element_{element_index}_{side}',
+ lower_boundary=segments[side],
+ upper_boundary=outer_segments[side],
+ left_boundary=_sample_segment_count(
+ segments[side][0],
+ outer_segments[side][0],
+ connector_point_count,
+ ),
+ right_boundary=_sample_segment_count(
+ segments[side][-1],
+ outer_segments[side][-1],
+ connector_point_count,
+ ),
+ role='element_ring',
+ element_index=element_index,
+ protected=True,
+ metadata={'side': side},
+ )
+ )
+
+ top_chain_segments.append(outer_segments['top'][::-1])
+ bottom_chain_segments.append(outer_segments['bottom'])
+
+ bridge_specs = []
+ bridge_top_segments = []
+ bridge_bottom_segments = []
+ for element_index in range(len(element_segments) - 1):
+ left_element = element_segments[element_index]
+ right_element = element_segments[element_index + 1]
+
+ left_boundary = left_element['outer']['right']
+ right_boundary = right_element['outer']['left'][::-1]
+
+ bridge_point_count = max(
+ _point_count_from_length(
+ np.linalg.norm(right_boundary[0] - left_boundary[0]),
+ target_spacing,
+ minimum=2,
+ maximum=200,
+ ),
+ _point_count_from_length(
+ np.linalg.norm(right_boundary[-1] - left_boundary[-1]),
+ target_spacing,
+ minimum=2,
+ maximum=200,
+ ),
+ )
+ bottom_bridge = _sample_segment_count(
+ left_boundary[0],
+ right_boundary[0],
+ bridge_point_count,
+ )
+ top_bridge = _sample_segment_count(
+ left_boundary[-1],
+ right_boundary[-1],
+ bridge_point_count,
+ )
+
+ bridge_bottom_segments.append(bottom_bridge)
+ bridge_top_segments.append(top_bridge)
+ bridge_specs.append(
+ QuadBlockSpec(
+ name=f'block_hybrid_bridge_{element_index}_{element_index + 1}',
+ lower_boundary=bottom_bridge,
+ upper_boundary=top_bridge,
+ left_boundary=left_boundary,
+ right_boundary=right_boundary,
+ role='bridge',
+ metadata={
+ 'left_element': element_index,
+ 'right_element': element_index + 1,
+ },
+ )
+ )
+
+ composed_top = []
+ composed_bottom = []
+ for index, segment in enumerate(top_chain_segments):
+ composed_top.append(segment)
+ if index < len(bridge_top_segments):
+ composed_top.append(bridge_top_segments[index])
+ for index, segment in enumerate(bottom_chain_segments):
+ composed_bottom.append(segment)
+ if index < len(bridge_bottom_segments):
+ composed_bottom.append(bridge_bottom_segments[index])
+
+ top_chain = _compose_segments(*composed_top)
+ bottom_chain = _compose_segments(*composed_bottom)
+
+ first_element = element_segments[0]
+ last_element = element_segments[-1]
+
+ inlet_right = first_element['outer']['left'][::-1]
+ outlet_left = last_element['outer']['right']
+
+ inlet_point_count = max(
+ _point_count_from_length(
+ np.linalg.norm(inlet_right[0] - np.array((farfield_left, farfield_bottom))),
+ target_spacing,
+ minimum=2,
+ maximum=200,
+ ),
+ _point_count_from_length(
+ np.linalg.norm(inlet_right[-1] - np.array((farfield_left, farfield_top))),
+ target_spacing,
+ minimum=2,
+ maximum=200,
+ ),
+ )
+ inlet_lower = _sample_segment_count(
+ np.array((farfield_left, farfield_bottom), dtype=float),
+ inlet_right[0],
+ inlet_point_count,
+ )
+ inlet_upper = _sample_segment_count(
+ np.array((farfield_left, farfield_top), dtype=float),
+ inlet_right[-1],
+ inlet_point_count,
+ )
+ outlet_point_count = max(
+ _point_count_from_length(
+ np.linalg.norm(outlet_left[0] - np.array((farfield_right, farfield_bottom))),
+ target_spacing,
+ minimum=2,
+ maximum=240,
+ ),
+ _point_count_from_length(
+ np.linalg.norm(outlet_left[-1] - np.array((farfield_right, farfield_top))),
+ target_spacing,
+ minimum=2,
+ maximum=240,
+ ),
+ )
+ outlet_lower = _sample_segment_count(
+ outlet_left[0],
+ np.array((farfield_right, farfield_bottom), dtype=float),
+ outlet_point_count,
+ )
+ outlet_upper = _sample_segment_count(
+ outlet_left[-1],
+ np.array((farfield_right, farfield_top), dtype=float),
+ outlet_point_count,
+ )
+
+ connector_vertical_count = max(inlet_point_count, outlet_point_count)
+ inlet_lower = _resample_polyline_count(
+ inlet_lower,
+ connector_vertical_count,
+ )
+ inlet_upper = _resample_polyline_count(
+ inlet_upper,
+ connector_vertical_count,
+ )
+ outlet_lower = _resample_polyline_count(
+ outlet_lower,
+ connector_vertical_count,
+ )
+ outlet_upper = _resample_polyline_count(
+ outlet_upper,
+ connector_vertical_count,
+ )
+
+ top_upper = _sample_segment_count(
+ np.array((farfield_left, farfield_top), dtype=float),
+ np.array((farfield_right, farfield_top), dtype=float),
+ len(top_chain),
+ )
+ bottom_upper = _sample_segment_count(
+ np.array((farfield_left, farfield_bottom), dtype=float),
+ np.array((farfield_right, farfield_bottom), dtype=float),
+ len(bottom_chain),
+ )
+
+ block_specs.extend(bridge_specs)
+ block_specs.extend((
+ QuadBlockSpec(
+ name='block_hybrid_inlet',
+ lower_boundary=inlet_lower,
+ upper_boundary=inlet_upper,
+ left_boundary=_sample_segment_count(
+ (farfield_left, farfield_bottom),
+ (farfield_left, farfield_top),
+ side_point_count,
+ ),
+ right_boundary=inlet_right,
+ role='inlet_connector',
+ ),
+ QuadBlockSpec(
+ name='block_hybrid_outlet',
+ lower_boundary=outlet_lower,
+ upper_boundary=outlet_upper,
+ left_boundary=outlet_left,
+ right_boundary=_sample_segment_count(
+ (farfield_right, farfield_bottom),
+ (farfield_right, farfield_top),
+ side_point_count,
+ ),
+ role='outlet_connector',
+ ),
+ QuadBlockSpec(
+ name='block_hybrid_top',
+ lower_boundary=top_chain,
+ upper_boundary=top_upper,
+ left_boundary=inlet_upper[::-1],
+ right_boundary=outlet_upper,
+ role='top_connector',
+ ),
+ QuadBlockSpec(
+ name='block_hybrid_bottom',
+ lower_boundary=bottom_chain,
+ upper_boundary=bottom_upper,
+ left_boundary=inlet_lower[::-1],
+ right_boundary=outlet_lower,
+ role='bottom_connector',
+ ),
+ ))
+
+ return QuadLayoutPlan(
+ boundary_points=outer_boundary,
+ boundary_loops=boundary_loops,
+ near_wall_loops=[element['outer_loop'] for element in element_segments],
+ outer_boundary=outer_boundary,
+ block_specs=block_specs,
+ metadata={
+ 'strategy': cls.name,
+ 'implemented': True,
+ 'element_count': len(boundary_loops),
+ 'block_count': len(block_specs),
+ 'ring_thickness': float(ring_thickness),
+ 'connector_layers': int(connector_layers),
+ 'normal_divisions': int(normal_divisions),
+ 'first_layer_thickness': float(first_layer_thickness),
+ 'target_spacing': float(target_spacing),
+ 'minimum_inter_element_gap': float(minimum_gap)
+ if np.isfinite(minimum_gap) else None,
+ },
+ )
+
+ @classmethod
+ def generate_metric_c_grid(
+ cls,
+ boundary_points=None, *,
+ boundary_loops=None,
+ metadata=None,
+ tunnel_height: float,
+ wake_length: float,
+ surface_points: int | Sequence[int] | None = 0,
+ normal_divisions: int = 20,
+ first_layer_thickness: float = 0.004,
+ connector_layers: int = 80,
+ trailing_edge_divisions: int = 3,
+ singularity_template: str = 'auto_boundary_c',
+ ):
+ boundary_loops = cls._boundary_loops_from_input(
+ boundary_points,
+ boundary_loops=boundary_loops,
+ metadata=metadata,
+ surface_points=surface_points,
+ trailing_edge_divisions=trailing_edge_divisions,
+ )
+ if len(boundary_loops) != 1:
+ raise ValueError(
+ 'The metric C-grid layout currently supports one closed contour.'
+ )
+
+ loop = boundary_loops[0]
+ contour_points = np.asarray(loop, dtype=float)
+ wake_point_count = max(6, int(connector_layers))
+ singularity_template = (
+ str(singularity_template).strip().lower() or 'auto_boundary_c'
+ )
+ if singularity_template not in ('auto_boundary_c',):
+ singularity_template = 'auto_boundary_c'
+
+ target_spacing = (
+ _polyline_length(_close_loop(contour_points)) /
+ max(1, len(contour_points) - 1)
+ )
+
+ reference_te_x = ExperimentalCGridGenerator._reference_trailing_edge_x(
+ contour_points
+ )
+ te_spacing = ExperimentalCGridGenerator._trailing_edge_spacing(
+ contour_points
+ )
+ upper_wake, lower_wake = ExperimentalCGridGenerator._build_wake_branches(
+ start_point=contour_points[0],
+ end_point=contour_points[-1],
+ reference_te_x=reference_te_x,
+ wake_length=float(wake_length),
+ point_count=wake_point_count,
+ first_spacing=te_spacing,
+ )
+ outer_boundary = _close_loop(
+ ExperimentalCGridGenerator._build_outer_boundary(
+ reference_te_x=reference_te_x,
+ radius=float(tunnel_height),
+ wake_length=float(wake_length),
+ upper_points=len(upper_wake),
+ middle_points=len(contour_points),
+ lower_points=len(lower_wake),
+ te_spacing=te_spacing,
+ wake_length_ratio=1.0,
+ wake_start_ratio=10.0,
+ )
+ )
+
+ te_position = np.array(contour_points[0], copy=True, dtype=float)
+ wake_upper = np.array(upper_wake[-1], copy=True, dtype=float)
+ wake_lower = np.array(lower_wake[-1], copy=True, dtype=float)
+ marker_offset = max(0.008, 0.008 * float(tunnel_height))
+
+ block_specs = [
+ QuadBlockSpec(
+ name='block_metric_c_grid',
+ lower_boundary=contour_points,
+ upper_boundary=outer_boundary,
+ left_boundary=upper_wake,
+ right_boundary=lower_wake,
+ role='metric_c_grid',
+ protected=True,
+ metadata={
+ 'contour_points': contour_points,
+ 'tunnel_height': float(tunnel_height),
+ 'wake_length': float(wake_length),
+ 'normal_divisions': int(normal_divisions),
+ 'first_layer_thickness': float(first_layer_thickness),
+ 'wake_point_count': int(wake_point_count),
+ 'mesh_family': 'c_grid',
+ 'te_geometry': 'sharp',
+ },
+ ),
+ ]
+
+ singularities = [
+ QuadSingularity(
+ position=te_position,
+ charge=0,
+ kind='boundary',
+ metadata={
+ 'label': 'TE upper',
+ 'role': 'te_upper',
+ 'mode': 'auto',
+ 'valence_hint': 1,
+ 'display_offset': (0.0, marker_offset),
+ },
+ ),
+ QuadSingularity(
+ position=te_position,
+ charge=0,
+ kind='boundary',
+ metadata={
+ 'label': 'TE lower',
+ 'role': 'te_lower',
+ 'mode': 'auto',
+ 'valence_hint': 1,
+ 'display_offset': (0.0, -marker_offset),
+ },
+ ),
+ QuadSingularity(
+ position=wake_upper,
+ charge=0,
+ kind='boundary',
+ metadata={
+ 'label': 'Wake upper',
+ 'role': 'wake_upper',
+ 'mode': 'auto',
+ 'valence_hint': 1,
+ 'display_offset': (0.0, marker_offset),
+ },
+ ),
+ QuadSingularity(
+ position=wake_lower,
+ charge=0,
+ kind='boundary',
+ metadata={
+ 'label': 'Wake lower',
+ 'role': 'wake_lower',
+ 'mode': 'auto',
+ 'valence_hint': 1,
+ 'display_offset': (0.0, -marker_offset),
+ },
+ )
+ ]
+
+ return QuadLayoutPlan(
+ boundary_points=outer_boundary,
+ boundary_loops=boundary_loops,
+ near_wall_loops=[],
+ outer_boundary=outer_boundary,
+ block_specs=block_specs,
+ singularities=singularities,
+ separatrices=[
+ upper_wake,
+ lower_wake,
+ ],
+ metadata={
+ 'strategy': 'metric_c_grid',
+ 'implemented': True,
+ 'element_count': 1,
+ 'block_count': len(block_specs),
+ 'farfield_shape': 'legacy_wind_tunnel',
+ 'mesh_family': 'c_grid',
+ 'te_geometry': 'sharp',
+ 'singularity_template': singularity_template,
+ 'wake_point_count': int(wake_point_count),
+ 'separatrix_count': 2,
+ 'normal_divisions': int(normal_divisions),
+ 'first_layer_thickness': float(first_layer_thickness),
+ 'target_spacing': float(target_spacing),
+ },
+ )
+
+ @classmethod
+ def generate_metric_wind_tunnel(
+ cls,
+ boundary_points=None, *,
+ boundary_loops=None,
+ metadata=None,
+ tunnel_height: float,
+ wake_length: float,
+ surface_points: int | Sequence[int] | None = 0,
+ normal_divisions: int = 20,
+ first_layer_thickness: float = 0.004,
+ layer_growth: float = 1.05,
+ connector_layers: int = 80,
+ trailing_edge_divisions: int = 3,
+ leading_edge_station: float = 0.10,
+ trailing_edge_station: float = 0.90,
+ nose_singularity_blend: float = 0.45,
+ wake_singularity_blend: float = 0.55,
+ vertical_singularity_blend: float = 0.55,
+ ):
+ del layer_growth
+ del leading_edge_station
+ del trailing_edge_station
+ del nose_singularity_blend
+ del wake_singularity_blend
+ del vertical_singularity_blend
+
+ return cls.generate_metric_c_grid(
+ boundary_points,
+ boundary_loops=boundary_loops,
+ metadata=metadata,
+ tunnel_height=tunnel_height,
+ wake_length=wake_length,
+ surface_points=surface_points,
+ normal_divisions=normal_divisions,
+ first_layer_thickness=first_layer_thickness,
+ connector_layers=connector_layers,
+ trailing_edge_divisions=trailing_edge_divisions,
+ )
diff --git a/src/QuadMonitor.py b/src/QuadMonitor.py
new file mode 100644
index 0000000..86908d7
--- /dev/null
+++ b/src/QuadMonitor.py
@@ -0,0 +1,196 @@
+from __future__ import annotations
+
+from dataclasses import dataclass
+from typing import Sequence
+
+import numpy as np
+
+
+MINIMUM_MONITOR_VALUE = 1.0e-12
+
+
+def _as_points(points: Sequence[Sequence[float]]) -> np.ndarray:
+ array = np.asarray(points, dtype=float)
+ if array.ndim != 2 or array.shape[1] != 2:
+ raise ValueError('Monitor points must be an N x 2 array.')
+ return array
+
+
+def _as_positive_values(values, name: str, minimum_value: float) -> np.ndarray:
+ array = np.asarray(values, dtype=float)
+ if array.ndim != 1:
+ raise ValueError(f'{name} must be a 1D array.')
+ return np.maximum(array, minimum_value)
+
+
+def _rotation_matrices(angles) -> np.ndarray:
+ radians = np.asarray(angles, dtype=float)
+ cosines = np.cos(radians)
+ sines = np.sin(radians)
+ rotation = np.empty((radians.size, 2, 2), dtype=float)
+ rotation[:, 0, 0] = cosines
+ rotation[:, 0, 1] = -sines
+ rotation[:, 1, 0] = sines
+ rotation[:, 1, 1] = cosines
+ return rotation
+
+
+@dataclass(frozen=True)
+class LineMonitor:
+ values: np.ndarray
+ minimum_value: float = MINIMUM_MONITOR_VALUE
+
+ def __post_init__(self):
+ minimum_value = max(float(self.minimum_value), MINIMUM_MONITOR_VALUE)
+ values = _as_positive_values(self.values, 'Monitor values', minimum_value)
+ object.__setattr__(self, 'minimum_value', minimum_value)
+ object.__setattr__(self, 'values', values)
+
+ @classmethod
+ def uniform(cls, point_count: int, value: float = 1.0):
+ point_count = int(point_count)
+ if point_count < 0:
+ raise ValueError('Point count must be non-negative.')
+ return cls(np.full(point_count, float(value), dtype=float))
+
+ @classmethod
+ def from_point_sizes(cls, sizes, exponent: float = 2.0,
+ minimum_value: float = MINIMUM_MONITOR_VALUE):
+ minimum_value = max(float(minimum_value), MINIMUM_MONITOR_VALUE)
+ size_values = _as_positive_values(sizes, 'Point sizes', minimum_value)
+ return cls(size_values ** (-float(exponent)), minimum_value=minimum_value)
+
+ def for_points(self, points: Sequence[Sequence[float]]) -> np.ndarray:
+ points_array = _as_points(points)
+ if len(points_array) != len(self.values):
+ raise ValueError(
+ 'Monitor values must match the line point count.'
+ )
+ return self.values
+
+
+@dataclass(frozen=True)
+class LineMetricField:
+ tensors: np.ndarray
+ minimum_eigenvalue: float = MINIMUM_MONITOR_VALUE
+
+ def __post_init__(self):
+ minimum_eigenvalue = max(
+ float(self.minimum_eigenvalue),
+ MINIMUM_MONITOR_VALUE,
+ )
+ tensors = np.asarray(self.tensors, dtype=float)
+ if tensors.ndim != 3 or tensors.shape[1:] != (2, 2):
+ raise ValueError('Metric tensors must be an N x 2 x 2 array.')
+
+ symmetric = 0.5 * (tensors + np.swapaxes(tensors, 1, 2))
+ eigenvalues, eigenvectors = np.linalg.eigh(symmetric)
+ clamped = np.maximum(eigenvalues, minimum_eigenvalue)
+ spd_tensors = np.einsum(
+ 'nij,nj,nkj->nik',
+ eigenvectors,
+ clamped,
+ eigenvectors,
+ )
+
+ object.__setattr__(self, 'minimum_eigenvalue', minimum_eigenvalue)
+ object.__setattr__(self, 'tensors', spd_tensors)
+
+ @classmethod
+ def uniform(cls, point_count: int, size: float = 1.0):
+ point_count = int(point_count)
+ if point_count < 0:
+ raise ValueError('Point count must be non-negative.')
+ size = max(float(size), MINIMUM_MONITOR_VALUE)
+ tensor = np.eye(2, dtype=float) / (size * size)
+ return cls(np.repeat(tensor[np.newaxis, :, :], point_count, axis=0))
+
+ @classmethod
+ def from_isotropic_sizes(cls, sizes,
+ minimum_eigenvalue: float = MINIMUM_MONITOR_VALUE):
+ minimum_eigenvalue = max(
+ float(minimum_eigenvalue),
+ MINIMUM_MONITOR_VALUE,
+ )
+ size_values = _as_positive_values(
+ sizes,
+ 'Isotropic sizes',
+ np.sqrt(minimum_eigenvalue),
+ )
+ tensor_values = 1.0 / (size_values * size_values)
+ tensors = np.zeros((size_values.size, 2, 2), dtype=float)
+ tensors[:, 0, 0] = tensor_values
+ tensors[:, 1, 1] = tensor_values
+ return cls(tensors, minimum_eigenvalue=minimum_eigenvalue)
+
+ @classmethod
+ def from_principal_sizes(cls, size_1, size_2, angles=0.0,
+ minimum_eigenvalue: float = MINIMUM_MONITOR_VALUE):
+ minimum_eigenvalue = max(
+ float(minimum_eigenvalue),
+ MINIMUM_MONITOR_VALUE,
+ )
+ first = _as_positive_values(
+ size_1,
+ 'Principal size 1',
+ np.sqrt(minimum_eigenvalue),
+ )
+ second = _as_positive_values(
+ size_2,
+ 'Principal size 2',
+ np.sqrt(minimum_eigenvalue),
+ )
+
+ if first.shape != second.shape:
+ raise ValueError('Principal size arrays must have matching shapes.')
+
+ angle_values = np.asarray(angles, dtype=float)
+ if angle_values.ndim == 0:
+ angle_values = np.full(first.size, float(angle_values), dtype=float)
+ if angle_values.ndim != 1 or angle_values.size != first.size:
+ raise ValueError('Metric angles must match the line point count.')
+
+ diagonal = np.zeros((first.size, 2, 2), dtype=float)
+ diagonal[:, 0, 0] = 1.0 / (first * first)
+ diagonal[:, 1, 1] = 1.0 / (second * second)
+
+ rotations = _rotation_matrices(angle_values)
+ tensors = np.einsum(
+ 'nij,njk,nlk->nil',
+ rotations,
+ diagonal,
+ rotations,
+ )
+ return cls(tensors, minimum_eigenvalue=minimum_eigenvalue)
+
+ def for_points(self, points: Sequence[Sequence[float]]) -> np.ndarray:
+ points_array = _as_points(points)
+ if len(points_array) != len(self.tensors):
+ raise ValueError(
+ 'Metric tensors must match the line point count.'
+ )
+ return self.tensors
+
+ def density_values(self) -> np.ndarray:
+ determinants = np.linalg.det(self.tensors)
+ return np.sqrt(np.maximum(determinants, 0.0))
+
+ def density_monitor(self) -> LineMonitor:
+ return LineMonitor(self.density_values())
+
+ def segment_lengths(self, points: Sequence[Sequence[float]]) -> np.ndarray:
+ points_array = _as_points(points)
+ tensors = self.for_points(points_array)
+
+ if len(points_array) < 2:
+ return np.array([], dtype=float)
+
+ segments = points_array[1:] - points_array[:-1]
+ segment_tensors = 0.5 * (tensors[:-1] + tensors[1:])
+ lengths_squared = np.einsum(
+ 'ni,nij,nj->n',
+ segments,
+ segment_tensors,
+ segments,
+ )
+ return np.sqrt(np.maximum(lengths_squared, 0.0))
diff --git a/src/QuadPipeline.py b/src/QuadPipeline.py
new file mode 100644
index 0000000..dc6f0ee
--- /dev/null
+++ b/src/QuadPipeline.py
@@ -0,0 +1,627 @@
+from __future__ import annotations
+
+from dataclasses import dataclass, field
+from typing import Sequence
+
+import numpy as np
+
+from BlockMesh import BlockMesh
+from ExperimentalCGrid import (
+ ExperimentalCGridGenerator,
+ ExperimentalCGridSettings,
+)
+from ExperimentalOGrid import (
+ ExperimentalOGridGenerator,
+ ExperimentalOGridSettings,
+)
+from QuadLayout import QuadLayoutGenerator
+from QuadMonitor import LineMetricField
+from Smoother import SmootherFactory
+
+
+MINIMUM_POINT_SIZE = 1.0e-9
+
+
+def _as_points(points: Sequence[Sequence[float]]) -> np.ndarray:
+ array = np.asarray(points, dtype=float)
+ if array.ndim != 2 or array.shape[1] != 2:
+ raise ValueError('Pipeline points must be an N x 2 array.')
+ return array
+
+
+def _point_sizes(line) -> np.ndarray:
+ points = _as_points(line)
+ point_count = len(points)
+ if point_count == 0:
+ return np.array([], dtype=float)
+ if point_count == 1:
+ return np.array([1.0], dtype=float)
+
+ segments = np.linalg.norm(points[1:] - points[:-1], axis=1)
+ sizes = np.empty(point_count, dtype=float)
+ sizes[0] = segments[0]
+ sizes[-1] = segments[-1]
+ if point_count > 2:
+ sizes[1:-1] = 0.5 * (segments[:-1] + segments[1:])
+ return np.maximum(sizes, MINIMUM_POINT_SIZE)
+
+
+@dataclass(slots=True)
+class HybridStage2Settings:
+ enabled: bool = True
+ sweeps: int = 2
+ redistribute_u: bool = True
+ redistribute_v: bool = True
+ monitor_kind: str = 'boundary_size'
+
+ def __post_init__(self):
+ self.enabled = bool(self.enabled)
+ self.sweeps = max(0, int(self.sweeps))
+ self.redistribute_u = bool(self.redistribute_u)
+ self.redistribute_v = bool(self.redistribute_v)
+ monitor_kind = str(self.monitor_kind).strip().lower() or 'boundary_size'
+ if monitor_kind not in ('boundary_size',):
+ monitor_kind = 'boundary_size'
+ self.monitor_kind = monitor_kind
+
+
+@dataclass(slots=True)
+class HybridStage1Settings:
+ enabled: bool = True
+ algorithm: str = 'angle_based'
+ iterations: int = 15
+ tolerance: float = 1.0e-4
+ relaxation: float = 0.6
+
+ def __post_init__(self):
+ self.enabled = bool(self.enabled)
+ algorithm = str(self.algorithm).strip().lower() or 'angle_based'
+ if algorithm in ('off', 'disabled'):
+ algorithm = 'none'
+ if algorithm not in ('none', 'simple', 'elliptic', 'angle_based'):
+ algorithm = 'angle_based'
+ self.algorithm = algorithm
+ self.iterations = max(0, int(self.iterations))
+ self.tolerance = max(1.0e-12, float(self.tolerance))
+ self.relaxation = float(np.clip(self.relaxation, 0.01, 1.0))
+
+
+@dataclass(slots=True)
+class HybridQuadPipelineSettings:
+ layout_strategy: str = 'metric_c_grid'
+ protect_near_wall: bool = True
+ singularity_template: str = 'auto_boundary_c'
+ stage2: HybridStage2Settings = field(default_factory=HybridStage2Settings)
+ stage1: HybridStage1Settings = field(default_factory=HybridStage1Settings)
+
+ def __post_init__(self):
+ strategy = (
+ str(self.layout_strategy).strip().lower() or 'metric_c_grid'
+ )
+ if strategy in ('standard_stub',):
+ strategy = 'multi_element_oc'
+ if strategy in ('metric_wind_tunnel', 'metric_based'):
+ strategy = 'metric_c_grid'
+ if strategy not in ('metric_c_grid', 'multi_element_oc'):
+ strategy = 'metric_c_grid'
+ self.layout_strategy = strategy
+ self.protect_near_wall = bool(self.protect_near_wall)
+ template = str(self.singularity_template).strip().lower() or 'auto_boundary_c'
+ if template not in ('auto_boundary_c',):
+ template = 'auto_boundary_c'
+ self.singularity_template = template
+ if not isinstance(self.stage2, HybridStage2Settings):
+ self.stage2 = HybridStage2Settings(**dict(self.stage2))
+ if not isinstance(self.stage1, HybridStage1Settings):
+ self.stage1 = HybridStage1Settings(**dict(self.stage1))
+
+
+@dataclass(slots=True)
+class HybridQuadPipelineResult:
+ blocks: list[BlockMesh]
+ layout_plan: object
+ metadata: dict
+
+
+class HybridQuadPipeline:
+ """4 -> 2 -> 1 orchestration for layout-driven structured hybrid meshes."""
+
+ @staticmethod
+ def default_stage2_metadata(settings: HybridStage2Settings, *,
+ protect_near_wall: bool = True):
+ return {
+ 'enabled': bool(settings.enabled),
+ 'monitor_kind': settings.monitor_kind,
+ 'sweeps': int(settings.sweeps),
+ 'redistribute_u': bool(settings.redistribute_u),
+ 'redistribute_v': bool(settings.redistribute_v),
+ 'protect_near_wall': bool(protect_near_wall),
+ 'blocks_processed': 0,
+ 'blocks_skipped': 0,
+ 'applied': False,
+ }
+
+ @staticmethod
+ def default_stage1_metadata(settings: HybridStage1Settings, *,
+ protect_near_wall: bool = True):
+ return {
+ 'enabled': bool(settings.enabled),
+ 'algorithm': settings.algorithm,
+ 'iterations': int(settings.iterations),
+ 'tolerance': float(settings.tolerance),
+ 'protect_near_wall': bool(protect_near_wall),
+ 'blocks_processed': 0,
+ 'blocks_skipped': 0,
+ 'applied': False,
+ }
+
+ @classmethod
+ def build_metadata(cls, layout_plan, settings: HybridQuadPipelineSettings, *,
+ engine: str = 'hybrid', stage2_metadata=None,
+ stage1_metadata=None):
+ stage2_metadata = dict(
+ stage2_metadata or cls.default_stage2_metadata(
+ settings.stage2,
+ protect_near_wall=settings.protect_near_wall,
+ )
+ )
+ stage1_metadata = dict(
+ stage1_metadata or cls.default_stage1_metadata(
+ settings.stage1,
+ protect_near_wall=settings.protect_near_wall,
+ )
+ )
+ return {
+ 'engine': str(engine).strip().lower() or 'hybrid',
+ 'layout_strategy': settings.layout_strategy,
+ 'protect_near_wall': bool(settings.protect_near_wall),
+ 'singularity_controls': {
+ 'template': settings.singularity_template,
+ },
+ 'stages': {
+ 'stage4_ready': True,
+ 'stage2_applied': bool(stage2_metadata.get('applied', False)),
+ 'stage1_applied': bool(stage1_metadata.get('applied', False)),
+ },
+ 'stage4': {
+ 'implemented': bool(layout_plan.metadata.get('implemented', False)),
+ 'strategy': settings.layout_strategy,
+ 'element_count': int(layout_plan.metadata.get('element_count', 0)),
+ 'block_count': int(layout_plan.metadata.get('block_count', 0)),
+ 'singularity_count': len(layout_plan.singularities),
+ 'applied': True,
+ },
+ 'stage2': stage2_metadata,
+ 'stage1': stage1_metadata,
+ }
+
+ def run_stage4(self, blocks=None, contour=None, settings=None, *,
+ airfoil_settings=None, tunnel_settings=None,
+ wake_settings=None, trailing_edge_settings=None,
+ contour_metadata=None, boundary_loops=None,
+ engine: str = 'hybrid'):
+ if settings is None:
+ settings = HybridQuadPipelineSettings()
+ if not isinstance(settings, HybridQuadPipelineSettings):
+ settings = HybridQuadPipelineSettings(**dict(settings))
+
+ layout_plan = self.build_layout_plan(
+ contour,
+ settings,
+ airfoil_settings=airfoil_settings,
+ tunnel_settings=tunnel_settings,
+ wake_settings=wake_settings,
+ trailing_edge_settings=trailing_edge_settings,
+ contour_metadata=contour_metadata,
+ boundary_loops=boundary_loops,
+ )
+
+ if blocks is None:
+ blocks = self.build_blocks_from_layout(
+ layout_plan,
+ airfoil_settings=airfoil_settings,
+ tunnel_settings=tunnel_settings,
+ )
+ else:
+ blocks = list(blocks)
+
+ return HybridQuadPipelineResult(
+ blocks=blocks,
+ layout_plan=layout_plan,
+ metadata=self.build_metadata(
+ layout_plan,
+ settings,
+ engine=engine,
+ ),
+ )
+
+ def run(self, blocks=None, contour=None, settings=None, *,
+ airfoil_settings=None, tunnel_settings=None, wake_settings=None,
+ trailing_edge_settings=None, contour_metadata=None,
+ boundary_loops=None):
+ stage4_result = self.run_stage4(
+ blocks=blocks,
+ contour=contour,
+ settings=settings,
+ airfoil_settings=airfoil_settings,
+ tunnel_settings=tunnel_settings,
+ wake_settings=wake_settings,
+ trailing_edge_settings=trailing_edge_settings,
+ contour_metadata=contour_metadata,
+ boundary_loops=boundary_loops,
+ engine='hybrid',
+ )
+ if settings is None:
+ settings = HybridQuadPipelineSettings()
+ if not isinstance(settings, HybridQuadPipelineSettings):
+ settings = HybridQuadPipelineSettings(**dict(settings))
+
+ layout_plan = stage4_result.layout_plan
+ blocks = list(stage4_result.blocks)
+
+ stage2_metadata = self.apply_stage2(
+ blocks,
+ settings.stage2,
+ protect_near_wall=settings.protect_near_wall,
+ )
+ stage1_metadata = self.apply_stage1(
+ blocks,
+ settings.stage1,
+ protect_near_wall=settings.protect_near_wall,
+ )
+
+ return HybridQuadPipelineResult(
+ blocks=blocks,
+ layout_plan=layout_plan,
+ metadata=self.build_metadata(
+ layout_plan,
+ settings,
+ engine='hybrid',
+ stage2_metadata=stage2_metadata,
+ stage1_metadata=stage1_metadata,
+ ),
+ )
+
+ @staticmethod
+ def build_layout_plan(contour, settings: HybridQuadPipelineSettings, *,
+ airfoil_settings=None, tunnel_settings=None,
+ wake_settings=None, trailing_edge_settings=None,
+ contour_metadata=None, boundary_loops=None):
+ if contour is None and boundary_loops is None:
+ raise ValueError('The hybrid pipeline requires contour data or explicit boundary loops.')
+
+ contour_metadata = dict(contour_metadata or {})
+ explicit_loops = (
+ boundary_loops is not None or
+ any(
+ key in contour_metadata
+ for key in ('hybrid_boundary_loops', 'boundary_loops', 'element_loops')
+ )
+ )
+
+ tunnel_height = float(
+ getattr(tunnel_settings, 'tunnel_height', 3.5)
+ )
+ wake_length = float(
+ getattr(wake_settings, 'tunnel_wake', 7.0)
+ )
+ surface_points = 0 if explicit_loops else (
+ len(contour[0]) if contour is not None and len(contour) == 2 else 0
+ )
+ normal_divisions = int(getattr(airfoil_settings, 'divisions', 20))
+ first_layer = float(getattr(airfoil_settings, 'thickness', 0.004))
+ layer_growth = float(getattr(airfoil_settings, 'growth', 1.05))
+ connector_layers = int(getattr(tunnel_settings, 'divisions_height', 80))
+ trailing_edge_divisions = int(
+ getattr(trailing_edge_settings, 'trailing_edge_divisions', 3)
+ )
+
+ if settings.layout_strategy == 'metric_c_grid':
+ return QuadLayoutGenerator.generate_metric_c_grid(
+ contour,
+ boundary_loops=boundary_loops,
+ metadata=contour_metadata,
+ tunnel_height=tunnel_height,
+ wake_length=wake_length,
+ surface_points=surface_points,
+ normal_divisions=normal_divisions,
+ first_layer_thickness=first_layer,
+ connector_layers=connector_layers,
+ trailing_edge_divisions=trailing_edge_divisions,
+ singularity_template=settings.singularity_template,
+ )
+
+ return QuadLayoutGenerator.generate(
+ contour,
+ boundary_loops=boundary_loops,
+ metadata=contour_metadata,
+ tunnel_height=tunnel_height,
+ wake_length=wake_length,
+ surface_points=surface_points,
+ normal_divisions=normal_divisions,
+ first_layer_thickness=first_layer,
+ layer_growth=layer_growth,
+ connector_layers=connector_layers,
+ trailing_edge_divisions=trailing_edge_divisions,
+ )
+
+ @classmethod
+ def build_blocks_from_layout(cls, layout_plan, *, airfoil_settings=None,
+ tunnel_settings=None):
+ ring_settings = ExperimentalOGridSettings(
+ name='block_hybrid_element',
+ normal_divisions=max(4, int(getattr(airfoil_settings, 'divisions', 20))),
+ first_layer_thickness=max(
+ 1.0e-12,
+ float(getattr(airfoil_settings, 'thickness', 0.004)),
+ ),
+ initial_smoothing_iterations=max(
+ 2,
+ min(30, int(getattr(tunnel_settings, 'smoothing_iterations', 10))),
+ ),
+ final_smoothing_iterations=max(
+ 0,
+ min(10, int(getattr(tunnel_settings, 'protected_guide_smoothing', 3))),
+ ),
+ smoothing_tolerance=max(
+ 1.0e-12,
+ float(getattr(tunnel_settings, 'smoothing_tolerance', 1.0e-4)),
+ ),
+ relaxation=float(
+ np.clip(
+ float(getattr(tunnel_settings, 'elliptic_relaxation', 0.4)),
+ 0.01,
+ 1.0,
+ )
+ ),
+ )
+
+ connector_iterations = max(
+ 0,
+ min(20, int(getattr(tunnel_settings, 'smoothing_iterations', 10))),
+ )
+ connector_tolerance = max(
+ 1.0e-12,
+ float(getattr(tunnel_settings, 'smoothing_tolerance', 1.0e-4)),
+ )
+ connector_relaxation = float(
+ np.clip(float(getattr(tunnel_settings, 'elliptic_relaxation', 0.4)), 0.01, 1.0)
+ )
+
+ blocks = []
+ for spec in layout_plan.block_specs:
+ block = cls._build_block_from_spec(
+ spec,
+ ring_settings=ring_settings,
+ connector_iterations=connector_iterations,
+ connector_tolerance=connector_tolerance,
+ connector_relaxation=connector_relaxation,
+ )
+ block.hybrid_role = spec.role
+ block.hybrid_element_index = spec.element_index
+ block.hybrid_protected = bool(spec.protected)
+ block.hybrid_metadata = dict(spec.metadata)
+ blocks.append(block)
+
+ return blocks
+
+ @staticmethod
+ def _build_block_from_spec(spec, *, ring_settings, connector_iterations,
+ connector_tolerance, connector_relaxation):
+ if spec.role == 'metric_patch':
+ block = BlockMesh(name=spec.name)
+ block.transfinite(
+ boundary=[
+ spec.lower_boundary.tolist(),
+ spec.upper_boundary.tolist(),
+ spec.left_boundary.tolist(),
+ spec.right_boundary.tolist(),
+ ]
+ )
+
+ x_grid, y_grid = ExperimentalOGridGenerator._ulines_to_grid(
+ block.getULines()
+ )
+ x_grid, y_grid = ExperimentalOGridGenerator._refine_block(
+ x_grid,
+ y_grid,
+ ring_settings,
+ )
+ block.setUlines(
+ ExperimentalOGridGenerator._grid_to_ulines(x_grid, y_grid)
+ )
+ return block
+
+ if spec.role == 'metric_c_grid':
+ contour_points = np.asarray(
+ spec.metadata.get('contour_points', spec.lower_boundary),
+ dtype=float,
+ )
+ settings = ExperimentalCGridSettings(
+ name=spec.name,
+ surface_points=max(0, len(contour_points)),
+ normal_divisions=max(
+ 4,
+ int(spec.metadata.get(
+ 'normal_divisions',
+ ring_settings.normal_divisions,
+ )),
+ ),
+ first_layer_thickness=max(
+ 1.0e-12,
+ float(spec.metadata.get(
+ 'first_layer_thickness',
+ ring_settings.first_layer_thickness,
+ )),
+ ),
+ wake_points=max(
+ 4,
+ int(spec.metadata.get(
+ 'wake_point_count',
+ ring_settings.normal_divisions,
+ )),
+ ),
+ initial_smoothing_iterations=max(
+ 20,
+ 4 * max(1, connector_iterations),
+ ),
+ final_smoothing_iterations=max(6, connector_iterations // 2),
+ local_te_smoothing_iterations=max(4, connector_iterations // 3),
+ smoothing_tolerance=connector_tolerance,
+ relaxation=connector_relaxation,
+ )
+ generator = ExperimentalCGridGenerator()
+ return generator.build_block(
+ (contour_points[:, 0], contour_points[:, 1]),
+ radius=float(spec.metadata['tunnel_height']),
+ wake_length=float(spec.metadata['wake_length']),
+ settings=settings,
+ )
+
+ if spec.role == 'element_ring':
+ return ExperimentalOGridGenerator._build_block(
+ spec.lower_boundary,
+ spec.upper_boundary,
+ settings=ring_settings,
+ name=spec.name,
+ )
+
+ block = BlockMesh(name=spec.name)
+ block.transfinite(
+ boundary=[
+ spec.lower_boundary.tolist(),
+ spec.upper_boundary.tolist(),
+ spec.left_boundary.tolist(),
+ spec.right_boundary.tolist(),
+ ]
+ )
+
+ if connector_iterations > 0:
+ smoother = SmootherFactory.create_smoother('elliptic')
+ smoother.smooth(
+ block,
+ iterations=connector_iterations,
+ tolerance=connector_tolerance,
+ relaxation=connector_relaxation,
+ )
+ return block
+
+ @staticmethod
+ def _eligible_blocks(blocks, *, protect_near_wall: bool):
+ if not protect_near_wall:
+ return list(blocks)
+ return [
+ block for block in blocks
+ if not bool(getattr(block, 'hybrid_protected', False))
+ ]
+
+ def apply_stage2(self, blocks, settings: HybridStage2Settings, *,
+ protect_near_wall: bool = True):
+ eligible_blocks = self._eligible_blocks(
+ blocks,
+ protect_near_wall=protect_near_wall,
+ )
+ metadata = {
+ 'enabled': bool(settings.enabled),
+ 'monitor_kind': settings.monitor_kind,
+ 'sweeps': int(settings.sweeps),
+ 'redistribute_u': bool(settings.redistribute_u),
+ 'redistribute_v': bool(settings.redistribute_v),
+ 'protect_near_wall': bool(protect_near_wall),
+ 'blocks_processed': 0,
+ 'blocks_skipped': len(blocks) - len(eligible_blocks),
+ 'applied': False,
+ }
+
+ if not settings.enabled or settings.sweeps <= 0:
+ return metadata
+
+ metadata['applied'] = True
+ processed = 0
+ for block in eligible_blocks:
+ if self._redistribute_block(block, settings):
+ processed += 1
+
+ metadata['blocks_processed'] = processed
+ return metadata
+
+ def _redistribute_block(self, block, settings: HybridStage2Settings) -> bool:
+ ulines = block.getULines()
+ if not ulines or len(ulines) < 3 or len(ulines[0]) < 3:
+ return False
+
+ redistributed = False
+ for _ in range(settings.sweeps):
+ if settings.redistribute_u:
+ bottom_sizes = _point_sizes(block.getULines()[0])
+ top_sizes = _point_sizes(block.getULines()[-1])
+ denominator = max(1, len(block.getULines()) - 1)
+ for line_index in range(1, len(block.getULines()) - 1):
+ blend = float(line_index) / float(denominator)
+ point_sizes = (
+ (1.0 - blend) * bottom_sizes +
+ blend * top_sizes
+ )
+ block.redistributeLine(
+ direction='u',
+ number=line_index,
+ metric=LineMetricField.from_isotropic_sizes(point_sizes),
+ )
+ redistributed = True
+
+ if settings.redistribute_v:
+ vlines = block.getVLines()
+ left_sizes = _point_sizes(vlines[0])
+ right_sizes = _point_sizes(vlines[-1])
+ denominator = max(1, len(vlines) - 1)
+ for line_index in range(1, len(vlines) - 1):
+ blend = float(line_index) / float(denominator)
+ point_sizes = (
+ (1.0 - blend) * left_sizes +
+ blend * right_sizes
+ )
+ block.redistributeLine(
+ direction='v',
+ number=line_index,
+ metric=LineMetricField.from_isotropic_sizes(point_sizes),
+ )
+ redistributed = True
+
+ return redistributed
+
+ @classmethod
+ def apply_stage1(cls, blocks, settings: HybridStage1Settings, *,
+ protect_near_wall: bool = True):
+ eligible_blocks = cls._eligible_blocks(
+ blocks,
+ protect_near_wall=protect_near_wall,
+ )
+ metadata = {
+ 'enabled': bool(settings.enabled),
+ 'algorithm': settings.algorithm,
+ 'iterations': int(settings.iterations),
+ 'tolerance': float(settings.tolerance),
+ 'protect_near_wall': bool(protect_near_wall),
+ 'blocks_processed': 0,
+ 'blocks_skipped': len(blocks) - len(eligible_blocks),
+ 'applied': False,
+ }
+
+ if (
+ not settings.enabled or
+ settings.algorithm == 'none' or
+ settings.iterations <= 0):
+ return metadata
+
+ metadata['applied'] = True
+ smoother = SmootherFactory.create_smoother(settings.algorithm)
+ for block in eligible_blocks:
+ smoother.smooth(
+ block,
+ iterations=settings.iterations,
+ tolerance=settings.tolerance,
+ relaxation=settings.relaxation,
+ )
+
+ metadata['blocks_processed'] = len(eligible_blocks)
+ return metadata
diff --git a/src/QuadQuality.py b/src/QuadQuality.py
new file mode 100644
index 0000000..4a46ed7
--- /dev/null
+++ b/src/QuadQuality.py
@@ -0,0 +1,190 @@
+from __future__ import annotations
+
+from dataclasses import dataclass
+from typing import Sequence
+
+import numpy as np
+
+from MathUtils import VectorUtils
+
+
+AREA_TOLERANCE = 1.0e-12
+
+
+def _as_vertices(vertices: Sequence[Sequence[float]]) -> np.ndarray:
+ array = np.asarray(vertices, dtype=float)
+ if array.ndim != 2 or array.shape[1] != 2:
+ raise ValueError(
+ 'Quadrilateral quality expects vertices as an N x 2 array.'
+ )
+ return array
+
+
+def _as_connectivity(connectivity: Sequence[Sequence[int]]) -> np.ndarray:
+ array = np.asarray(connectivity, dtype=int)
+ if array.size == 0:
+ return np.empty((0, 4), dtype=int)
+ if array.ndim != 2 or array.shape[1] != 4:
+ raise ValueError(
+ 'Quadrilateral quality expects connectivity as an M x 4 array.'
+ )
+ return array
+
+
+@dataclass(frozen=True)
+class QuadQualityReport:
+ criterion: str
+ values: np.ndarray
+ signed_areas: np.ndarray
+
+ def __post_init__(self):
+ values = np.asarray(self.values, dtype=float)
+ signed_areas = np.asarray(self.signed_areas, dtype=float)
+
+ if values.ndim != 1:
+ raise ValueError('Quadrilateral quality values must be a 1D array.')
+ if signed_areas.ndim != 1:
+ raise ValueError('Quadrilateral signed areas must be a 1D array.')
+ if values.shape != signed_areas.shape:
+ raise ValueError(
+ 'Quadrilateral quality values and signed areas must have '
+ 'matching shapes.'
+ )
+
+ object.__setattr__(self, 'values', values)
+ object.__setattr__(self, 'signed_areas', signed_areas)
+
+ @property
+ def cell_count(self) -> int:
+ return int(self.values.size)
+
+ @property
+ def minimum_value(self) -> float | None:
+ if not self.values.size:
+ return None
+ return float(np.min(self.values))
+
+ @property
+ def maximum_value(self) -> float | None:
+ if not self.values.size:
+ return None
+ return float(np.max(self.values))
+
+ @property
+ def mean_value(self) -> float | None:
+ if not self.values.size:
+ return None
+ return float(np.mean(self.values))
+
+ @property
+ def minimum_signed_area(self) -> float | None:
+ if not self.signed_areas.size:
+ return None
+ return float(np.min(self.signed_areas))
+
+ @property
+ def has_inverted_cells(self) -> bool:
+ return bool(np.any(self.signed_areas < -AREA_TOLERANCE))
+
+ @property
+ def has_degenerate_cells(self) -> bool:
+ return bool(np.any(np.abs(self.signed_areas) <= AREA_TOLERANCE))
+
+ @property
+ def inverted_cell_indices(self) -> np.ndarray:
+ return np.flatnonzero(self.signed_areas < -AREA_TOLERANCE)
+
+
+class QuadQualityEvaluator:
+ """Reusable quality evaluation for planar quadrilateral meshes."""
+
+ supported_criteria = ('k2inf',)
+
+ @classmethod
+ def evaluate(cls, vertices, connectivity, criterion='k2inf'):
+ normalized_criterion = str(criterion).strip().lower()
+ if normalized_criterion not in cls.supported_criteria:
+ raise ValueError(
+ f'Unknown mesh quality criterion: {criterion}'
+ )
+
+ vertices_array = _as_vertices(vertices)
+ connectivity_array = _as_connectivity(connectivity)
+
+ signed_areas = cls.signed_areas(vertices_array, connectivity_array)
+ if normalized_criterion == 'k2inf':
+ values = cls.k2inf(vertices_array, connectivity_array)
+
+ return QuadQualityReport(
+ criterion=normalized_criterion,
+ values=values,
+ signed_areas=signed_areas,
+ )
+
+ @staticmethod
+ def signed_areas(vertices, connectivity) -> np.ndarray:
+ vertices_array = _as_vertices(vertices)
+ connectivity_array = _as_connectivity(connectivity)
+
+ if connectivity_array.size == 0:
+ return np.array([], dtype=float)
+
+ cells = vertices_array[connectivity_array]
+ x_values = cells[:, :, 0]
+ y_values = cells[:, :, 1]
+ return 0.5 * np.sum(
+ x_values * np.roll(y_values, -1, axis=1) -
+ y_values * np.roll(x_values, -1, axis=1),
+ axis=1,
+ )
+
+ @staticmethod
+ def k2inf(vertices, connectivity) -> np.ndarray:
+ vertices_array = _as_vertices(vertices)
+ connectivity_array = _as_connectivity(connectivity)
+
+ if connectivity_array.size == 0:
+ return np.array([], dtype=float)
+
+ v12 = vertices_array[connectivity_array[:, 1]] - vertices_array[
+ connectivity_array[:, 0]
+ ]
+ v23 = vertices_array[connectivity_array[:, 2]] - vertices_array[
+ connectivity_array[:, 1]
+ ]
+ v34 = vertices_array[connectivity_array[:, 3]] - vertices_array[
+ connectivity_array[:, 2]
+ ]
+ v41 = vertices_array[connectivity_array[:, 0]] - vertices_array[
+ connectivity_array[:, 3]
+ ]
+
+ a = np.linalg.norm(v12, axis=1)
+ b = np.linalg.norm(v23, axis=1)
+ c = np.linalg.norm(v34, axis=1)
+ d = np.linalg.norm(v41, axis=1)
+
+ alpha = VectorUtils.angle_between(v12, -v41)
+ beta = VectorUtils.angle_between(v23, -v12)
+ gamma = VectorUtils.angle_between(v34, -v23)
+ delta = VectorUtils.angle_between(v41, -v34)
+
+ sin_alpha = np.sin(alpha)
+ sin_beta = np.sin(beta)
+ sin_gamma = np.sin(gamma)
+ sin_delta = np.sin(delta)
+
+ def _quality_ratio(first, second, sine_values):
+ denominator = first * second * sine_values
+ return np.divide(
+ first**2 + second**2,
+ denominator,
+ out=np.full_like(first, np.inf, dtype=float),
+ where=np.abs(denominator) > AREA_TOLERANCE,
+ )
+
+ ka = _quality_ratio(a, d, sin_alpha)
+ kb = _quality_ratio(a, b, sin_beta)
+ kc = _quality_ratio(b, c, sin_gamma)
+ kd = _quality_ratio(c, d, sin_delta)
+ return 0.5 * np.max(np.stack((ka, kb, kc, kd)), axis=0)
diff --git a/src/QuadRedistribute.py b/src/QuadRedistribute.py
new file mode 100644
index 0000000..180a259
--- /dev/null
+++ b/src/QuadRedistribute.py
@@ -0,0 +1,121 @@
+from __future__ import annotations
+
+from typing import Sequence
+
+import numpy as np
+
+from QuadMonitor import LineMetricField, LineMonitor
+
+
+MINIMUM_LENGTH = 1.0e-15
+
+
+def _as_points(points: Sequence[Sequence[float]]) -> np.ndarray:
+ array = np.asarray(points, dtype=float)
+ if array.ndim != 2 or array.shape[1] != 2:
+ raise ValueError('Polyline points must be an N x 2 array.')
+ return array
+
+
+def _as_monitor(monitor, point_count: int) -> LineMonitor:
+ if isinstance(monitor, LineMonitor):
+ line_monitor = monitor
+ else:
+ line_monitor = LineMonitor(monitor)
+
+ if len(line_monitor.values) != point_count:
+ raise ValueError('Monitor values must match the line point count.')
+ return line_monitor
+
+
+def _as_metric(metric, point_count: int) -> LineMetricField:
+ if isinstance(metric, LineMetricField):
+ line_metric = metric
+ else:
+ line_metric = LineMetricField(metric)
+
+ if len(line_metric.tensors) != point_count:
+ raise ValueError('Metric tensors must match the line point count.')
+ return line_metric
+
+
+def polyline_arc_lengths(points: Sequence[Sequence[float]]) -> np.ndarray:
+ points_array = _as_points(points)
+ if len(points_array) < 2:
+ return np.array([], dtype=float)
+ return np.linalg.norm(points_array[1:] - points_array[:-1], axis=1)
+
+
+def weighted_segment_lengths(points: Sequence[Sequence[float]], monitor=None,
+ metric=None) -> np.ndarray:
+ points_array = _as_points(points)
+ if monitor is not None and metric is not None:
+ raise ValueError('Provide either a monitor or a metric, not both.')
+
+ if len(points_array) < 2:
+ return np.array([], dtype=float)
+
+ if metric is not None:
+ return _as_metric(metric, len(points_array)).segment_lengths(points_array)
+
+ arc_lengths = polyline_arc_lengths(points_array)
+ if monitor is None:
+ return arc_lengths
+
+ monitor_values = _as_monitor(monitor, len(points_array)).for_points(points_array)
+ segment_monitor = 0.5 * (monitor_values[:-1] + monitor_values[1:])
+ return segment_monitor * arc_lengths
+
+
+def _cumulative_profile(segment_lengths: np.ndarray) -> np.ndarray:
+ if segment_lengths.size == 0:
+ return np.array([0.0], dtype=float)
+ return np.concatenate(([0.0], np.cumsum(segment_lengths)))
+
+
+def _compress_profile(cumulative: np.ndarray, points: np.ndarray):
+ if cumulative.size != len(points):
+ raise ValueError('Cumulative profile must match the point count.')
+
+ if cumulative.size <= 1:
+ return cumulative, points
+
+ keep = np.ones(cumulative.size, dtype=bool)
+ keep[1:] = np.diff(cumulative) > MINIMUM_LENGTH
+ keep[0] = True
+ keep[-1] = True
+ return cumulative[keep], points[keep]
+
+
+def redistribute_polyline(points: Sequence[Sequence[float]], point_count: int | None = None,
+ monitor=None, metric=None) -> np.ndarray:
+ points_array = _as_points(points)
+ if point_count is None:
+ point_count = len(points_array)
+ point_count = int(point_count)
+
+ if point_count < 0:
+ raise ValueError('Point count must be non-negative.')
+ if len(points_array) == 0:
+ return np.empty((0, 2), dtype=float)
+ if len(points_array) == 1:
+ return np.repeat(points_array, point_count, axis=0)
+
+ segment_lengths = weighted_segment_lengths(
+ points_array,
+ monitor=monitor,
+ metric=metric,
+ )
+ cumulative = _cumulative_profile(segment_lengths)
+
+ if cumulative[-1] <= MINIMUM_LENGTH:
+ cumulative = _cumulative_profile(polyline_arc_lengths(points_array))
+
+ if cumulative[-1] <= MINIMUM_LENGTH:
+ return np.repeat(points_array[:1], point_count, axis=0)
+
+ cumulative, points_array = _compress_profile(cumulative, points_array)
+ targets = np.linspace(0.0, cumulative[-1], point_count)
+ x_values = np.interp(targets, cumulative, points_array[:, 0])
+ y_values = np.interp(targets, cumulative, points_array[:, 1])
+ return np.column_stack((x_values, y_values))
diff --git a/src/Settings.py b/src/Settings.py
index fea5452..e8e4603 100644
--- a/src/Settings.py
+++ b/src/Settings.py
@@ -1,85 +1,97 @@
-# ****************
-# PyAero settings
-# ****************
-
-import os
-
-
-PYAEROPATH = os.getcwd()
-
-# check if user has set the path via environment variable
-if os.getenv('PYAEROPATH'):
- PYAEROPATH = os.getenv('PYAEROPATH')
-
-# path to data
-DATAPATH = os.path.join(PYAEROPATH, 'data')
-
-# path to data (e.g. airfoil coordinate files)
-# path can be absolute or relative (to position where starting PyAero)
-AIRFOILDATA = os.path.join(PYAEROPATH, 'data/Airfoils')
-
-# modified contours and mesh folder
-OUTPUTDATA = os.path.join(DATAPATH, 'OUTPUT')
-
-# path to menu data
-MENUDATA = os.path.join(DATAPATH, 'Menus')
-
-# path to log files
-LOGDATA = os.path.join(DATAPATH, 'LOGS')
-
-# set locale
-# can be either 'C' or ''
-# if string is empty then system default locale is used
-# in case of 'C' decimal separator is a dot in spin boxes, etc.
-LOCALE = 'C'
-
-# application can be exited by pressing the escape key
-EXITONESCAPE = True
-
-# airfoil chord length
-CHORDLENGTH = 1.
-
-# path to icons
-ICONS = os.path.join(PYAEROPATH, 'data/Icons')
-ICONS_S = os.path.join(ICONS, '16x16')
-ICONS_L = os.path.join(ICONS, '24x24')
-
-# size of airfoil coordinate markers in pixels
-MARKERSIZE = 3
-
-# default airfoil for fast loading
-DEFAULT_CONTOUR = os.path.join(AIRFOILDATA, 'F1K/hn1033a.dat')
-
-# set the filter for files to be shown in dialogs
-DIALOGFILTER = 'Airfoil contour files (*.dat *.txt)'
-DIALOGFILTER_MESH = 'Mesh files FIRE(*.flma);;Mesh files SU2 (*.su2);;Mesh files GMSH (*.msh)'
-
-# set the filter for files to be shown in the airfoil browser
-FILEFILTER = ['*.dat', '*.txt', '*.su2']
-
-# set anchor for zooming
-# 'mouse' means zooming wrt to mouse pointer location
-# 'center' means zooming wrt to the view center
-ZOOMANCHOR = 'mouse'
-
-# background of graphicsview ('solid', 'gradient')
-VIEWSTYLE = 'solid'
-
-# set zoom limits so that scene is always in meaningful size
-MINZOOM = 10.0
-MAXZOOM = 120000.
-
-# set minimum relative rubberband size
-# i.e. width of zoom rectangle wrt to viewer window width
-# for smaller rectangles zoom is deactivated to avoid accidential zooms
-# valid values between 0.05 and 1.0
-RUBBERBANDSIZE = 0.08
-
-# scale increment (must be >= 1.1)
-SCALEINC = 1.1
-
- # zoom direction (can be inverted by changing the sign)
-ZOOMDIRECTION = -1
-
-# Color for emphasized log messages
-LOGCOLOR = '#1763E7'
+import os
+import configparser
+from pathlib import Path
+
+
+ROOT = Path(__file__).resolve().parent.parent
+CONFIG_FILE = ROOT / 'config' / 'config.ini'
+WINDOW_STARTUP_MODES = ('maximized', 'preset_1', 'preset_2', 'preset_3')
+
+
+def normalize_window_startup_mode(value):
+ mode = str(value or '').strip().lower()
+ if mode in WINDOW_STARTUP_MODES:
+ return mode
+ raise ValueError(
+ 'Window startup mode must be one of: '
+ + ', '.join(WINDOW_STARTUP_MODES)
+ )
+
+
+def parse_window_geometry(value):
+ parts = [part.strip() for part in str(value or '').split(',')]
+ if len(parts) != 4:
+ raise ValueError(
+ 'Window geometry must contain exactly four integers: '
+ 'x, y, width, height.'
+ )
+
+ try:
+ x, y, width, height = (int(part) for part in parts)
+ except ValueError as error:
+ raise ValueError(
+ 'Window geometry must contain only integers: '
+ 'x, y, width, height.'
+ ) from error
+
+ if width <= 0 or height <= 0:
+ raise ValueError(
+ 'Window geometry width and height must be positive integers.'
+ )
+
+ return x, y, width, height
+
+
+class Config:
+ def __init__(self, mainwindow):
+ self.mw = mainwindow
+ self.load_config()
+ self.set_attributes()
+
+ def get(self, section, key):
+ return self.config_parser.get(section, key)
+
+ def getint(self, section, key):
+ return self.config_parser.getint(section, key)
+
+ def getfloat(self, section, key):
+ return self.config_parser.getfloat(section, key)
+
+ def getboolean(self, section, key):
+ return self.config_parser.getboolean(section, key)
+
+ def load_config(self):
+ # Read the configuration file
+ self.config_parser = configparser.ConfigParser(
+ interpolation=configparser.ExtendedInterpolation()
+ )
+ self.config_parser.optionxform = str
+ self.config_parser.read(CONFIG_FILE, encoding='utf-8')
+ self.config = self.config_parser
+
+ def reload(self):
+ self.load_config()
+ self.set_attributes()
+
+ def set_attributes(self):
+ # Automatically derive attributes from the config file
+ attributes = {section: self.config_parser.options(section) for section in self.config_parser.sections()}
+
+ for section, keys in attributes.items():
+ for key in keys:
+ value = self.config_parser.get(section, key)
+ try:
+ # Try to convert to int
+ value = int(value)
+ except ValueError:
+ try:
+ # Try to convert to float
+ value = float(value)
+ except ValueError:
+ try:
+ # Try to convert to boolean
+ value = self.config_parser.getboolean(section, key)
+ except ValueError:
+ # Keep as string if all conversions fail
+ pass
+ setattr(self.mw, key.upper(), value)
diff --git a/src/SettingsEditor.py b/src/SettingsEditor.py
new file mode 100644
index 0000000..3deab02
--- /dev/null
+++ b/src/SettingsEditor.py
@@ -0,0 +1,229 @@
+from __future__ import annotations
+
+import configparser
+from collections import OrderedDict
+
+from PySide6 import QtCore, QtWidgets
+
+import Settings
+import UiExport
+
+
+CHOICE_FIELDS = {
+ ('Application', 'DECIMAL_SEPARATOR'): ('.', ','),
+ ('Graphics', 'ZOOM_ANCHOR'): ('mouse', 'center'),
+ ('Graphics', 'VIEW_STYLE'): ('solid', 'gradient'),
+ ('Window', 'WINDOW_STARTUP_MODE'): Settings.WINDOW_STARTUP_MODES,
+}
+
+BOOLEAN_STRINGS = {'true', 'false', 'yes', 'no', 'on', 'off', '1', '0'}
+CONFIG_HEADER = '; ****************\n; PyAero Settings\n; ****************\n\n'
+
+
+class SettingsEditorDialog(QtWidgets.QDialog):
+ def __init__(self, mainwindow):
+ super().__init__(mainwindow)
+ self.mw = mainwindow
+ self._fields = OrderedDict()
+ self._parser = self._load_parser()
+
+ self._build_ui()
+ self._populate_tabs()
+
+ def _build_ui(self):
+ self.setWindowTitle('Settings')
+ self.setModal(True)
+ self.resize(760, 620)
+ self.setMinimumSize(700, 560)
+ self.setWindowFlag(QtCore.Qt.WindowContextHelpButtonHint, False)
+
+ layout = QtWidgets.QVBoxLayout(self)
+ layout.setContentsMargins(16, 16, 16, 16)
+ layout.setSpacing(12)
+
+ heading = QtWidgets.QLabel('Application Settings')
+ heading.setStyleSheet('font-size: 20px; font-weight: 700; color: #1d3148;')
+ layout.addWidget(heading)
+
+ note = QtWidgets.QLabel(
+ 'These values are stored in config/config.ini. '
+ 'Visual and shortcut-related changes are applied immediately where possible. '
+ 'Some settings are still best treated as taking effect on the next app start.'
+ )
+ note.setWordWrap(True)
+ note.setStyleSheet('color: #506274;')
+ layout.addWidget(note)
+
+ self.tabs = QtWidgets.QTabWidget()
+ layout.addWidget(self.tabs, 1)
+
+ self.button_box = QtWidgets.QDialogButtonBox(
+ QtWidgets.QDialogButtonBox.Save | QtWidgets.QDialogButtonBox.Cancel
+ )
+ UiExport.install_dialog_export_button(
+ self.button_box,
+ mainwindow=self.mw,
+ widget=self,
+ default_name='settings_dialog.png',
+ dialog_title='Export Settings Dialog As',
+ success_label='Settings dialog',
+ )
+ self.button_box.accepted.connect(self._save_and_accept)
+ self.button_box.rejected.connect(self.reject)
+ layout.addWidget(self.button_box)
+
+ def _populate_tabs(self):
+ for section in self._parser.sections():
+ page = QtWidgets.QWidget()
+ page_layout = QtWidgets.QVBoxLayout(page)
+ page_layout.setContentsMargins(14, 14, 14, 14)
+ page_layout.setSpacing(10)
+
+ form = QtWidgets.QFormLayout()
+ form.setFieldGrowthPolicy(QtWidgets.QFormLayout.ExpandingFieldsGrow)
+ form.setLabelAlignment(QtCore.Qt.AlignLeft | QtCore.Qt.AlignVCenter)
+ form.setHorizontalSpacing(18)
+ form.setVerticalSpacing(10)
+
+ for key in self._parser.options(section):
+ raw_value = self._parser.get(section, key, raw=True)
+ widget = self._make_editor(section, key, raw_value)
+ form.addRow(self._display_label(section, key), widget)
+ self._fields[(section, key)] = widget
+
+ page_layout.addLayout(form)
+ page_layout.addStretch(1)
+ self.tabs.addTab(page, section)
+
+ def _make_editor(self, section, key, raw_value):
+ field_key = (section, key)
+ normalized = raw_value.strip()
+ lower_value = normalized.lower()
+
+ if field_key in CHOICE_FIELDS:
+ combo = QtWidgets.QComboBox()
+ combo.addItems(CHOICE_FIELDS[field_key])
+ index = combo.findText(normalized)
+ combo.setCurrentIndex(max(index, 0))
+ return combo
+
+ if lower_value in BOOLEAN_STRINGS:
+ checkbox = QtWidgets.QCheckBox()
+ checkbox.setChecked(lower_value in {'true', 'yes', 'on', '1'})
+ return checkbox
+
+ line_edit = QtWidgets.QLineEdit(normalized)
+ line_edit.setClearButtonEnabled(True)
+ return line_edit
+
+ def _save_and_accept(self):
+ updated_values = OrderedDict()
+
+ for (section, key), widget in self._fields.items():
+ try:
+ updated_values[(section, key)] = self._read_widget_value(
+ section,
+ key,
+ widget,
+ )
+ except ValueError as error:
+ QtWidgets.QMessageBox.warning(
+ self,
+ 'Invalid setting value',
+ str(error),
+ )
+ return
+
+ for (section, key), value in updated_values.items():
+ self._parser.set(section, key, value)
+
+ try:
+ self._write_parser()
+ except OSError as error:
+ QtWidgets.QMessageBox.critical(
+ self,
+ 'Failed to save settings',
+ str(error),
+ )
+ return
+
+ self.mw.config.reload()
+ self.mw.applyRuntimeSettings(apply_window_mode=True)
+ self.accept()
+
+ def _read_widget_value(self, section, key, widget):
+ original_raw = self._parser.get(section, key, raw=True).strip()
+ original_kind = self._infer_kind(original_raw)
+
+ if isinstance(widget, QtWidgets.QComboBox):
+ value = widget.currentText().strip()
+ if not value:
+ raise ValueError(f'{section}.{key} cannot be empty.')
+ return value
+
+ if isinstance(widget, QtWidgets.QCheckBox):
+ return 'True' if widget.isChecked() else 'False'
+
+ value = widget.text().strip()
+ if not value:
+ raise ValueError(f'{section}.{key} cannot be empty.')
+
+ if section == 'Window' and key.startswith('WINDOW_PRESET_'):
+ try:
+ Settings.parse_window_geometry(value)
+ except ValueError as error:
+ raise ValueError(f'{section}.{key} {error}') from error
+ return ', '.join(part.strip() for part in value.split(','))
+
+ if original_kind == 'int':
+ try:
+ int(value)
+ except ValueError as error:
+ raise ValueError(f'{section}.{key} must be an integer.') from error
+ elif original_kind == 'float':
+ try:
+ float(value)
+ except ValueError as error:
+ raise ValueError(f'{section}.{key} must be a number.') from error
+
+ return value
+
+ def _load_parser(self):
+ parser = configparser.ConfigParser(
+ interpolation=configparser.ExtendedInterpolation()
+ )
+ parser.optionxform = str
+ parser.read(Settings.CONFIG_FILE, encoding='utf-8')
+ return parser
+
+ def _write_parser(self):
+ with Settings.CONFIG_FILE.open('w', encoding='utf-8') as handle:
+ handle.write(CONFIG_HEADER)
+ self._parser.write(handle)
+
+ @staticmethod
+ def _display_label(section, key):
+ section_prefix = f'{section.upper()}_'
+ if key.startswith(section_prefix):
+ key = key[len(section_prefix):]
+ return key.replace('_', ' ').title()
+
+ @staticmethod
+ def _infer_kind(raw_value):
+ value = raw_value.strip()
+ lower_value = value.lower()
+
+ if lower_value in BOOLEAN_STRINGS:
+ return 'bool'
+
+ try:
+ int(value)
+ return 'int'
+ except ValueError:
+ pass
+
+ try:
+ float(value)
+ return 'float'
+ except ValueError:
+ return 'string'
diff --git a/src/Shape.py b/src/Shape.py
new file mode 100644
index 0000000..d6f69b0
--- /dev/null
+++ b/src/Shape.py
@@ -0,0 +1,282 @@
+from __future__ import annotations
+
+import math
+from abc import ABC, abstractmethod
+from typing import Iterable, Sequence
+
+import numpy as np
+
+
+Point2D = tuple[float, float]
+
+
+def _as_point(point: Sequence[float]) -> Point2D:
+ if len(point) != 2:
+ raise ValueError('Expected a 2D point.')
+ return float(point[0]), float(point[1])
+
+
+def _as_points(points: Iterable[Sequence[float]]) -> list[Point2D]:
+ return [_as_point(point) for point in points]
+
+
+def _resample_points(points: list[Point2D], resolution: int,
+ closed: bool) -> list[Point2D]:
+ if resolution <= 0:
+ raise ValueError('Resolution must be positive.')
+ if len(points) <= 1 or resolution <= len(points):
+ return list(points)
+
+ coordinates = np.asarray(points, dtype=float)
+ if closed and not np.allclose(coordinates[0], coordinates[-1]):
+ coordinates = np.vstack([coordinates, coordinates[0]])
+
+ deltas = np.diff(coordinates, axis=0)
+ segment_lengths = np.linalg.norm(deltas, axis=1)
+ cumulative = np.concatenate([[0.0], np.cumsum(segment_lengths)])
+
+ if np.isclose(cumulative[-1], 0.0):
+ return [tuple(coordinates[0])] * resolution
+
+ targets = np.linspace(0.0, cumulative[-1], resolution)
+ x = np.interp(targets, cumulative, coordinates[:, 0])
+ y = np.interp(targets, cumulative, coordinates[:, 1])
+ return list(zip(x.tolist(), y.tolist()))
+
+
+class Shape(ABC):
+ """Base class for geometry primitives used by the domain layer."""
+
+ def __init__(self, name: str | None = None):
+ self.name = name or self.__class__.__name__
+
+ @property
+ @abstractmethod
+ def is_closed(self) -> bool:
+ """Whether the shape is intended to be geometrically closed."""
+
+ @abstractmethod
+ def sample_points(self, resolution: int | None = None) -> list[Point2D]:
+ """Return sampled points for the shape."""
+
+ def to_polygon(self, resolution: int | None = None) -> list[Point2D]:
+ return self.sample_points(resolution=resolution)
+
+ def as_array(self, resolution: int | None = None) -> np.ndarray:
+ return np.asarray(self.to_polygon(resolution=resolution), dtype=float)
+
+ def bounds(self, resolution: int | None = None) -> tuple[float, float, float, float]:
+ coordinates = self.as_array(resolution=resolution)
+ if coordinates.size == 0:
+ raise ValueError('Cannot compute bounds for an empty shape.')
+ xmin = float(np.min(coordinates[:, 0]))
+ xmax = float(np.max(coordinates[:, 0]))
+ ymin = float(np.min(coordinates[:, 1]))
+ ymax = float(np.max(coordinates[:, 1]))
+ return xmin, xmax, ymin, ymax
+
+ def display(self, resolution: int | None = None) -> list[Point2D]:
+ """Return a drawable point representation for GUI adapters."""
+ return self.to_polygon(resolution=resolution)
+
+
+class Line(Shape):
+ def __init__(self, p1=None, p2=None, x1=None, y1=None, x2=None, y2=None,
+ coords=None, name: str | None = None):
+ super().__init__(name=name)
+
+ if coords is not None:
+ if len(coords) != 4:
+ raise ValueError(
+ 'Provide four values as (x1, y1, x2, y2).'
+ )
+ x1, y1, x2, y2 = coords
+ elif p1 is not None and p2 is not None:
+ x1, y1 = p1
+ x2, y2 = p2
+
+ if None in (x1, y1, x2, y2):
+ raise ValueError(
+ 'Provide either two points or four coordinates.'
+ )
+
+ self.start = _as_point((x1, y1))
+ self.end = _as_point((x2, y2))
+
+ @property
+ def is_closed(self) -> bool:
+ return False
+
+ def sample_points(self, resolution: int | None = None) -> list[Point2D]:
+ points = [self.start, self.end]
+ if resolution is None:
+ return points
+ return _resample_points(points, resolution=resolution, closed=False)
+
+
+class Polyline(Shape):
+ def __init__(self, vertices: Iterable[Sequence[float]], closed: bool = False,
+ name: str | None = None):
+ super().__init__(name=name)
+ self.vertices = _as_points(vertices)
+ if len(self.vertices) < 2:
+ raise ValueError('A polyline needs at least two points.')
+ self._is_closed = bool(closed)
+
+ @property
+ def is_closed(self) -> bool:
+ return self._is_closed
+
+ def sample_points(self, resolution: int | None = None) -> list[Point2D]:
+ points = list(self.vertices)
+ if self._is_closed and points[0] != points[-1]:
+ points.append(points[0])
+ if resolution is None:
+ return points
+ return _resample_points(points, resolution=resolution,
+ closed=self._is_closed)
+
+
+class Polygon(Polyline):
+ def __init__(self, vertices: Iterable[Sequence[float]],
+ name: str | None = None):
+ super().__init__(vertices=vertices, closed=True, name=name)
+
+
+class Rectangle(Polygon):
+ def __init__(self, width=None, height=None, center=None, lower_left=None,
+ upper_right=None, name: str | None = None):
+ if lower_left is not None and upper_right is not None:
+ x1, y1 = _as_point(lower_left)
+ x2, y2 = _as_point(upper_right)
+ width = x2 - x1
+ height = y2 - y1
+ center = ((x1 + x2) / 2.0, (y1 + y2) / 2.0)
+ elif width is None or height is None or center is None:
+ raise ValueError(
+ 'Provide either (width, height, center) or '
+ '(lower_left, upper_right).'
+ )
+
+ cx, cy = _as_point(center)
+ half_width = float(width) / 2.0
+ half_height = float(height) / 2.0
+ vertices = [
+ (cx - half_width, cy - half_height),
+ (cx + half_width, cy - half_height),
+ (cx + half_width, cy + half_height),
+ (cx - half_width, cy + half_height),
+ ]
+ super().__init__(vertices=vertices, name=name or 'Rectangle')
+ self.width = float(width)
+ self.height = float(height)
+ self.center = (cx, cy)
+
+
+class Arc(Shape):
+ def __init__(self, center=None, radius=None, start_angle=0.0,
+ end_angle=math.pi, start_point=None, end_point=None,
+ name: str | None = None):
+ super().__init__(name=name)
+
+ if center is None:
+ raise ValueError('A center point is required.')
+ self.center = _as_point(center)
+
+ if radius is not None:
+ self.radius = float(radius)
+ self.start_angle = self._coerce_angle(start_angle)
+ self.end_angle = self._coerce_angle(end_angle)
+ elif start_point is not None and end_point is not None:
+ start = np.asarray(_as_point(start_point), dtype=float)
+ end = np.asarray(_as_point(end_point), dtype=float)
+ center_array = np.asarray(self.center, dtype=float)
+ self.radius = float(np.linalg.norm(end - start) / 2.0)
+ self.start_angle = math.atan2(
+ start[1] - center_array[1], start[0] - center_array[0]
+ )
+ self.end_angle = math.atan2(
+ end[1] - center_array[1], end[0] - center_array[0]
+ )
+ else:
+ raise ValueError(
+ 'Provide either (center, radius, start_angle, end_angle) '
+ 'or (center, start_point, end_point).'
+ )
+
+ @staticmethod
+ def _coerce_angle(angle: float) -> float:
+ if abs(angle) > 2.0 * math.pi:
+ return math.radians(angle)
+ return float(angle)
+
+ @property
+ def is_closed(self) -> bool:
+ return False
+
+ def sample_points(self, resolution: int | None = None) -> list[Point2D]:
+ resolution = resolution or 100
+ if resolution < 2:
+ raise ValueError('An arc needs at least two sample points.')
+ angles = np.linspace(self.start_angle, self.end_angle, resolution)
+ x = self.center[0] + self.radius * np.cos(angles)
+ y = self.center[1] + self.radius * np.sin(angles)
+ return list(zip(x.tolist(), y.tolist()))
+
+
+class Circle(Arc):
+ def __init__(self, center=None, radius=None, diameter=None,
+ name: str | None = None):
+ if center is None:
+ raise ValueError('A center point is required.')
+ if radius is None and diameter is None:
+ raise ValueError('Provide either a radius or a diameter.')
+ if radius is None:
+ radius = float(diameter) / 2.0
+ super().__init__(
+ center=center,
+ radius=radius,
+ start_angle=0.0,
+ end_angle=2.0 * math.pi,
+ name=name or 'Circle',
+ )
+
+ @property
+ def is_closed(self) -> bool:
+ return True
+
+ def sample_points(self, resolution: int | None = None) -> list[Point2D]:
+ points = super().sample_points(resolution=resolution or 180)
+ if points[0] != points[-1]:
+ points.append(points[0])
+ return points
+
+
+class Spline(Shape):
+ """Polyline-based spline placeholder for the rewrite scaffolding."""
+
+ def __init__(self, points: Iterable[Sequence[float]] | tuple[Sequence[float], Sequence[float]],
+ closed: bool = False, name: str | None = None):
+ super().__init__(name=name)
+
+ if isinstance(points, tuple) and len(points) == 2:
+ x_values, y_values = points
+ points = list(zip(x_values, y_values))
+
+ self.points = _as_points(points)
+ if len(self.points) < 2:
+ raise ValueError('A spline needs at least two support points.')
+ self._is_closed = bool(closed)
+
+ @property
+ def is_closed(self) -> bool:
+ return self._is_closed
+
+ def sample_points(self, resolution: int | None = None) -> list[Point2D]:
+ points = list(self.points)
+ if self._is_closed and points[0] != points[-1]:
+ points.append(points[0])
+ if resolution is None:
+ return points
+ return _resample_points(points, resolution=resolution,
+ closed=self._is_closed)
diff --git a/src/ShortCuts.py b/src/ShortCuts.py
deleted file mode 100644
index 7be2219..0000000
--- a/src/ShortCuts.py
+++ /dev/null
@@ -1,39 +0,0 @@
-
-from PySide6 import QtGui, QtWidgets
-
-class ShortCuts:
- """docstring for ClassName """
- def __init__(self, parent):
- if not isinstance(parent, QtWidgets.QMainWindow):
- raise TypeError('parent must be a MainWindow instance')
-
- self.parent = parent
-
- def addShortcut(self, shortcut, slotMethod, *args):
- """Add a shortcut to a slot (event handler)
-
- Args:
- shortcut (STRING): Something like 'ALT+m'
- slotMethod (STRING): Method of GuiSlots 'Slot' class
-
- Returns:
- object: QShortcut object
- """
-
- # gui_slot converts to:
- # self.parent.slots.slotMethod(*args)
- gui_slot = getattr(self.parent.slots, slotMethod)
-
- sc = QtGui.QShortcut(QtGui.QKeySequence(shortcut), self.parent)
-
- # connect shortcut to slot
- # e.g. self.parent.slots.toggleLogDock(*args)
- sc.activated.connect(lambda: gui_slot(*args))
-
- return
-
- def enableShortcut(self, enable=True):
- self.setEnabled(self, enable)
-
- def changeKey(self, key):
- self.setKey(key)
diff --git a/src/ShortcutEditor.py b/src/ShortcutEditor.py
new file mode 100644
index 0000000..eb57863
--- /dev/null
+++ b/src/ShortcutEditor.py
@@ -0,0 +1,385 @@
+from __future__ import annotations
+
+from PySide6 import QtCore, QtGui, QtWidgets
+
+import UiExport
+
+
+class ShortcutEditorDialog(QtWidgets.QDialog):
+ def __init__(self, mainwindow):
+ super().__init__(mainwindow)
+ self.mw = mainwindow
+ self.registry = mainwindow.action_registry
+ self.platform_overrides = dict(self.registry.platform_override_specs())
+ self._entry_map = {}
+ self._action_items = {}
+ self._syncing_details = False
+
+ self._build_ui()
+ self._populate_tree()
+ self._refresh()
+
+ def _build_ui(self):
+ self.setWindowTitle('Keyboard Shortcuts')
+ self.setModal(True)
+ self.resize(980, 700)
+ self.setMinimumSize(860, 620)
+ self.setWindowFlag(QtCore.Qt.WindowContextHelpButtonHint, False)
+
+ layout = QtWidgets.QVBoxLayout(self)
+ layout.setContentsMargins(16, 16, 16, 16)
+ layout.setSpacing(12)
+
+ heading = QtWidgets.QLabel('Keyboard Shortcuts')
+ heading.setStyleSheet('font-size: 20px; font-weight: 700; color: #1d3148;')
+ layout.addWidget(heading)
+
+ note = QtWidgets.QLabel(
+ 'Built-in defaults come from resources/Shortcuts/shortcuts.json. '
+ f'Saving writes {self.registry.platform_label()} overrides to '
+ 'config/shortcuts_user.json.'
+ )
+ note.setWordWrap(True)
+ note.setStyleSheet('color: #506274;')
+ layout.addWidget(note)
+
+ splitter = QtWidgets.QSplitter(QtCore.Qt.Horizontal)
+ splitter.setChildrenCollapsible(False)
+ layout.addWidget(splitter, 1)
+
+ self.tree = QtWidgets.QTreeWidget()
+ self.tree.setColumnCount(3)
+ self.tree.setHeaderLabels(['Command', 'Current', 'Default'])
+ self.tree.setRootIsDecorated(True)
+ self.tree.setAlternatingRowColors(True)
+ self.tree.setSelectionMode(QtWidgets.QAbstractItemView.SingleSelection)
+ self.tree.setUniformRowHeights(True)
+ self.tree.currentItemChanged.connect(self._on_current_item_changed)
+ header = self.tree.header()
+ header.setStretchLastSection(False)
+ header.setSectionResizeMode(0, QtWidgets.QHeaderView.Stretch)
+ header.setSectionResizeMode(1, QtWidgets.QHeaderView.ResizeToContents)
+ header.setSectionResizeMode(2, QtWidgets.QHeaderView.ResizeToContents)
+ splitter.addWidget(self.tree)
+
+ details = QtWidgets.QFrame()
+ details.setStyleSheet(
+ 'QFrame { background: #f6f9fc; border: 1px solid #d7e3ef; border-radius: 12px; }'
+ )
+ details_layout = QtWidgets.QVBoxLayout(details)
+ details_layout.setContentsMargins(14, 14, 14, 14)
+ details_layout.setSpacing(10)
+
+ self.title_label = QtWidgets.QLabel('Select a command')
+ self.title_label.setStyleSheet('font-size: 18px; font-weight: 700; color: #1d3148;')
+ details_layout.addWidget(self.title_label)
+
+ self.description_label = QtWidgets.QLabel('')
+ self.description_label.setWordWrap(True)
+ self.description_label.setStyleSheet('color: #516274;')
+ details_layout.addWidget(self.description_label)
+
+ meta_grid = QtWidgets.QGridLayout()
+ meta_grid.setHorizontalSpacing(12)
+ meta_grid.setVerticalSpacing(6)
+ self.scope_value = QtWidgets.QLabel('')
+ self.default_value = QtWidgets.QLabel('')
+ self.current_value = QtWidgets.QLabel('')
+ for label in (self.scope_value, self.default_value, self.current_value):
+ label.setWordWrap(True)
+ label.setStyleSheet('color: #21374f; font-weight: 600;')
+ meta_grid.addWidget(QtWidgets.QLabel('Scope'), 0, 0)
+ meta_grid.addWidget(self.scope_value, 0, 1)
+ meta_grid.addWidget(QtWidgets.QLabel('Built-in default'), 1, 0)
+ meta_grid.addWidget(self.default_value, 1, 1)
+ meta_grid.addWidget(QtWidgets.QLabel('Current shortcut'), 2, 0)
+ meta_grid.addWidget(self.current_value, 2, 1)
+ meta_grid.setColumnStretch(1, 1)
+ details_layout.addLayout(meta_grid)
+
+ self.use_default_radio = QtWidgets.QRadioButton('Use built-in shortcut(s)')
+ self.use_custom_radio = QtWidgets.QRadioButton(
+ f'Use one custom shortcut for {self.registry.platform_label()}'
+ )
+ self.use_default_radio.toggled.connect(self._on_mode_changed)
+ self.use_custom_radio.toggled.connect(self._on_mode_changed)
+ details_layout.addWidget(self.use_default_radio)
+ details_layout.addWidget(self.use_custom_radio)
+
+ self.record_edit = QtWidgets.QKeySequenceEdit()
+ self.record_edit.keySequenceChanged.connect(self._on_recording_changed)
+ details_layout.addWidget(self.record_edit)
+
+ helper = QtWidgets.QLabel(
+ 'Selecting a custom shortcut replaces the current platform override for this command. '
+ 'Switch back to built-in to remove the override.'
+ )
+ helper.setWordWrap(True)
+ helper.setStyleSheet('color: #66788c;')
+ details_layout.addWidget(helper)
+ details_layout.addStretch(1)
+
+ self.message_label = QtWidgets.QLabel('')
+ self.message_label.setWordWrap(True)
+ self.message_label.setStyleSheet('color: #506274;')
+ details_layout.addWidget(self.message_label)
+
+ splitter.addWidget(details)
+ splitter.setStretchFactor(0, 6)
+ splitter.setStretchFactor(1, 4)
+
+ self.button_box = QtWidgets.QDialogButtonBox(
+ QtWidgets.QDialogButtonBox.Save | QtWidgets.QDialogButtonBox.Cancel
+ )
+ UiExport.install_dialog_export_button(
+ self.button_box,
+ mainwindow=self.mw,
+ widget=self,
+ default_name='keyboard_shortcuts_dialog.png',
+ dialog_title='Export Keyboard Shortcuts Dialog As',
+ success_label='Keyboard shortcuts dialog',
+ )
+ self.button_box.accepted.connect(self._save_and_accept)
+ self.button_box.rejected.connect(self.reject)
+ layout.addWidget(self.button_box)
+
+ def _populate_tree(self):
+ category_items = {}
+ for entry in self.registry.shortcut_editor_entries():
+ category_item = category_items.get(entry.category)
+ if category_item is None:
+ category_item = QtWidgets.QTreeWidgetItem([entry.category])
+ category_item.setFirstColumnSpanned(True)
+ category_item.setFlags(category_item.flags() & ~QtCore.Qt.ItemIsSelectable)
+ category_item.setExpanded(True)
+ self.tree.addTopLevelItem(category_item)
+ category_items[entry.category] = category_item
+
+ item = QtWidgets.QTreeWidgetItem(category_item)
+ item.setData(0, QtCore.Qt.UserRole, entry.action_id)
+ self._action_items[entry.action_id] = item
+
+ self.tree.expandAll()
+
+ def _refresh(self):
+ self._entry_map = {
+ entry.action_id: entry
+ for entry in self.registry.shortcut_editor_entries(self.platform_overrides)
+ }
+ current_action_id = self.current_action_id()
+
+ for action_id, item in self._action_items.items():
+ entry = self._entry_map[action_id]
+ item.setText(0, entry.text)
+ item.setText(1, ' / '.join(entry.current_shortcuts) or 'None')
+ item.setText(2, ' / '.join(entry.default_shortcuts) or 'None')
+
+ if entry.editable:
+ brush = QtGui.QBrush()
+ else:
+ brush = QtGui.QBrush(QtGui.QColor('#8998a8'))
+ for column in range(3):
+ item.setForeground(column, brush)
+
+ self._restore_selection(current_action_id)
+ self._refresh_details()
+
+ def _refresh_entry_preview(self, action_id):
+ self._entry_map = {
+ entry.action_id: entry
+ for entry in self.registry.shortcut_editor_entries(self.platform_overrides)
+ }
+
+ entry = self._entry_map[action_id]
+ item = self._action_items[action_id]
+ item.setText(0, entry.text)
+ item.setText(1, ' / '.join(entry.current_shortcuts) or 'None')
+ item.setText(2, ' / '.join(entry.default_shortcuts) or 'None')
+ self.current_value.setText(' / '.join(entry.current_shortcuts) or 'None')
+ self.default_value.setText(' / '.join(entry.default_shortcuts) or 'None')
+
+ def _restore_selection(self, action_id):
+ if action_id and action_id in self._action_items:
+ self.tree.setCurrentItem(self._action_items[action_id])
+ return
+
+ for item in self._action_items.values():
+ self.tree.setCurrentItem(item)
+ return
+
+ self.tree.setCurrentItem(None)
+
+ def _on_current_item_changed(self, _current, _previous):
+ self._refresh_details()
+
+ def _refresh_details(self):
+ action_id = self.current_action_id()
+ self._syncing_details = True
+ try:
+ if action_id is None:
+ self.title_label.setText('Select a command')
+ self.description_label.setText('')
+ self.scope_value.setText('')
+ self.default_value.setText('')
+ self.current_value.setText('')
+ self.use_default_radio.setChecked(False)
+ self.use_custom_radio.setChecked(False)
+ self.use_default_radio.setEnabled(False)
+ self.use_custom_radio.setEnabled(False)
+ self.record_edit.setEnabled(False)
+ self.record_edit.setKeySequence(QtGui.QKeySequence())
+ self.message_label.setText('')
+ return
+
+ entry = self._entry_map[action_id]
+ self.title_label.setText(entry.text)
+ self.description_label.setText(entry.description)
+ self.scope_value.setText(entry.scope)
+ self.default_value.setText(' / '.join(entry.default_shortcuts) or 'None')
+ self.current_value.setText(' / '.join(entry.current_shortcuts) or 'None')
+
+ editable = entry.editable
+ self.use_default_radio.setEnabled(editable)
+ self.use_custom_radio.setEnabled(editable)
+
+ override_specs = self.platform_overrides.get(action_id)
+ if editable and override_specs:
+ self.use_custom_radio.setChecked(True)
+ self.record_edit.setEnabled(True)
+ self.record_edit.setKeySequence(
+ self._sequence_from_override_specs(override_specs)
+ )
+ self.message_label.setText(
+ f'This command has a {self.registry.platform_label()} override.'
+ )
+ elif editable:
+ self.use_default_radio.setChecked(True)
+ self.record_edit.setEnabled(False)
+ self.record_edit.setKeySequence(QtGui.QKeySequence())
+ self.message_label.setText(
+ f'This command currently uses the built-in shortcut for {self.registry.platform_label()}.'
+ )
+ else:
+ self.use_default_radio.setChecked(False)
+ self.use_custom_radio.setChecked(False)
+ self.record_edit.setEnabled(False)
+ self.record_edit.setKeySequence(QtGui.QKeySequence())
+ self.message_label.setText(
+ 'This shortcut is managed by the application and cannot be edited here.'
+ )
+ finally:
+ self._syncing_details = False
+
+ def current_action_id(self):
+ item = self.tree.currentItem()
+ if item is None:
+ return None
+ return item.data(0, QtCore.Qt.UserRole)
+
+ def _on_mode_changed(self):
+ if self._syncing_details:
+ return
+
+ action_id = self.current_action_id()
+ if action_id is None:
+ return
+
+ entry = self._entry_map.get(action_id)
+ if entry is None or not entry.editable:
+ return
+
+ if self.use_default_radio.isChecked():
+ self.platform_overrides.pop(action_id, None)
+ self.record_edit.setEnabled(False)
+ self.record_edit.setKeySequence(QtGui.QKeySequence())
+ self._refresh_entry_preview(action_id)
+ self.message_label.setText(
+ f'This command currently uses the built-in shortcut for {self.registry.platform_label()}.'
+ )
+ return
+
+ if self.use_custom_radio.isChecked():
+ override_specs = self.platform_overrides.get(action_id)
+ self.record_edit.setEnabled(True)
+ self.record_edit.setKeySequence(
+ self._sequence_from_override_specs(override_specs)
+ )
+ self.record_edit.setFocus(QtCore.Qt.TabFocusReason)
+ self.message_label.setText('Press the new shortcut now.')
+
+ def _on_recording_changed(self, _sequence):
+ if self._syncing_details:
+ return
+
+ action_id = self.current_action_id()
+ if action_id is None or not self.use_custom_radio.isChecked():
+ return
+
+ self._store_recorded_shortcut(action_id)
+ self._refresh_entry_preview(action_id)
+ self.message_label.setText(
+ f'Pending shortcut: {self.current_value.text()}'
+ )
+
+ def _store_recorded_shortcut(self, action_id):
+ sequence = self.record_edit.keySequence()
+ if sequence.isEmpty():
+ self.platform_overrides.pop(action_id, None)
+ return
+
+ portable_text = sequence.toString(QtGui.QKeySequence.PortableText)
+ if not portable_text:
+ self.platform_overrides.pop(action_id, None)
+ return
+
+ self.platform_overrides[action_id] = (portable_text,)
+
+ def _save_and_accept(self):
+ conflicts = self.registry.detect_shortcut_conflicts(self.platform_overrides)
+ if conflicts:
+ lines = []
+ for portable_text, action_ids in sorted(conflicts.items()):
+ sequence = QtGui.QKeySequence.fromString(
+ portable_text,
+ QtGui.QKeySequence.PortableText,
+ )
+ label = sequence.toString(QtGui.QKeySequence.NativeText) or portable_text
+ commands = ', '.join(
+ self.registry.definition(action_id).text
+ for action_id in action_ids
+ )
+ lines.append(f'{label}: {commands}')
+
+ QtWidgets.QMessageBox.warning(
+ self,
+ 'Shortcut conflict',
+ 'Please resolve conflicting shortcuts before saving.\n\n'
+ + '\n'.join(lines),
+ )
+ return
+
+ try:
+ self.registry.save_platform_overrides(self.platform_overrides)
+ except OSError as error:
+ QtWidgets.QMessageBox.critical(
+ self,
+ 'Failed to save shortcuts',
+ str(error),
+ )
+ return
+
+ self.accept()
+
+ @staticmethod
+ def _sequence_from_override_specs(specs):
+ if not specs:
+ return QtGui.QKeySequence()
+
+ first = specs[0]
+ if isinstance(first, dict):
+ return QtGui.QKeySequence()
+
+ return QtGui.QKeySequence.fromString(
+ str(first),
+ QtGui.QKeySequence.PortableText,
+ )
diff --git a/src/Smooth_angle_based.py b/src/Smooth_angle_based.py
index de0dd61..55d41ea 100644
--- a/src/Smooth_angle_based.py
+++ b/src/Smooth_angle_based.py
@@ -1,304 +1,358 @@
-
-import copy
-from distutils.debug import DEBUG
-from types import prepare_class
+from __future__ import annotations
import numpy as np
-from PySide6 import QtGui, QtCore
-
-import GraphicsItemsCollection as gic
-import GraphicsItem
-import Connect
import logging
logger = logging.getLogger(__name__)
-class SmoothAngleBased:
- """Mesh smoothing based on the paper:
- An Angular Method with Position Control for Block Mesh Squareness Improvement
- by Jin Yao, Douglas Stillman
-
- There are several errors in the paper.
- Nevertheless, the idea and algorithm description are formulated very clear.
+class SmoothAngleBased:
+ """Angle-based mesh smoothing for block-mesh vertex/connectivity data."""
- The errors are in the summation over all 12 angles, which in fact needs
- to be split into four for the alphas and eight for the betas.
- Therefore also the depicted derivatives are wrong as well as
- the inverse Hessian for the Newton optimization iterations.
+ EPSILON = 1.0e-9
+ DEFAULT_LOG_INTERVAL = 10
- This class contains the corrected equations.
- """
+ def __init__(self, data, connectivity=None, data_source=None):
+ self.block = None
- def __init__(self, data, data_source='block'):
-
- # get MainWindow instance (overcomes handling parents)
- self.mainwindow = QtCore.QCoreApplication.instance().mainwindow
+ if connectivity is not None:
+ vertices = data
+ elif data_source == 'block':
+ import Connect
- # data_source is one of 'block' or 'mesh'
- if data_source == 'block':
self.block = data
- connect = Connect.Connect(None)
- vertices = connect.getVertices(self.block)
- connectivity = connect.getConnectivity(self.block)
- self.mesh = vertices, connectivity
- if data_source == 'mesh':
- self.mesh = data
-
- lvc = self.makeLVC()
- self.stencils = self.make_stencil(lvc)
-
- self.drawlines = None
+ connector = Connect.Connect()
+ vertices = connector.getVertices(self.block)
+ connectivity = connector.getConnectivity(self.block)
+ elif data_source == 'mesh':
+ vertices, connectivity = data
+ else:
+ raise ValueError(
+ 'SmoothAngleBased expects vertices/connectivity data or '
+ 'data_source="block"/"mesh".'
+ )
+
+ self.vertices = np.asarray(vertices, dtype=float)
+ self.connectivity = np.asarray(connectivity, dtype=int)
+
+ if self.vertices.ndim != 2 or self.vertices.shape[1] != 2:
+ raise ValueError('SmoothAngleBased expects vertices as an N x 2 array.')
+ if self.connectivity.ndim != 2:
+ raise ValueError('SmoothAngleBased expects connectivity as a 2D array.')
+
+ self.lvc = self.makeLVC()
+ self.stencils = self.make_stencil(self.lvc)
+ self._compile_stencils(self.stencils)
+
+ @staticmethod
+ def _should_log_iteration(iteration, iterations, log_interval):
+ return (
+ iteration == 1 or
+ iteration == iterations or
+ iteration % log_interval == 0
+ )
+
+ @staticmethod
+ def _as_vertex_list(vertices):
+ return [
+ (float(vertex[0]), float(vertex[1]))
+ for vertex in np.asarray(vertices, dtype=float)
+ ]
def makeLVC(self):
- _, connectivity = self.mesh
- nodes = set([node for cell in connectivity for node in cell])
+ lvc = {}
+ for cell in self.connectivity:
+ for node in cell:
+ lvc.setdefault(int(node), []).append(cell)
+
+ self.lvc = {
+ node: np.asarray(cells, dtype=int)
+ for node, cells in lvc.items()
+ }
+ return self.lvc
- self.lvc = dict()
- conn = np.array(connectivity)
+ def make_stencil(self, lvc, verbose=False):
+ stencils = {}
- for node in nodes:
- cells, _ = np.where(conn == node)
- self.lvc.setdefault(node, []).append([conn[cell] for cell in cells])
+ for idx in range(len(self.vertices)):
+ if idx not in lvc:
+ continue
- return self.lvc
+ local_cells = np.asarray(lvc[idx], dtype=int)
+ vertices, counts = np.unique(local_cells, return_counts=True)
+ vertices_star = vertices[counts == 2]
- def make_stencil(self, lvc, verbose=False):
- # lvc is a dictionary
- self.stencils = dict()
- for idx in range(len(lvc)):
- v, c = np.unique(lvc[idx], return_counts=True)
- vertices_quad = v[np.argwhere(c==1)]
- vertices_star = v[np.argwhere(c==2)]
-
- cells_with_common_edges = list()
if len(vertices_star) != 4:
continue
-
+
+ cells_with_common_edges = []
for vertex in vertices_star:
- mask = np.isin(lvc[idx], [vertex[0], idx])
- mask1 = np.count_nonzero(mask, axis=1) == 2
- cells_with_common_edges.append(np.array(lvc[idx])[mask1])
+ mask = np.isin(local_cells, [vertex, idx])
+ mask_edges = np.count_nonzero(mask, axis=1) == 2
+ cells_with_common_edges.append(local_cells[mask_edges])
if idx == 6 and verbose:
-
for cells in cells_with_common_edges:
- mask2 = np.isin(cells, np.append(vertices_star.flatten(), idx))
-
- corresponding_corners = list()
+ np.isin(cells, np.append(vertices_star.flatten(), idx))
+
+ corresponding_corners = []
+ stencil_vertices = np.append(vertices_star.flatten(), idx)
for cells in cells_with_common_edges:
- mask2 = np.isin(cells, np.append(vertices_star.flatten(), idx))
- corresponding_corners.append(cells[~mask2])
-
- self.stencils[idx] = corresponding_corners
-
+ mask_corners = np.isin(cells, stencil_vertices)
+ corresponding_corners.append(cells[~mask_corners])
+
+ stencils[idx] = corresponding_corners
+
+ self.stencils = stencils
return self.stencils
+ def _compile_stencils(self, stencils):
+ center_indices = []
+ d_indices = []
+ e_indices = []
+ f_indices = []
+ g_indices = []
+
+ for center in sorted(stencils):
+ stencil = stencils[center]
+ center_indices.append(int(center))
+ d_indices.append(int(stencil[2][0]))
+ e_indices.append(int(stencil[0][0]))
+ f_indices.append(int(stencil[1][1]))
+ g_indices.append(int(stencil[0][1]))
+
+ self.center_indices = np.asarray(center_indices, dtype=int)
+ self.d_indices = np.asarray(d_indices, dtype=int)
+ self.e_indices = np.asarray(e_indices, dtype=int)
+ self.f_indices = np.asarray(f_indices, dtype=int)
+ self.g_indices = np.asarray(g_indices, dtype=int)
+
+ def _compute_cardinals(self, vertices):
+ d_vertex = vertices[self.d_indices]
+ e_vertex = vertices[self.e_indices]
+ f_vertex = vertices[self.f_indices]
+ g_vertex = vertices[self.g_indices]
+
+ south = 0.5 * (d_vertex + e_vertex)
+ west = 0.5 * (d_vertex + g_vertex)
+ east = 0.5 * (e_vertex + f_vertex)
+ north = 0.5 * (g_vertex + f_vertex)
+
+ return south, west, east, north, d_vertex, e_vertex, f_vertex, g_vertex
+
def make_cardinals(self, vertices):
+ vertices = np.asarray(vertices, dtype=float)
+ cardinals = {}
+
+ if self.center_indices.size == 0:
+ return cardinals
+
+ south, west, east, north, d_vertex, e_vertex, f_vertex, g_vertex = (
+ self._compute_cardinals(vertices)
+ )
+
+ for index, center in enumerate(self.center_indices):
+ cardinals[int(center)] = (
+ (float(south[index, 0]), float(south[index, 1])),
+ (float(west[index, 0]), float(west[index, 1])),
+ (float(east[index, 0]), float(east[index, 1])),
+ (float(north[index, 0]), float(north[index, 1])),
+ [float(d_vertex[index, 0]), float(d_vertex[index, 1])],
+ [float(e_vertex[index, 0]), float(e_vertex[index, 1])],
+ [float(f_vertex[index, 0]), float(f_vertex[index, 1])],
+ [float(g_vertex[index, 0]), float(g_vertex[index, 1])],
+ )
- cardinals = dict()
-
- for stencil in self.stencils:
- s = self.stencils[stencil]
-
- D = [vertices[s[2][0]][0], vertices[s[2][0]][1]]
- EE = [vertices[s[0][0]][0], vertices[s[0][0]][1]]
- F = [vertices[s[1][1]][0], vertices[s[1][1]][1]]
- G = [vertices[s[0][1]][0], vertices[s[0][1]][1]]
-
- S = (0.5 * (D[0] + EE[0]), 0.5 * (D[1] + EE[1]))
- W = (0.5 * (D[0] + G[0]), 0.5 * (D[1] + G[1]))
- E = (0.5 * (EE[0] + F[0]), 0.5 * (EE[1] + F[1]))
- N = (0.5 * (G[0] + F[0]), 0.5 * (G[1] + F[1]))
- cardinals[stencil] = (S, W, E, N, D, EE, F, G)
return cardinals
- def draw_cardinal(self, S, W, E, N, D, EE, F, G):
-
- self.drawlines = list()
-
- gc = gic.GraphicsCollection()
-
- points = [QtCore.QPointF(x, y) for x, y in [S, N]]
- gc.Polyline(QtGui.QPolygonF(points), '')
- gc.pen.setColor(QtGui.QColor(255, 0, 0, 255))
- gc.pen.setWidthF(3.0)
- gc.pen.setCosmetic(True)
- gc.brush.setStyle(QtCore.Qt.NoBrush)
- meshline = GraphicsItem.GraphicsItem(gc)
- self.drawlines.append(meshline)
-
- gc = gic.GraphicsCollection()
- points = [QtCore.QPointF(x, y) for x, y in [E, W]]
- gc.Polyline(QtGui.QPolygonF(points), '')
- gc.pen.setColor(QtGui.QColor(0, 255, 0, 255))
- gc.pen.setWidthF(3.0)
- gc.pen.setCosmetic(True)
- gc.brush.setStyle(QtCore.Qt.NoBrush)
- meshline = GraphicsItem.GraphicsItem(gc)
- self.drawlines.append(meshline)
-
- gc = gic.GraphicsCollection()
- points = [QtCore.QPointF(x, y) for x, y in [D, EE, F, G, D]]
- gc.Polyline(QtGui.QPolygonF(points), '')
- gc.pen.setColor(QtGui.QColor(0, 0, 255, 255))
- gc.pen.setWidthF(5.0)
- gc.pen.setCosmetic(True)
- gc.brush.setStyle(QtCore.Qt.NoBrush)
- meshline = GraphicsItem.GraphicsItem(gc)
- self.drawlines.append(meshline)
-
- '''
- gc = gic.GraphicsCollection()
- points = [QtCore.QPointF(x, y) for x, y in [EE, G]]
- gc.Polyline(QtGui.QPolygonF(points), '')
- gc.pen.setColor(QtGui.QColor(0, 255, 255, 255))
- gc.pen.setWidthF(9.0)
- gc.pen.setCosmetic(True)
- gc.brush.setStyle(QtCore.Qt.NoBrush)
- meshline = GraphicsItem.GraphicsItem(gc)
- self.drawlines.append(meshline)
-
- gc = gic.GraphicsCollection()
- points = [QtCore.QPointF(x, y) for x, y in [D, G]]
- gc.Polyline(QtGui.QPolygonF(points), '')
- gc.pen.setColor(QtGui.QColor(255, 0, 255, 255))
- gc.pen.setWidthF(9.0)
- gc.pen.setCosmetic(True)
- gc.brush.setStyle(QtCore.Qt.NoBrush)
- meshline = GraphicsItem.GraphicsItem(gc)
- self.drawlines.append(meshline)
-
- gc = gic.GraphicsCollection()
- points = [QtCore.QPointF(x, y) for x, y in [D, F]]
- gc.Polyline(QtGui.QPolygonF(points), '')
- gc.pen.setColor(QtGui.QColor(0, 0, 0, 255))
- gc.pen.setWidthF(9.0)
- gc.pen.setCosmetic(True)
- gc.brush.setStyle(QtCore.Qt.NoBrush)
- meshline = GraphicsItem.GraphicsItem(gc)
- self.drawlines.append(meshline)
- '''
-
- # self.mainwindow.scene.createItemGroup(self.drawlines)
-
- def smooth(self, iterations=20, tolerance=1.e-4, verbose=False):
-
- # iterations=1
-
- vertices, _ = self.mesh
-
- cardinals = self.make_cardinals(vertices)
-
- smoothed_vertices = copy.deepcopy(vertices)
- smoothed_vertices_old = copy.deepcopy(vertices)
-
- corner = False
- omega = 1
- if corner:
- omega = 0
-
- # loop until convergence
- iteration = 0
- while iteration < iterations:
- iteration += 1
-
- # loop over all stencils (for vertices to be smoothed)
- for ic, cardinal in enumerate(cardinals):
-
- (x, y) = smoothed_vertices[cardinal]
- (xold, yold) = smoothed_vertices_old[cardinal]
-
- S, W, E, N, D, EE, F, G = cardinals[cardinal]
-
- DEBUG = False
- if DEBUG and ic == 143:
- self.draw_cardinal(S, W, E, N, D, EE, F, G)
- # print('ic, cardinal', ic, cardinal)
- # print('Stencil', self.stencils[cardinal])
-
- # calculate position control
- NS = np.linalg.norm( (S[0] - N[0], S[1] - N[1]) )
- WE = np.linalg.norm( (E[0] - W[0], E[1] - W[1]) )
- sigma = np.max((NS/WE, WE/NS))
-
- # angles alpha
- a1 = np.array([S[0], E[0], N[0], W[0]])
- a2 = np.array([E[0], N[0], W[0], S[0]])
- b1 = np.array([S[1], E[1], N[1], W[1]])
- b2 = np.array([E[1], N[1], W[1], S[1]])
-
- # angles beta
- c1 = np.array([S[0], S[0], E[0], E[0], N[0], N[0], W[0], W[0]])
- c2 = np.array([D[0], EE[0], EE[0], F[0], F[0], G[0], G[0], D[0]])
- d1 = np.array([S[1], S[1], E[1], E[1], N[1], N[1], W[1], W[1]])
- d2 = np.array([D[1], EE[1], EE[1], F[1], F[1], G[1], G[1], D[1]])
-
- # derivatives of alpha contributions (including position control)
- ca = np.sum(omega / ( (a1**2 + b1**2 - 2*a1*xold + xold**2 - 2*b1*yold + yold**2) * \
- (a2**2 + b2**2 - 2*a2*xold + xold**2 - 2*b2*yold + yold**2) + 1.e-9))
- dTdx_alpha = np.sum(-(a1*a2 + b1*b2 - a1*x - a2*x + x**2 - b1*y - b2*y + y**2) * \
- (a1 + a2 - 2.*x) - (a1 + a1 + a1 + a1 - 4.*x) * sigma)
- dTdy_alpha = np.sum(-(a1*a2 + b1*b2 - a1*x - a2*x + x**2 - b1*y - b2*y + y**2) * \
- (b1 + b2 - 2*y) - (b1 + b1 + b1 + b1 - 4*y)*sigma)
- d2Tdx2_alpha = np.sum((a1 + a2 - 2*x)**2 + 2*a1*a2 + 2*b1*b2 - 2*a1*x - 2*a2*x + 2*x**2 - \
- 2*b1*y - 2*b2*y + 2*y**2 + 4*sigma)
- d2Tdy2_alpha = np.sum(2*a1*a2 + (b1 + b2 - 2*y)**2 + 2*b1*b2 - 2*a1*x - 2*a2*x + 2*x**2 - \
- 2*b1*y - 2*b2*y + 2*y**2 + 4*sigma)
- d2Tdxdy_alpha = np.sum((a1 + a2 - 2*x)*(b1 + b2 - 2*y))
-
- # derivatives of beta contributions (including position control)
- cb = np.sum(omega / ((c1**2 - 2*c1*c2 + c2**2 + d1**2 - 2*d1*d2 + d2**2) * \
- (c1**2 + d1**2 - 2*c1*xold + xold**2 - 2*d1*yold + yold**2) + 1.e-9))
- dTdx_beta = np.sum(-(c1**2 - c1*c2 + d1**2 - d1*d2 - c1*x + c2*x - d1*y + d2*y) * (c1 - c2) -\
- (a1[0] + a1[1] + a1[2] + a1[3] - 4.*x)*sigma)
- dTdy_beta = np.sum(-(c1**2 - c1*c2 + d1**2 - d1*d2 - c1*x + c2*x - d1*y + d2*y) * (d1 - d2) -\
- (b1[0] + b1[1] + b1[2] + b1[3] - 4*y)*sigma)
- d2Tdx2_beta = np.sum((c1 - c2)**2 + 4*sigma)
- d2Tdy2_beta = np.sum((d1 - d2)**2 + 4*sigma)
- d2Tdxdy_beta = np.sum((c1 - c2)*(d1 - d2))
-
- # compile derivatives of all contributions
- dTdx = ca * dTdx_alpha + cb * dTdx_beta
- dTdy = ca * dTdy_alpha + cb * dTdy_beta
- d2Tdx2 = ca * d2Tdx2_alpha + cb * d2Tdx2_beta
- d2Tdy2 = ca * d2Tdy2_alpha + cb * d2Tdy2_beta
- d2Tdxdy = ca * d2Tdxdy_alpha + cb * d2Tdxdy_beta
-
- # Newton iteration for optimization
- xnew = x - [d2Tdy2 * dTdx - d2Tdxdy * dTdy] / (d2Tdx2 * d2Tdy2 - (d2Tdxdy)**2)
- ynew = y - [d2Tdx2 * dTdy - d2Tdxdy * dTdx] / (d2Tdx2 * d2Tdy2 - (d2Tdxdy)**2)
-
- smoothed_vertices[cardinal] = (xnew[0], ynew[0])
- smoothed_vertices_old[cardinal] = (x, y)
-
- tol = np.linalg.norm((xnew[0] - x, ynew[0] - y))
-
- if verbose:
- logger.info(f'Iteration={iteration:3d}, residual={tol:.3e}')
-
- if tol < tolerance:
+ def smooth(self, iterations=20, tolerance=1.0e-4, verbose=False,
+ log_interval=None):
+ iterations = int(iterations)
+ log_interval = (
+ self.DEFAULT_LOG_INTERVAL if log_interval is None
+ else max(1, int(log_interval))
+ )
+
+ if iterations <= 0 or self.center_indices.size == 0:
+ return self._as_vertex_list(self.vertices)
+
+ current_vertices = np.array(self.vertices, copy=True, dtype=float)
+ previous_vertices = np.array(self.vertices, copy=True, dtype=float)
+ omega = 1.0
+
+ for iteration in range(1, iterations + 1):
+ south, west, east, north, d_vertex, e_vertex, f_vertex, g_vertex = (
+ self._compute_cardinals(current_vertices)
+ )
+
+ centers = current_vertices[self.center_indices]
+ centers_old = previous_vertices[self.center_indices]
+ x = centers[:, 0]
+ y = centers[:, 1]
+ xold = centers_old[:, 0]
+ yold = centers_old[:, 1]
+
+ ns = np.linalg.norm(south - north, axis=1)
+ we = np.linalg.norm(east - west, axis=1)
+ ns_safe = np.maximum(ns, self.EPSILON)
+ we_safe = np.maximum(we, self.EPSILON)
+ sigma = np.maximum(ns_safe / we_safe, we_safe / ns_safe)
+
+ a1 = np.column_stack((south[:, 0], east[:, 0], north[:, 0], west[:, 0]))
+ a2 = np.column_stack((east[:, 0], north[:, 0], west[:, 0], south[:, 0]))
+ b1 = np.column_stack((south[:, 1], east[:, 1], north[:, 1], west[:, 1]))
+ b2 = np.column_stack((east[:, 1], north[:, 1], west[:, 1], south[:, 1]))
+
+ c1 = np.column_stack((
+ south[:, 0], south[:, 0], east[:, 0], east[:, 0],
+ north[:, 0], north[:, 0], west[:, 0], west[:, 0],
+ ))
+ c2 = np.column_stack((
+ d_vertex[:, 0], e_vertex[:, 0], e_vertex[:, 0], f_vertex[:, 0],
+ f_vertex[:, 0], g_vertex[:, 0], g_vertex[:, 0], d_vertex[:, 0],
+ ))
+ d1 = np.column_stack((
+ south[:, 1], south[:, 1], east[:, 1], east[:, 1],
+ north[:, 1], north[:, 1], west[:, 1], west[:, 1],
+ ))
+ d2 = np.column_stack((
+ d_vertex[:, 1], e_vertex[:, 1], e_vertex[:, 1], f_vertex[:, 1],
+ f_vertex[:, 1], g_vertex[:, 1], g_vertex[:, 1], d_vertex[:, 1],
+ ))
+
+ x4 = x[:, None]
+ y4 = y[:, None]
+ xold4 = xold[:, None]
+ yold4 = yold[:, None]
+ sigma4 = sigma[:, None]
+
+ alpha_denominator_1 = (
+ a1**2 + b1**2 - 2 * a1 * xold4 + xold4**2 -
+ 2 * b1 * yold4 + yold4**2
+ )
+ alpha_denominator_2 = (
+ a2**2 + b2**2 - 2 * a2 * xold4 + xold4**2 -
+ 2 * b2 * yold4 + yold4**2
+ )
+ ca = np.sum(
+ omega / (alpha_denominator_1 * alpha_denominator_2 + self.EPSILON),
+ axis=1,
+ )
+
+ alpha_energy = (
+ a1 * a2 + b1 * b2 - a1 * x4 - a2 * x4 + x4**2 -
+ b1 * y4 - b2 * y4 + y4**2
+ )
+ dTdx_alpha = np.sum(
+ -alpha_energy * (a1 + a2 - 2.0 * x4) -
+ (4.0 * a1 - 4.0 * x4) * sigma4,
+ axis=1,
+ )
+ dTdy_alpha = np.sum(
+ -alpha_energy * (b1 + b2 - 2.0 * y4) -
+ (4.0 * b1 - 4.0 * y4) * sigma4,
+ axis=1,
+ )
+ d2Tdx2_alpha = np.sum(
+ (a1 + a2 - 2.0 * x4) ** 2 + 2.0 * a1 * a2 + 2.0 * b1 * b2 -
+ 2.0 * a1 * x4 - 2.0 * a2 * x4 + 2.0 * x4**2 -
+ 2.0 * b1 * y4 - 2.0 * b2 * y4 + 2.0 * y4**2 + 4.0 * sigma4,
+ axis=1,
+ )
+ d2Tdy2_alpha = np.sum(
+ 2.0 * a1 * a2 + (b1 + b2 - 2.0 * y4) ** 2 + 2.0 * b1 * b2 -
+ 2.0 * a1 * x4 - 2.0 * a2 * x4 + 2.0 * x4**2 -
+ 2.0 * b1 * y4 - 2.0 * b2 * y4 + 2.0 * y4**2 + 4.0 * sigma4,
+ axis=1,
+ )
+ d2Tdxdy_alpha = np.sum((a1 + a2 - 2.0 * x4) * (b1 + b2 - 2.0 * y4), axis=1)
+
+ x8 = x[:, None]
+ y8 = y[:, None]
+ xold8 = xold[:, None]
+ yold8 = yold[:, None]
+
+ beta_denominator_1 = (
+ c1**2 - 2.0 * c1 * c2 + c2**2 + d1**2 - 2.0 * d1 * d2 + d2**2
+ )
+ beta_denominator_2 = (
+ c1**2 + d1**2 - 2.0 * c1 * xold8 + xold8**2 -
+ 2.0 * d1 * yold8 + yold8**2
+ )
+ cb = np.sum(
+ omega / (beta_denominator_1 * beta_denominator_2 + self.EPSILON),
+ axis=1,
+ )
+
+ beta_energy = (
+ c1**2 - c1 * c2 + d1**2 - d1 * d2 - c1 * x8 +
+ c2 * x8 - d1 * y8 + d2 * y8
+ )
+ alpha_control = (np.sum(a1, axis=1) - 4.0 * x) * sigma
+ beta_control = (np.sum(b1, axis=1) - 4.0 * y) * sigma
+ dTdx_beta = np.sum(
+ -beta_energy * (c1 - c2) - alpha_control[:, None],
+ axis=1,
+ )
+ dTdy_beta = np.sum(
+ -beta_energy * (d1 - d2) - beta_control[:, None],
+ axis=1,
+ )
+ d2Tdx2_beta = np.sum((c1 - c2) ** 2 + 4.0 * sigma[:, None], axis=1)
+ d2Tdy2_beta = np.sum((d1 - d2) ** 2 + 4.0 * sigma[:, None], axis=1)
+ d2Tdxdy_beta = np.sum((c1 - c2) * (d1 - d2), axis=1)
+
+ dTdx = ca * dTdx_alpha + cb * dTdx_beta
+ dTdy = ca * dTdy_alpha + cb * dTdy_beta
+ d2Tdx2 = ca * d2Tdx2_alpha + cb * d2Tdx2_beta
+ d2Tdy2 = ca * d2Tdy2_alpha + cb * d2Tdy2_beta
+ d2Tdxdy = ca * d2Tdxdy_alpha + cb * d2Tdxdy_beta
+
+ hessian_determinant = d2Tdx2 * d2Tdy2 - d2Tdxdy**2
+ safe_hessian_determinant = np.where(
+ np.abs(hessian_determinant) < self.EPSILON,
+ np.where(hessian_determinant < 0.0, -self.EPSILON, self.EPSILON),
+ hessian_determinant,
+ )
+
+ xnew = x - (d2Tdy2 * dTdx - d2Tdxdy * dTdy) / safe_hessian_determinant
+ ynew = y - (d2Tdx2 * dTdy - d2Tdxdy * dTdx) / safe_hessian_determinant
+
+ residual = np.max(np.hypot(xnew - x, ynew - y))
+
+ previous_vertices[:, :] = current_vertices
+ current_vertices[self.center_indices, 0] = xnew
+ current_vertices[self.center_indices, 1] = ynew
+
+ if verbose and self._should_log_iteration(
+ iteration,
+ iterations,
+ log_interval,
+ ):
+ logger.info(f'Iteration={iteration:3d}, residual={residual:.3e}')
+
+ if residual < tolerance:
break
- # update current cardinals for next iteration
- cardinals = self.make_cardinals(smoothed_vertices)
-
- if self.drawlines:
- self.mainwindow.scene.createItemGroup(self.drawlines)
+ return self._as_vertex_list(current_vertices)
- return smoothed_vertices
-
def mapToUlines(self, smoothed_vertices):
+ if self.block is None:
+ raise ValueError(
+ 'mapToUlines() requires initialization with data_source="block".'
+ )
- self.new_ulines = list()
+ vertices = np.asarray(smoothed_vertices, dtype=float)
+ ulines = []
+ vertex_index = 0
- j = -1
for uline in self.block.getULines():
- new_uline = list()
- for i in range(len(uline)):
- j += 1
- new_uline.append(smoothed_vertices[j])
-
- self.new_ulines.append(new_uline)
-
- return self.new_ulines
+ point_count = len(uline)
+ new_uline = []
+ for offset in range(point_count):
+ x_value, y_value = vertices[vertex_index + offset]
+ new_uline.append((float(x_value), float(y_value)))
+ ulines.append(new_uline)
+ vertex_index += point_count
+
+ return ulines
diff --git a/src/Smoother.py b/src/Smoother.py
new file mode 100644
index 0000000..2ef6da7
--- /dev/null
+++ b/src/Smoother.py
@@ -0,0 +1,363 @@
+from __future__ import annotations
+
+from abc import ABC, abstractmethod
+from dataclasses import dataclass
+
+import numpy as np
+
+import Connect
+
+
+@dataclass(frozen=True)
+class StructuredBlockGrid:
+ """Structured block coordinates stored as x/y index arrays."""
+
+ x: np.ndarray
+ y: np.ndarray
+
+ def __post_init__(self):
+ if self.x.shape != self.y.shape:
+ raise ValueError('Structured grid x/y arrays must have the same shape.')
+ if self.x.ndim != 2:
+ raise ValueError('Structured grid coordinates must be 2D arrays.')
+
+ @property
+ def nx(self):
+ return self.x.shape[0]
+
+ @property
+ def ny(self):
+ return self.x.shape[1]
+
+ def copy(self):
+ return StructuredBlockGrid(
+ x=np.array(self.x, copy=True, dtype=float),
+ y=np.array(self.y, copy=True, dtype=float),
+ )
+
+ @classmethod
+ def from_ulines(cls, ulines):
+ if not ulines:
+ empty = np.empty((0, 0), dtype=float)
+ return cls(x=empty, y=empty)
+
+ ny = len(ulines)
+ nx = len(ulines[0])
+ x = np.empty((nx, ny), dtype=float)
+ y = np.empty_like(x)
+
+ for j_index, uline in enumerate(ulines):
+ if len(uline) != nx:
+ raise ValueError('All structured block lines must have the same length.')
+
+ coordinates = np.asarray(uline, dtype=float)
+ if coordinates.ndim != 2 or coordinates.shape[1] != 2:
+ raise ValueError('Structured block lines must contain 2D coordinates.')
+
+ x[:, j_index] = coordinates[:, 0]
+ y[:, j_index] = coordinates[:, 1]
+
+ return cls(x=x, y=y)
+
+ def to_ulines(self):
+ ulines = []
+ for j_index in range(self.ny):
+ ulines.append(
+ [
+ (float(self.x[i_index, j_index]), float(self.y[i_index, j_index]))
+ for i_index in range(self.nx)
+ ]
+ )
+ return ulines
+
+
+class Smoother(ABC):
+ """Abstract base class for smoothing algorithms."""
+
+ name = 'base'
+
+ @abstractmethod
+ def smooth(self, mesh, domain=None, **kwargs):
+ """Apply smoothing to a mesh or block-like object."""
+
+
+class BlockMeshSmoother(Smoother):
+ """Common utilities for smoothers operating on BlockMesh-like objects."""
+
+ default_iterations = 0
+ default_tolerance = 0.0
+ default_verbose = False
+ default_log_interval = 10
+
+ def smooth(self, mesh, domain=None, **kwargs):
+ self._require_block_mesh(mesh)
+ return self._smooth_block(mesh, domain=domain, **kwargs)
+
+ @abstractmethod
+ def _smooth_block(self, mesh, domain=None, **kwargs):
+ """Smooth a BlockMesh-like object and return it."""
+
+ @staticmethod
+ def _require_block_mesh(mesh):
+ if not hasattr(mesh, 'getULines') or not callable(mesh.getULines):
+ raise TypeError('Expected a block mesh with a getULines() method.')
+ if not hasattr(mesh, 'setUlines') or not callable(mesh.setUlines):
+ raise TypeError('Expected a block mesh with a setUlines() method.')
+
+ @staticmethod
+ def _grid_from_mesh(mesh):
+ return StructuredBlockGrid.from_ulines(mesh.getULines())
+
+ @staticmethod
+ def _apply_grid(mesh, grid):
+ mesh.setUlines(grid.to_ulines())
+ return mesh
+
+ @staticmethod
+ def _block_topology(mesh):
+ connector = Connect.Connect()
+ return connector.getVertices(mesh), connector.getConnectivity(mesh)
+
+ @staticmethod
+ def _map_vertices_to_ulines(mesh, vertices):
+ ulines = []
+ vertex_index = 0
+
+ for uline in mesh.getULines():
+ point_count = len(uline)
+ new_uline = []
+ for offset in range(point_count):
+ x_value, y_value = vertices[vertex_index + offset]
+ new_uline.append((float(x_value), float(y_value)))
+ ulines.append(new_uline)
+ vertex_index += point_count
+
+ if vertex_index != len(vertices):
+ raise ValueError(
+ 'Vertex count does not match the structured block line layout.'
+ )
+
+ return ulines
+
+ @classmethod
+ def _coerce_iterations(cls, kwargs):
+ return int(kwargs.get('iterations', cls.default_iterations))
+
+ @classmethod
+ def _coerce_tolerance(cls, kwargs):
+ return float(kwargs.get('tolerance', cls.default_tolerance))
+
+ @classmethod
+ def _coerce_verbose(cls, kwargs):
+ return bool(kwargs.get('verbose', cls.default_verbose))
+
+ @classmethod
+ def _coerce_log_interval(cls, kwargs):
+ interval = int(kwargs.get('log_interval', cls.default_log_interval))
+ return max(1, interval)
+
+
+class StructuredGridSmoother(BlockMeshSmoother):
+ """Base class for smoothers that work on structured x/y arrays."""
+
+ def _smooth_block(self, mesh, domain=None, **kwargs):
+ grid = self._grid_from_mesh(mesh)
+ smoothed_grid = self.smooth_grid(grid, domain=domain, **kwargs)
+ return self._apply_grid(mesh, smoothed_grid)
+
+ @abstractmethod
+ def smooth_grid(self, grid, domain=None, **kwargs):
+ """Return a smoothed StructuredBlockGrid."""
+
+
+class NoOpSmoother(BlockMeshSmoother):
+ name = 'none'
+
+ def _smooth_block(self, mesh, domain=None, **kwargs):
+ return mesh
+
+
+class SimpleBlockSmoother(StructuredGridSmoother):
+ name = 'simple'
+
+ @staticmethod
+ def _normalize_region(grid, domain='interior', ij=None):
+ if domain == 'interior':
+ i_start, i_end = 1, grid.nx - 1
+ j_start, j_end = 1, grid.ny - 1
+ elif domain == 'ij' and ij is not None:
+ i_start, i_end = ij[0], ij[1]
+ j_start, j_end = ij[2], ij[3]
+ else:
+ raise ValueError(f'Unknown node selection domain: {domain}')
+
+ i_start = max(1, min(i_start, grid.nx - 1))
+ i_end = max(i_start, min(i_end, grid.nx - 1))
+ j_start = max(1, min(j_start, grid.ny - 1))
+ j_end = max(j_start, min(j_end, grid.ny - 1))
+
+ return i_start, i_end, j_start, j_end
+
+ @staticmethod
+ def _smooth_region(x, y, region, iterations=1, algorithm='laplace'):
+ i_start, i_end, j_start, j_end = region
+
+ for _ in range(iterations):
+ for i_index in range(i_start, i_end):
+ for j_index in range(j_start, j_end):
+ if algorithm == 'laplace':
+ x[i_index, j_index] = (
+ x[i_index, j_index - 1] +
+ x[i_index + 1, j_index] +
+ x[i_index, j_index + 1] +
+ x[i_index - 1, j_index]
+ ) / 4.0
+ y[i_index, j_index] = (
+ y[i_index, j_index - 1] +
+ y[i_index + 1, j_index] +
+ y[i_index, j_index + 1] +
+ y[i_index - 1, j_index]
+ ) / 4.0
+ elif algorithm == 'parallelogram':
+ x[i_index, j_index] = (
+ x[i_index - 1, j_index - 1] +
+ x[i_index + 1, j_index - 1] +
+ x[i_index + 1, j_index + 1] +
+ x[i_index - 1, j_index + 1]
+ ) / 4.0 - (
+ x[i_index, j_index - 1] +
+ x[i_index + 1, j_index] +
+ x[i_index, j_index + 1] +
+ x[i_index - 1, j_index]
+ ) / 2.0
+ y[i_index, j_index] = (
+ y[i_index - 1, j_index - 1] +
+ y[i_index + 1, j_index - 1] +
+ y[i_index + 1, j_index + 1] +
+ y[i_index - 1, j_index + 1]
+ ) / 4.0 - (
+ y[i_index, j_index - 1] +
+ y[i_index + 1, j_index] +
+ y[i_index, j_index + 1] +
+ y[i_index - 1, j_index]
+ ) / 2.0
+ else:
+ raise ValueError(
+ f'Unknown simple smoothing algorithm: {algorithm}'
+ )
+
+ def smooth_grid(self, grid, domain=None, **kwargs):
+ smoothed = grid.copy()
+
+ self._smooth_region(
+ smoothed.x,
+ smoothed.y,
+ self._normalize_region(smoothed, domain='interior'),
+ iterations=1,
+ algorithm='laplace',
+ )
+
+ self._smooth_region(
+ smoothed.x,
+ smoothed.y,
+ self._normalize_region(
+ smoothed,
+ domain='ij',
+ ij=[1, 30, 1, smoothed.ny - 2],
+ ),
+ iterations=2,
+ algorithm='laplace',
+ )
+
+ self._smooth_region(
+ smoothed.x,
+ smoothed.y,
+ self._normalize_region(
+ smoothed,
+ domain='ij',
+ ij=[smoothed.nx - 31, smoothed.nx - 2, 1, smoothed.ny - 2],
+ ),
+ iterations=3,
+ algorithm='laplace',
+ )
+
+ return smoothed
+
+
+class EllipticBlockSmoother(StructuredGridSmoother):
+ name = 'elliptic'
+ default_iterations = 10
+ default_tolerance = 1.0e-3
+
+ def smooth_grid(self, grid, domain=None, **kwargs):
+ from Elliptic import EllipticSolver
+
+ iterations = self._coerce_iterations(kwargs)
+ tolerance = self._coerce_tolerance(kwargs)
+ verbose = self._coerce_verbose(kwargs)
+ log_interval = self._coerce_log_interval(kwargs)
+ boundary_condition = kwargs.get('boundary_condition', kwargs.get('bnd_type'))
+ boundary_guides = kwargs.get('boundary_guides')
+ sliding_boundaries = kwargs.get('sliding_boundaries')
+ relaxation = kwargs.get('relaxation')
+
+ solver = EllipticSolver(grid.x, grid.y)
+ x_smooth, y_smooth = solver.smooth(
+ iterations=iterations,
+ tolerance=tolerance,
+ boundary_condition=boundary_condition,
+ boundary_guides=boundary_guides,
+ sliding_boundaries=sliding_boundaries,
+ relaxation=relaxation,
+ verbose=verbose,
+ log_interval=log_interval,
+ )
+ return StructuredBlockGrid(x=x_smooth, y=y_smooth)
+
+
+class AngleBasedBlockSmoother(BlockMeshSmoother):
+ name = 'angle_based'
+ default_iterations = 20
+ default_tolerance = 1.0e-4
+
+ def _smooth_block(self, mesh, domain=None, **kwargs):
+ from Smooth_angle_based import SmoothAngleBased
+
+ iterations = self._coerce_iterations(kwargs)
+ tolerance = self._coerce_tolerance(kwargs)
+ verbose = self._coerce_verbose(kwargs)
+ log_interval = self._coerce_log_interval(kwargs)
+
+ vertices, connectivity = self._block_topology(mesh)
+ smoother = SmoothAngleBased(vertices, connectivity)
+ smoothed_vertices = smoother.smooth(
+ iterations=iterations,
+ tolerance=tolerance,
+ verbose=verbose,
+ log_interval=log_interval,
+ )
+ mesh.setUlines(self._map_vertices_to_ulines(mesh, smoothed_vertices))
+ return mesh
+
+
+class SmootherFactory:
+ """Factory class to create smoother instances."""
+
+ _registry = {
+ 'none': NoOpSmoother,
+ 'simple': SimpleBlockSmoother,
+ 'laplace': SimpleBlockSmoother,
+ 'elliptic': EllipticBlockSmoother,
+ 'angle_based': AngleBasedBlockSmoother,
+ }
+
+ @classmethod
+ def create_smoother(cls, algorithm: str):
+ key = algorithm.strip().lower()
+ try:
+ smoother_class = cls._registry[key]
+ except KeyError as error:
+ raise ValueError(
+ f'Unknown smoothing algorithm: {algorithm}'
+ ) from error
+ return smoother_class()
diff --git a/src/SplineRefine.py b/src/SplineRefine.py
index e8fbd51..738cfc1 100644
--- a/src/SplineRefine.py
+++ b/src/SplineRefine.py
@@ -1,86 +1,101 @@
import copy
-import numpy as np
-from scipy import interpolate
-
-from PySide6 import QtGui, QtCore
-
-from Utils import Utils
-import GraphicsItemsCollection as gic
-import GraphicsItem
+import numpy as np
+from scipy import interpolate
+
+from PySide6 import QtGui, QtCore
+
+from ContourData import SplineData
+from CSTAirfoil import (
+ METHOD_BSPLINE,
+ METHOD_CST_MODIFIED,
+ build_modified_cst_spline_data,
+)
+from MathUtils import VectorUtils
+import GraphicsItemsCollection as gic
+import GraphicsItem
import logging
logger = logging.getLogger(__name__)
-class SplineRefine:
-
- def __init__(self):
-
- # get MainWindow instance (overcomes handling parents)
- self.mainwindow = QtCore.QCoreApplication.instance().mainwindow
-
- def doSplineRefine(self, tolerance=172.0, points=150, ref_te=3,
- ref_te_n=6, ref_te_ratio=3.0):
-
- logger.debug('Arrived in doSplineRefine')
-
- # get raw coordinates
- x, y = self.mainwindow.airfoil.raw_coordinates
-
- # interpolate a spline through the raw contour points
- # constant point distribution used here
- # typically nose radius poorly resolved by that
- self.spline_data = self.spline(x, y, points=points, degree=3)
-
- # refine the contour in order to meet the tolerance
- # this keeps the constant distribution but refines around the nose
- spline_data = copy.deepcopy(self.spline_data)
- self.refine(spline_data, tolerance=tolerance)
-
- # redo spline on refined contour
- # spline only evaluated at refined contour points (evaluate=True)
- coo, u, t, der1, der2, tck = self.spline_data
- x, y = coo
- self.spline_data = self.spline(x, y, points=points, degree=3,
- evaluate=True)
-
- # refine the trailing edge of the spline
- self.refine_te(ref_te, ref_te_n, ref_te_ratio)
+class SplineRefine:
+ MAX_REFINEMENT_RECURSIONS = 50
+ MIN_REFINEMENT_PARAMETER_DELTA = 1.0e-10
+
+ def __init__(self):
+
+ # MainWindow instance
+ self.mw = QtCore.QCoreApplication.instance().mainwindow
+ self.spline_data = None
+
+ def doSplineRefine(self, tolerance=172.0, points=150, ref_te=3,
+ ref_te_n=6, ref_te_ratio=3.0,
+ method=METHOD_BSPLINE, cst_order=8):
+
+ logger.debug('Arrived in doSplineRefine')
+
+ # get raw coordinates
+ x, y = self.mw.airfoil.raw_coordinates
+
+ if method == METHOD_CST_MODIFIED:
+ self.spline_data = build_modified_cst_spline_data(
+ (x, y),
+ point_count=points,
+ order=cst_order,
+ )
+ spline_data = copy.deepcopy(self.spline_data)
+ self.spline_data = self.refine(spline_data, tolerance=tolerance)
+ else:
+ # interpolate a spline through the raw contour points
+ # constant point distribution used here
+ # typically nose radius poorly resolved by that
+ self.spline_data = self.spline(x, y, points=points, degree=3)
+
+ # refine the contour in order to meet the tolerance
+ # this keeps the constant distribution but refines around the nose
+ spline_data = copy.deepcopy(self.spline_data)
+ self.spline_data = self.refine(spline_data, tolerance=tolerance)
+
+ # redo spline on refined contour
+ # spline only evaluated at refined contour points (evaluate=True)
+ x, y = self.spline_data.coordinates
+ self.spline_data = self.spline(x, y, points=points, degree=3,
+ evaluate=True)
+
+ # refine the trailing edge of the spline
+ self.refine_te(ref_te, ref_te_n, ref_te_ratio)
# add spline data to airfoil object
- self.mainwindow.airfoil.spline_data = self.spline_data
-
- def getCamberThickness(self, spline_data, le_id):
-
- # split airfoil spline at leading edge
- # FIXME
- # FIXME why do I need to substract -3 here to be at LE ????
- # FIXME
- u_le = spline_data[1][le_id - 3]
- upper = np.linspace(u_le, 0.0, 300)
- lower = np.linspace(u_le, 1.0, 300)
- tck = spline_data[5]
- coo_upper = interpolate.splev(upper, tck, der=0)
- coo_lower = interpolate.splev(lower, tck, der=0)
-
- camber = 0.5 * (np.array(coo_upper) + np.array(coo_lower))
- thickness = np.array(coo_upper) - np.array(coo_lower)
-
- # maximum distance of y-coordinated to chord
- max_camber = np.max(camber[1])
- pos_camber = np.where(camber[1] == max_camber)
- max_camber_pos = camber[0][pos_camber][0]
-
- # maximum thickness
- max_thickness = np.max(thickness)
- pos_thickness = np.where(thickness == max_thickness)[1]
- # print('coo_upper[0]', coo_upper[0])
- max_thickness_pos = coo_upper[0][pos_thickness][0]
-
- # since we work with unit chord, multiply with 100 for percent
- logger.info('Maximum thickness: {:5.2f} % at {:5.2f} % chord'
- .format(max_thickness*100.0, max_thickness_pos*100.0))
+ self.mw.airfoil.spline_data = self.spline_data
+
+ def getCamberThickness(self, spline_data, le_id):
+
+ del le_id
+ stations = np.linspace(0.0, 1.0, 300)
+ upper = spline_data.upper_surface_parameters(stations)
+ lower = spline_data.lower_surface_parameters(stations)
+ coo_upper = spline_data.evaluate(upper, der=0)
+ coo_lower = spline_data.evaluate(lower, der=0)
+
+ camber = 0.5 * (np.array(coo_upper) + np.array(coo_lower))
+ thickness_vectors = np.array(coo_upper) - np.array(coo_lower)
+ paired_thickness = np.linalg.norm(thickness_vectors, axis=0)
+
+ # maximum distance of y-coordinated to chord
+ max_camber_id = int(np.argmax(camber[1]))
+ max_camber = camber[1][max_camber_id]
+ max_camber_pos = camber[0][max_camber_id]
+
+ # Thickness is defined here by the distance between paired upper/lower
+ # spline samples, preserving the deliberate parameter-based pairing.
+ max_thickness_id = int(np.argmax(paired_thickness))
+ max_thickness = paired_thickness[max_thickness_id]
+ max_thickness_pos = camber[0][max_thickness_id]
+
+ # since we work with unit chord, multiply with 100 for percent
+ logger.info('Maximum thickness: {:5.2f} % at {:5.2f} % chord'
+ .format(max_thickness*100.0, max_thickness_pos*100.0))
# since we work with unit chord, multiply with 100 for percent
logger.info('Maximum camber: {:5.2f} % at {:5.2f} % chord'
@@ -88,81 +103,86 @@ def getCamberThickness(self, spline_data, le_id):
return camber
- def makeLeCircle(self, rc, xc, yc, xle, yle):
-
- # delete exitsing LE circle ItemGroup from scene
- if hasattr(self.mainwindow.airfoil, 'le_circle') and \
- self.mainwindow.airfoil.le_circle in self.mainwindow.scene.items():
- self.mainwindow.scene.removeItem(self.mainwindow.airfoil.le_circle)
+ def makeLeCircle(self, rc, xc, yc, xle, yle):
+ palette = self.mw.airfoil._display_palette()
+
+ # delete exitsing LE circle ItemGroup from scene
+ if hasattr(self.mw.airfoil, 'le_circle') and \
+ self.mw.airfoil.le_circle in self.mw.scene.items():
+ self.mw.scene.removeItem(self.mw.airfoil.le_circle)
# put LE circle, center and tangent point in a list
circles = list()
-
- circle = gic.GraphicsCollection()
- circle.pen.setColor(QtGui.QColor(0, 150, 0, 255))
- circle.pen.setWidthF(0.3)
- # no pen thickness change when zoomed
- circle.pen.setCosmetic(True)
- circle.brush.setColor(QtGui.QColor(10, 200, 10, 150))
- circle.Circle(xc, yc, rc)
-
- circle = GraphicsItem.GraphicsItem(circle)
- circles.append(circle)
-
- circle = gic.GraphicsCollection()
- circle.pen.setColor(QtGui.QColor(255, 0, 0, 255))
- circle.pen.setWidthF(0.3)
- # no pen thickness change when zoomed
- circle.pen.setCosmetic(True)
- circle.brush.setColor(QtGui.QColor(255, 0, 0, 255))
- circle.Circle(xc, yc, 0.0002)
-
- circle = GraphicsItem.GraphicsItem(circle)
- circles.append(circle)
-
- circle = gic.GraphicsCollection()
- circle.pen.setColor(QtGui.QColor(255, 0, 0, 255))
- circle.pen.setWidthF(1.6)
- # no pen thickness change when zoomed
- circle.pen.setCosmetic(True)
- circle.brush.setColor(QtGui.QColor(255, 0, 0, 255))
- circle.Circle(xle, yle, 0.0002)
-
- circle = GraphicsItem.GraphicsItem(circle)
- circles.append(circle)
-
- self.mainwindow.airfoil.le_circle = \
- self.mainwindow.scene.createItemGroup(circles)
- self.mainwindow.airfoil.le_circle.setZValue(110)
-
- self.mainwindow.centralwidget.leading_edge_circle_checkbox.setChecked(True)
- self.mainwindow.centralwidget.leading_edge_circle_checkbox.setEnabled(True)
-
- def spline(self, x, y, points=200, degree=2, evaluate=False):
- """Interpolate spline through given points
-
- Args:
- spline (int, optional): Number of points on the spline
- degree (int, optional): Degree of the spline
- evaluate (bool, optional): If True, evaluate spline just at
- the coordinates of the knots
- """
-
- # interpolate B-spline through data points
- # returns knots of control polygon
- # tck ... tuple (t,c,k) containing the vector of knots,
- # the B-spline coefficients, and the degree of the spline.
- # u ... array of the parameters for each knot
- # NOTE: s=0.0 is important as no smoothing should be done on the spline
- # after interpolating it
- tck, u = interpolate.splprep([x, y], s=0.0, k=degree)
-
- # number of points on interpolated B-spline (parameter t)
- t = np.linspace(0.0, 1.0, points)
-
- # if True, evaluate spline just at the coordinates of the knots
- if evaluate:
- t = u
+
+ circle = gic.GraphicsCollection()
+ circle.pen.setColor(palette['le_circle_pen'])
+ circle.pen.setWidthF(0.9)
+ # no pen thickness change when zoomed
+ circle.pen.setCosmetic(True)
+ circle.brush.setColor(palette['le_circle_fill'])
+ circle.Circle(xc, yc, rc)
+
+ circle = GraphicsItem.GraphicsItem(circle)
+ circle.setAcceptHoverEvents(False)
+ circles.append(circle)
+
+ circle = gic.GraphicsCollection()
+ circle.pen.setColor(palette['le_center_pen'])
+ circle.pen.setWidthF(0.95)
+ # no pen thickness change when zoomed
+ circle.pen.setCosmetic(True)
+ circle.brush.setColor(palette['le_center_fill'])
+ circle.Circle(xc, yc, 0.0002)
+
+ circle = GraphicsItem.GraphicsItem(circle)
+ circle.setAcceptHoverEvents(False)
+ circles.append(circle)
+
+ circle = gic.GraphicsCollection()
+ circle.pen.setColor(palette['le_tangent_pen'])
+ circle.pen.setWidthF(1.2)
+ # no pen thickness change when zoomed
+ circle.pen.setCosmetic(True)
+ circle.brush.setColor(palette['le_tangent_fill'])
+ circle.Circle(xle, yle, 0.0002)
+
+ circle = GraphicsItem.GraphicsItem(circle)
+ circle.setAcceptHoverEvents(False)
+ circles.append(circle)
+
+ self.mw.airfoil.le_circle = \
+ self.mw.scene.createItemGroup(circles)
+ self.mw.airfoil.le_circle.setZValue(110)
+
+ self.mw.mainArea.leading_edge_circle_checkbox.setChecked(True)
+ self.mw.mainArea.leading_edge_circle_checkbox.setEnabled(True)
+
+ def spline(self, x, y, points=200, degree=2, evaluate=False):
+ """Interpolate spline through given points
+
+ Args:
+ points (int, optional): Number of points used to sample the spline
+ degree (int, optional): Degree of the spline
+ evaluate (bool, optional): If True, evaluate the spline at the
+ input-point parameters returned by
+ splprep so the output matches the
+ current contour points.
+ """
+
+ # Interpolate a parametric B-spline through the input contour points.
+ # tck ... tuple (knots, coefficients, degree) describing the spline.
+ # u ... parameter value assigned by splprep to each input point.
+ # NOTE: s=0.0 is important as no smoothing should be done on the spline
+ # after interpolating it
+ tck, u = interpolate.splprep([x, y], s=0.0, k=degree)
+
+ # t is the parameter array used to sample the spline for coo/derivatives.
+ t = np.linspace(0.0, 1.0, points)
+
+ # When evaluate=True we keep the refined contour point distribution
+ # instead of resampling it on a uniform parameter grid.
+ if evaluate:
+ t = u
# evaluate B-spline at given parameters
# der=0: returns point coordinates
@@ -171,138 +191,265 @@ def spline(self, x, y, points=200, degree=2, evaluate=False):
# evaluate 1st derivative at given parameters
der1 = interpolate.splev(t, tck, der=1)
- # evaluate 2nd derivative at given parameters
- der2 = interpolate.splev(t, tck, der=2)
-
- spline_data = [coo, u, t, der1, der2, tck]
-
- return spline_data
-
- def refine(self, spline_data, tolerance=170.0, recursions=0):
- """Recursive refinement with respect to angle criterion (tol).
- If angle between two adjacent line segments is less than tol,
- a recursive refinement of the contour is performed until
- tol is met.
-
- Args:
- tol (float, optional): Angle between two adjacent contour segments
- recursions (int, optional): NO USER INPUT HERE
- Needed just for level information
- during recursions
- """
-
- # self.spline_data = [coo, u, t, der1, der2, tck]
- xx, yy = spline_data[0]
- t = spline_data[2]
- tck = spline_data[5]
-
- logger.debug('\nPoints before refining: {} \n'.format(len(xx)))
-
- xn = copy.deepcopy(xx)
- yn = copy.deepcopy(yy)
- tn = copy.deepcopy(t)
-
- j = 0
- refinements = 0
- first = True
- refined = dict()
-
- for i in range(len(xx) - 2):
- refined[i] = False
-
- # angle between two contour line segments
- a = np.array([xx[i], yy[i]])
- b = np.array([xx[i + 1], yy[i + 1]])
- c = np.array([xx[i + 2], yy[i + 2]])
- angle = Utils.angle_between(a - b, c - b, degree=True)
-
- if angle < tolerance:
-
- logger.debug('Refining between segments {} {},'
- .format(i, i + 1))
- logger.debug('Tol={0:5.1f}, Angle={1:05.1f}\n'
- .format(tolerance, angle))
-
- refined[i] = True
- refinements += 1
-
- # parameters for new points
- t1 = (t[i] + t[i + 1]) / 2.
- t2 = (t[i + 1] + t[i + 2]) / 2.
-
- # coordinates of new points
- p1 = interpolate.splev(t1, tck, der=0)
- p2 = interpolate.splev(t2, tck, der=0)
-
- # insert points and their parameters into arrays
- if i > 0 and not refined[i - 1]:
- xn = np.insert(xn, i + 1 + j, p1[0])
- yn = np.insert(yn, i + 1 + j, p1[1])
- tn = np.insert(tn, i + 1 + j, t1)
- j += 1
- xn = np.insert(xn, i + 2 + j, p2[0])
- yn = np.insert(yn, i + 2 + j, p2[1])
- tn = np.insert(tn, i + 2 + j, t2)
- j += 1
-
- if first and recursions > 0:
- logger.debug('Recursion level: {} \n'.format(recursions))
- first = False
-
- logger.debug('Points after refining: {}'.format(len(xn)))
-
- # update coordinate array, including inserted points
- spline_data[0] = (xn, yn)
- # update parameter array, including parameters of inserted points
- spline_data[2] = tn
-
- # this is the recursion :)
- if refinements > 0:
- self.refine(spline_data, tolerance, recursions + 1)
-
- # stopping from recursion if no refinements done in this recursion
- else:
- # update derivatives, including inserted points
- spline_data[3] = interpolate.splev(tn, tck, der=1)
- spline_data[4] = interpolate.splev(tn, tck, der=2)
-
- logger.debug('No more refinements.')
- logger.debug('\nTotal number of recursions: {}'
- .format(recursions - 1))
-
- # due to recursive call to refine, here no object can be returned
- # instead use self to transfer data to the outer world :)
- self.spline_data = copy.deepcopy(spline_data)
- return
-
- def refine_te(self, ref_te, ref_te_n, ref_te_ratio):
- """Refine the airfoil contour at the trailing edge
-
- Args:
- ref_te (TYPE): Description
- ref_te_n (TYPE): Description
- ref_te_ratio (TYPE): Description
-
- Returns:
- TYPE: Description
- """
- # get parameter of point to which refinement reaches
- tref = self.spline_data[2][ref_te]
+ # evaluate 2nd derivative at given parameters
+ der2 = interpolate.splev(t, tck, der=2)
+
+ return SplineData(
+ coordinates=coo,
+ fit_parameters=u,
+ sample_parameters=t,
+ first_derivative=der1,
+ second_derivative=der2,
+ spline=tck,
+ method=METHOD_BSPLINE,
+ metadata={
+ 'degree': degree,
+ 'label': 'B-spline',
+ },
+ leading_edge_parameter=float(t[int(np.argmin(coo[0]))]),
+ )
+
+ def _refreshSampledData(self, spline_data, parameters=None):
+ if parameters is not None:
+ spline_data.sample_parameters = np.asarray(parameters, dtype=float)
+
+ t = np.asarray(spline_data.sample_parameters, dtype=float)
+ spline_data.coordinates = tuple(
+ np.asarray(values, dtype=float)
+ for values in spline_data.evaluate(t, der=0)
+ )
+ spline_data.first_derivative = tuple(
+ np.asarray(values, dtype=float)
+ for values in spline_data.evaluate(t, der=1)
+ )
+ spline_data.second_derivative = tuple(
+ np.asarray(values, dtype=float)
+ for values in spline_data.evaluate(t, der=2)
+ )
+ spline_data.leading_edge_parameter = float(
+ t[int(np.argmin(spline_data.coordinates[0]))]
+ )
+ return spline_data
+
+ def rebuildSplineData(self, coordinates=None, degree=3, method=None,
+ cst_order=None):
+ coordinates = coordinates or (
+ self.spline_data.coordinates if self.spline_data is not None else None
+ )
+ if coordinates is None:
+ return None
+
+ x, y = coordinates
+ point_count = len(x)
+ if point_count < 2:
+ return None
+
+ template = self.spline_data
+ if template is None:
+ template = getattr(getattr(self.mw, 'airfoil', None), 'spline_data', None)
+
+ active_method = method or getattr(template, 'method', METHOD_BSPLINE)
+ if active_method == METHOD_CST_MODIFIED:
+ order = cst_order
+ if order is None and template is not None:
+ order = getattr(template, 'metadata', {}).get('order')
+ order = 8 if order is None else int(order)
+
+ sample_parameters = None
+ if template is not None and \
+ getattr(template, 'sample_parameters', None) is not None and \
+ len(template.sample_parameters) == point_count:
+ sample_parameters = np.asarray(template.sample_parameters, dtype=float)
+
+ self.spline_data = build_modified_cst_spline_data(
+ (x, y),
+ order=order,
+ sample_parameters=sample_parameters,
+ )
+ return self.spline_data
+
+ degree = max(1, min(degree, point_count - 1))
+ self.spline_data = self.spline(
+ x,
+ y,
+ points=point_count,
+ degree=degree,
+ evaluate=True,
+ )
+ return self.spline_data
+
+ def _finalizeRefinement(self, spline_data, recursions, reason):
+ self._refreshSampledData(spline_data)
+
+ logger.debug(reason)
+ logger.debug(
+ '\nTotal number of recursive refinement passes: {}'
+ .format(recursions)
+ )
+
+ self.spline_data = copy.deepcopy(spline_data)
+ return self.spline_data
+
+ def _canInsertRefinementParameters(self, *parameters, min_parameter_delta):
+ parameters = np.asarray(parameters, dtype=float)
+ differences = np.abs(parameters[:, None] - parameters[None, :])
+ differences = differences[np.triu_indices(len(parameters), k=1)]
+ return bool(np.all(differences > min_parameter_delta))
+
+ def refine(self, spline_data, tolerance=170.0, recursions=0,
+ max_recursions=None, min_parameter_delta=None):
+ """Recursive refinement with respect to angle criterion (tol).
+ If angle between two adjacent line segments is less than tol,
+ a recursive refinement of the contour is performed until
+ tol is met.
+
+ Args:
+ tol (float, optional): Angle between two adjacent contour segments
+ recursions (int, optional): NO USER INPUT HERE
+ Needed just for level information
+ during recursions
+ """
+ max_recursions = (
+ self.MAX_REFINEMENT_RECURSIONS
+ if max_recursions is None else
+ int(max_recursions)
+ )
+ min_parameter_delta = (
+ self.MIN_REFINEMENT_PARAMETER_DELTA
+ if min_parameter_delta is None else
+ float(min_parameter_delta)
+ )
+
+ xx, yy = spline_data.coordinates
+ t = spline_data.sample_parameters
+
+ logger.debug('\nPoints before refining: {} \n'.format(len(xx)))
+ if recursions > 0:
+ logger.debug('Refinement recursion level: {}'.format(recursions))
+
+ if recursions >= max_recursions:
+ return self._finalizeRefinement(
+ spline_data,
+ recursions,
+ 'Reached maximum recursive refinement depth ({}).'
+ .format(max_recursions),
+ )
+
+ if len(xx) < 3:
+ return self._finalizeRefinement(
+ spline_data,
+ recursions,
+ 'Refinement stopped because fewer than three contour points remain.',
+ )
+
+ refinements = 0
+ refined = [False] * (len(xx) - 2)
+ spacing_limited = 0
+
+ for i in range(len(xx) - 2):
+ # angle between two contour line segments
+ a = np.array([xx[i], yy[i]])
+ b = np.array([xx[i + 1], yy[i + 1]])
+ c = np.array([xx[i + 2], yy[i + 2]])
+ angle = VectorUtils.angle_between(a - b, c - b, degree=True)
+
+ if angle < tolerance:
+ # parameters for new points
+ t1 = (t[i] + t[i + 1]) / 2.
+ t2 = (t[i + 1] + t[i + 2]) / 2.
+ can_insert = self._canInsertRefinementParameters(
+ t[i], t1, t[i + 1], t2, t[i + 2],
+ min_parameter_delta=min_parameter_delta,
+ )
+ if not can_insert:
+ spacing_limited += 1
+ continue
+
+ logger.debug('Refining between segments {} {},'
+ .format(i, i + 1))
+ logger.debug('Tol={0:5.1f}, Angle={1:05.1f}\n'
+ .format(tolerance, angle))
+
+ refined[i] = True
+ refinements += 1
+
+ xn = [float(xx[0])]
+ yn = [float(yy[0])]
+ tn = [float(t[0])]
+
+ for segment_index in range(len(xx) - 1):
+ # Preserve the existing midpoint insertion rule from the recursive
+ # implementation: interior segments receive one midpoint if either
+ # adjacent refinement triplet requested it.
+ if segment_index > 0:
+ insert_midpoint = refined[segment_index - 1]
+ if segment_index < len(refined):
+ insert_midpoint = insert_midpoint or refined[segment_index]
+ if insert_midpoint:
+ t_mid = 0.5 * (t[segment_index] + t[segment_index + 1])
+ p_mid = spline_data.evaluate(t_mid, der=0)
+ xn.append(float(p_mid[0]))
+ yn.append(float(p_mid[1]))
+ tn.append(float(t_mid))
+
+ xn.append(float(xx[segment_index + 1]))
+ yn.append(float(yy[segment_index + 1]))
+ tn.append(float(t[segment_index + 1]))
+
+ logger.debug('Points after refining: {}'.format(len(xn)))
+
+ # update coordinate array, including inserted points
+ spline_data.coordinates = (
+ np.asarray(xn, dtype=float),
+ np.asarray(yn, dtype=float),
+ )
+ # update parameter array, including parameters of inserted points
+ spline_data.sample_parameters = np.asarray(tn, dtype=float)
+
+ # this is the recursion :)
+ if refinements > 0:
+ return self.refine(
+ spline_data,
+ tolerance=tolerance,
+ recursions=recursions + 1,
+ max_recursions=max_recursions,
+ min_parameter_delta=min_parameter_delta,
+ )
+
+ # stopping from recursion if no refinements done in this recursion
+ reason = 'No more refinements.'
+ if spacing_limited > 0:
+ reason = (
+ 'No more refinements. Minimum parameter spacing blocked {} '
+ 'candidate insertions.'
+ ).format(spacing_limited)
+ return self._finalizeRefinement(spline_data, recursions, reason)
+
+ def refine_te(self, ref_te, ref_te_n, ref_te_ratio):
+ """Refine the airfoil contour at the trailing edge
+
+ Args:
+ ref_te (int): Number of original points removed per side near the
+ trailing edge before reinserting the refined distribution.
+ ref_te_n (int): Number of refined subdivisions used per side.
+ ref_te_ratio (float): Growth ratio used for the refined spacing.
+ """
+ # get parameter of point to which refinement reaches
+ tref = self.spline_data.sample_parameters[ref_te]
# calculate the new spacing at the trailing edge points
spacing = self.spacing(divisions=ref_te_n, ratio=ref_te_ratio,
thickness=tref)
- # insert new points with the spacing into the airfoil contour data
-
- x, y = self.spline_data[0]
- t = self.spline_data[2]
- tck = self.spline_data[5]
-
- # remove points which will be refined
- index = range(ref_te + 1)
- x = np.delete(x, index)
- y = np.delete(y, index)
+ # insert new points with the spacing into the airfoil contour data
+
+ x, y = self.spline_data.coordinates
+ t = self.spline_data.sample_parameters
+
+ # remove points which will be refined
+ index = range(ref_te + 1)
+ x = np.delete(x, index)
+ y = np.delete(y, index)
t = np.delete(t, index)
index = range(len(x))[-(ref_te + 1):]
@@ -310,38 +457,33 @@ def refine_te(self, ref_te, ref_te_n, ref_te_ratio):
y = np.delete(y, index)
t = np.delete(t, index)
- # add refined points
- for s in spacing[::-1]:
- # upper side
- p = interpolate.splev(s, tck, der=0)
- x = np.insert(x, 0, p[0])
- y = np.insert(y, 0, p[1])
- t = np.insert(t, 0, s)
- # lower side
- p = interpolate.splev(1. - s, tck, der=0)
- x = np.append(x, p[0])
- y = np.append(y, p[1])
- t = np.append(t, 1. - s)
-
- # update coordinate array, including inserted points
- self.spline_data[0] = (x, y)
- # update parameter array, including parameters of inserted points
- self.spline_data[2] = t
- # update derivatives, including inserted points
- self.spline_data[3] = interpolate.splev(t, tck, der=1)
- self.spline_data[4] = interpolate.splev(t, tck, der=2)
-
- def spacing(self, divisions=10, ratio=1.0, thickness=1.0):
- """Calculate point distribution on a line
-
- Args:
- divisions (int, optional): Number of subdivisions
- ratio (float, optional): Ratio of last to first subdivision size
- thickness (float, optional): length of line
-
- Returns:
- TYPE: Description
- """
+ # add refined points
+ for s in spacing[::-1]:
+ # upper side
+ p = self.spline_data.evaluate(s, der=0)
+ x = np.insert(x, 0, p[0])
+ y = np.insert(y, 0, p[1])
+ t = np.insert(t, 0, s)
+ # lower side
+ p = self.spline_data.evaluate(1. - s, der=0)
+ x = np.append(x, p[0])
+ y = np.append(y, p[1])
+ t = np.append(t, 1. - s)
+
+ self.spline_data.coordinates = (x, y)
+ self._refreshSampledData(self.spline_data, parameters=t)
+
+ def spacing(self, divisions=10, ratio=1.0, thickness=1.0):
+ """Calculate point distribution on a line
+
+ Args:
+ divisions (int, optional): Number of subdivisions
+ ratio (float, optional): Ratio of last to first subdivision size
+ thickness (float, optional): length of line
+
+ Returns:
+ np.ndarray: Normalized spacing scaled to ``thickness``.
+ """
if divisions == 1:
sp = [0.0, 1.0]
return np.array(sp)
diff --git a/src/StructuredCore.py b/src/StructuredCore.py
new file mode 100644
index 0000000..0014eb0
--- /dev/null
+++ b/src/StructuredCore.py
@@ -0,0 +1,163 @@
+"""Shared dataclasses and distribution utilities for the Structured engine.
+
+See docs/superpowers/specs/2026-08-22-structured-grid-framework-design.md.
+"""
+from __future__ import annotations
+
+from dataclasses import dataclass, field
+
+import numpy as np
+
+
+@dataclass(slots=True)
+class TunnelBoundaryControl:
+ distribution: str = 'uniform' # 'uniform' | 'clustered'
+ clustering_ratio: float = 1.0 # last/first spacing when clustered
+ angle_mode: str = 'free' # 'free' | 'orthogonal'
+
+
+@dataclass(slots=True)
+class StructuredMeshSettings:
+ topology: str = 'c' # 'c' | 'o'
+ tunnel_shape: str = 'legacy' # 'legacy' | 'circular'
+ tunnel_height: float = 3.5 # legacy half-height / circular radius
+ wake_length: float = 7.0 # TE to outlet (C-mesh only)
+ algorithm: str = 'tfi' # 'tfi' | 'elliptic' | 'hyperbolic'
+ tfi_variant: str = 'standard' # 'standard' | 'hermite'
+ elliptic_iterations: int = 150
+ elliptic_relaxation: float = 0.8
+ hyperbolic_fraction_cap: float = 0.5
+ hyperbolic_smoothing: float = 1.0
+ smoother: str = 'none' # 'none' | 'laplacian' | 'elliptic' | 'angle_based'
+ smoother_iterations: int = 10
+ normal_divisions: int = 60 # total wall->outer cells (incl. ortho)
+ first_layer_thickness: float = 0.002
+ wake_points: int = 60 # nodes along each wake cut (C-mesh)
+ ortho_layers: int = 0 # 0 = ortho block off
+ ortho_growth: float = 1.15
+ boundary_control: TunnelBoundaryControl = field(
+ default_factory=TunnelBoundaryControl
+ )
+
+
+@dataclass(slots=True)
+class GridFrame:
+ """Boundary frame a volume algorithm fills. All arrays are (n, 2) float.
+
+ wall is row j=0, outer is row j=nj-1; side_start / side_end are the
+ i=0 / i=ni-1 columns running wall -> outer. len(side_start) ==
+ len(side_end) and side endpoints coincide with wall/outer endpoints.
+ """
+ wall: np.ndarray
+ outer: np.ndarray
+ side_start: np.ndarray
+ side_end: np.ndarray
+ kind: str # 'o' | 'c' | 'wake_strip'
+ te_type: str # 'sharp' | 'blunt'
+ periodic: bool = False
+ metadata: dict = field(default_factory=dict)
+
+
+def contour_array(spline_data) -> np.ndarray:
+ x, y = spline_data.coordinates
+ return np.column_stack((np.asarray(x, float), np.asarray(y, float)))
+
+
+def detect_te_type(contour: np.ndarray, tolerance: float = 1.0e-6) -> str:
+ contour = np.asarray(contour, dtype=float)
+ chord = float(np.ptp(contour[:, 0])) or 1.0
+ gap = float(np.linalg.norm(contour[0] - contour[-1]))
+ return 'sharp' if gap <= tolerance * chord else 'blunt'
+
+
+def _growth_for_length(length: float, first_spacing: float,
+ divisions: int) -> float:
+ """Solve sum_{k=0}^{divisions-1} first*g**k == length for g by bisection."""
+ if divisions * first_spacing >= length:
+ return 1.0
+ low, high = 1.0 + 1.0e-12, 10.0
+ for _ in range(200):
+ mid = 0.5 * (low + high)
+ total = first_spacing * (mid ** divisions - 1.0) / (mid - 1.0)
+ if total < length:
+ low = mid
+ else:
+ high = mid
+ return 0.5 * (low + high)
+
+
+def geometric_distances(length: float, first_spacing: float,
+ count: int) -> np.ndarray:
+ if count < 2:
+ raise ValueError('geometric_distances needs count >= 2.')
+ if first_spacing <= 0.0 or length <= 0.0:
+ raise ValueError('length and first_spacing must be positive.')
+ divisions = count - 1
+ if divisions * first_spacing >= length:
+ # First spacing too large to grow: fall back to uniform.
+ return np.linspace(0.0, length, count)
+ growth = _growth_for_length(length, first_spacing, divisions)
+ steps = first_spacing * growth ** np.arange(divisions)
+ distances = np.concatenate(([0.0], np.cumsum(steps)))
+ distances *= length / distances[-1]
+ return distances
+
+
+def sample_te_base(p_from: np.ndarray, p_to: np.ndarray,
+ spacing: float) -> np.ndarray:
+ p_from = np.asarray(p_from, dtype=float)
+ p_to = np.asarray(p_to, dtype=float)
+ gap = float(np.linalg.norm(p_to - p_from))
+ if gap <= 0.0:
+ raise ValueError('sample_te_base needs distinct endpoints.')
+ divisions = max(2, int(round(gap / max(spacing, 1.0e-12))))
+ fractions = np.linspace(0.0, 1.0, divisions + 1)[:, None]
+ return p_from[None, :] * (1.0 - fractions) + p_to[None, :] * fractions
+
+
+def polyline_cumulative(points: np.ndarray) -> np.ndarray:
+ deltas = np.diff(points, axis=0)
+ return np.concatenate(([0.0], np.cumsum(np.hypot(deltas[:, 0],
+ deltas[:, 1]))))
+
+
+def sample_polyline_at(points: np.ndarray,
+ distances: np.ndarray) -> np.ndarray:
+ points = np.asarray(points, dtype=float)
+ cumulative = polyline_cumulative(points)
+ distances = np.clip(distances, 0.0, cumulative[-1])
+ x = np.interp(distances, cumulative, points[:, 0])
+ y = np.interp(distances, cumulative, points[:, 1])
+ return np.column_stack((x, y))
+
+
+def cell_jacobians(rows: np.ndarray) -> np.ndarray:
+ """Signed area of each quad cell of a (nj, ni, 2) structured array."""
+ a = rows[:-1, :-1]
+ b = rows[:-1, 1:]
+ c = rows[1:, 1:]
+ d = rows[1:, :-1]
+ return 0.5 * (
+ (a[..., 0] * b[..., 1] - b[..., 0] * a[..., 1]) +
+ (b[..., 0] * c[..., 1] - c[..., 0] * b[..., 1]) +
+ (c[..., 0] * d[..., 1] - d[..., 0] * c[..., 1]) +
+ (d[..., 0] * a[..., 1] - a[..., 0] * d[..., 1])
+ )
+
+
+def distribute_on_polyline(points: np.ndarray, count: int,
+ distribution: str = 'uniform',
+ ratio: float = 1.0) -> np.ndarray:
+ points = np.asarray(points, dtype=float)
+ length = polyline_cumulative(points)[-1]
+ if distribution == 'uniform' or ratio == 1.0:
+ distances = np.linspace(0.0, length, count)
+ elif distribution == 'clustered':
+ growth = ratio ** (1.0 / (count - 2))
+ steps = growth ** np.arange(count - 1)
+ distances = np.concatenate(([0.0], np.cumsum(steps)))
+ distances *= length / distances[-1]
+ else:
+ raise ValueError(
+ f'Unknown boundary distribution: {distribution!r}.')
+ return sample_polyline_at(points, distances)
diff --git a/src/StructuredEngine.py b/src/StructuredEngine.py
new file mode 100644
index 0000000..2f1039a
--- /dev/null
+++ b/src/StructuredEngine.py
@@ -0,0 +1,181 @@
+"""Orchestrator for the Structured engine.
+
+Composes topology frames, the optional exact-normal ortho block, and the
+selected volume algorithm (phase 1: TFI standard/Hermite) into ordinary
+BlockMesh objects. A blunt-TE C-mesh yields two blocks: the main C block
+and the wake strip behind the base.
+"""
+from __future__ import annotations
+
+import logging
+
+import numpy as np
+
+import GridElliptic
+import GridHyperbolic
+import GridSmoothers
+import GridTFI
+import OrthoLayers
+from BlockMesh import BlockMesh
+from StructuredCore import (
+ StructuredMeshSettings,
+ cell_jacobians,
+ contour_array,
+)
+from StructuredTopologies import build_frames, side_segment
+
+MAIN_BLOCK_NAME = 'block_structured'
+WAKE_STRIP_BLOCK_NAME = 'block_structured_wake_strip'
+
+logger = logging.getLogger(__name__)
+
+
+class StructuredEngine:
+
+ def build_blocks(self, *, spline_data,
+ settings: StructuredMeshSettings):
+ contour = contour_array(spline_data)
+ frames = build_frames(contour, settings)
+
+ named_blocks = []
+ for frame in frames:
+ if frame.kind == 'wake_strip':
+ rows = GridTFI.fill(frame, 'standard')
+ name = WAKE_STRIP_BLOCK_NAME
+ frozen_rows = 1
+ else:
+ rows = self._fill_main_frame(frame, spline_data, settings)
+ name = MAIN_BLOCK_NAME
+ frozen_rows = 1 + settings.ortho_layers
+ rows = self._apply_smoother(rows, frame, settings, frozen_rows,
+ name)
+ self._reject_inverted(rows, name)
+ named_blocks.append((name, self._emit_block(name, rows)))
+ return named_blocks
+
+ @staticmethod
+ def _apply_smoother(rows, frame, settings: StructuredMeshSettings,
+ frozen_rows: int, name: str) -> np.ndarray:
+ if settings.smoother == 'none':
+ return rows
+ rows, info = GridSmoothers.smooth(
+ rows, method=settings.smoother,
+ iterations=settings.smoother_iterations,
+ periodic=frame.periodic, frozen_rows=frozen_rows,
+ )
+ logger.info(
+ 'Structured block %r: %s smoother quality %.3e -> %.3e',
+ name, settings.smoother, info['quality_before'],
+ info['quality_after'])
+ return rows
+
+ def _volume_fill(self, frame, settings: StructuredMeshSettings, *,
+ spline_data=None, corner_indices=None) -> np.ndarray:
+ if settings.algorithm == 'hyperbolic':
+ contour_slice = (frame.metadata.get('contour_slice')
+ if spline_data is not None else None)
+ normals = OrthoLayers.wall_normals(
+ frame.wall, corner_indices or [],
+ spline_data=spline_data, contour_slice=contour_slice,
+ closed=frame.periodic,
+ )
+ return GridHyperbolic.march(
+ frame, normals=normals,
+ first_spacing=settings.first_layer_thickness,
+ fraction_cap=settings.hyperbolic_fraction_cap,
+ smoothing=settings.hyperbolic_smoothing,
+ )
+
+ rows = GridTFI.fill(frame, settings.tfi_variant,
+ settings.boundary_control)
+ if settings.algorithm == 'elliptic':
+ rows, _info = GridElliptic.solve(
+ rows,
+ periodic=frame.periodic,
+ iterations=settings.elliptic_iterations,
+ relaxation=settings.elliptic_relaxation,
+ outer_orthogonal=(
+ settings.boundary_control.angle_mode == 'orthogonal'),
+ )
+ return rows
+
+ def _fill_main_frame(self, frame, spline_data,
+ settings: StructuredMeshSettings) -> np.ndarray:
+ if settings.algorithm not in ('tfi', 'elliptic', 'hyperbolic'):
+ raise ValueError(
+ f'Unknown structured algorithm: {settings.algorithm!r}.')
+ corner_indices = self._corner_indices(frame)
+ if settings.ortho_layers <= 0:
+ return self._volume_fill(
+ frame, settings, spline_data=spline_data,
+ corner_indices=corner_indices)
+
+ if settings.ortho_layers >= settings.normal_divisions:
+ raise ValueError(
+ 'Ortho layers must be fewer than total normal divisions.')
+
+ contour_slice = frame.metadata.get('contour_slice')
+ normals = OrthoLayers.wall_normals(
+ frame.wall, corner_indices,
+ spline_data=spline_data, contour_slice=contour_slice,
+ closed=frame.periodic,
+ )
+ caps = OrthoLayers.max_offset_heights(
+ frame.wall, normals,
+ spline_data=spline_data, contour_slice=contour_slice,
+ )
+ ortho_rows = OrthoLayers.build_layers(
+ frame.wall, normals, settings.ortho_layers,
+ settings.first_layer_thickness, settings.ortho_growth, caps,
+ )
+
+ rim = ortho_rows[-1]
+ next_spacing = (settings.first_layer_thickness *
+ settings.ortho_growth ** settings.ortho_layers)
+ remaining_count = (settings.normal_divisions -
+ settings.ortho_layers + 1)
+ side_start = side_segment(rim[0], frame.outer[0],
+ next_spacing, remaining_count)
+ if frame.periodic:
+ side_end = side_start.copy()
+ else:
+ side_end = side_segment(rim[-1], frame.outer[-1],
+ next_spacing, remaining_count)
+ reduced = type(frame)(
+ wall=rim, outer=frame.outer,
+ side_start=side_start, side_end=side_end,
+ kind=frame.kind, te_type=frame.te_type,
+ periodic=frame.periodic, metadata=dict(frame.metadata),
+ )
+ # The rim is no longer the exact contour, so the reduced-frame
+ # fill never uses spline_data/contour_slice for its normals
+ # (hyperbolic) — only finite differences on the rim polyline,
+ # per the design spec's "rim normals from finite differences".
+ outer_rows = self._volume_fill(
+ reduced, settings, spline_data=None,
+ corner_indices=corner_indices)
+ return np.vstack((ortho_rows, outer_rows[1:]))
+
+ @staticmethod
+ def _corner_indices(frame):
+ if frame.kind == 'o':
+ return [0, len(frame.wall) - 1]
+ start, stop = frame.metadata['contour_slice']
+ return [start, stop - 1]
+
+ @staticmethod
+ def _reject_inverted(rows: np.ndarray, name: str):
+ jacobians = cell_jacobians(rows)
+ positive = int(np.sum(jacobians > 0.0))
+ negative = int(np.sum(jacobians < 0.0))
+ inverted = min(positive, negative) + int(np.sum(jacobians == 0.0))
+ if inverted:
+ raise ValueError(
+ f'Structured mesh block {name!r} has {inverted} inverted '
+ 'or degenerate cells; adjust layer heights or divisions.')
+
+ @staticmethod
+ def _emit_block(name: str, rows: np.ndarray) -> BlockMesh:
+ block = BlockMesh(name=name)
+ block.setUlines(BlockMesh.as_ulines(rows))
+ return block
diff --git a/src/StructuredTopologies.py b/src/StructuredTopologies.py
new file mode 100644
index 0000000..8c70be1
--- /dev/null
+++ b/src/StructuredTopologies.py
@@ -0,0 +1,364 @@
+"""Topology builders for the Structured engine: O-grid and C-mesh frames.
+
+Geometry conventions
+--------------------
+- Contour orientation: TE -> upper -> LE -> lower -> TE (counterclockwise).
+ Outer curves are sampled in the same rotational direction so TFI cells
+ stay positive.
+- Legacy tunnel: half-circle of radius ``tunnel_height`` centered at
+ ``(x_te, 0)`` upstream, horizontal walls ``y = +/- tunnel_height`` to the
+ outlet plane ``x_outlet = x_te + wake_length``. For O the loop is closed
+ by the vertical outlet segment.
+- Circular tunnel: circle of radius ``tunnel_height`` centered at mid-chord
+ ``(0.5, 0)``. For C it is truncated at the outlet plane.
+- C-mesh wall row: ``upper_cut[::-1][:-1] + contour + lower_cut[1:]`` with
+ each cut running TE corner -> outlet. Sharp TE: both cuts coincide
+ node-for-node (matched cut). Blunt TE: distinct horizontal cuts bounding
+ a constant-width wake strip meshed as a second frame.
+- O-grid wall row: the contour closed either exactly at the sharp TE or by
+ the blunt base nodes; the seam runs from the TE along the horizontal ray
+ (+x) to the outer curve.
+"""
+from __future__ import annotations
+
+import numpy as np
+
+from StructuredCore import (
+ GridFrame,
+ StructuredMeshSettings,
+ detect_te_type,
+ distribute_on_polyline,
+ geometric_distances,
+ sample_te_base,
+)
+
+ARC_SAMPLES = 360
+STRAIGHT_SAMPLES_PER_UNIT = 40
+
+
+def build_frames(contour, settings: StructuredMeshSettings):
+ contour = np.asarray(contour, dtype=float)
+ te_type = detect_te_type(contour)
+ if settings.topology == 'o':
+ return [_build_o_frame(contour, settings, te_type)]
+ if settings.topology == 'c':
+ return _build_c_frames(contour, settings, te_type)
+ raise ValueError(f'Unknown topology: {settings.topology!r}.')
+
+
+def validate_frame(frame: GridFrame, tolerance: float = 1.0e-9) -> GridFrame:
+ checks = (
+ (frame.side_start[0], frame.wall[0], 'side_start/wall'),
+ (frame.side_start[-1], frame.outer[0], 'side_start/outer'),
+ (frame.side_end[0], frame.wall[-1], 'side_end/wall'),
+ (frame.side_end[-1], frame.outer[-1], 'side_end/outer'),
+ )
+ for actual, expected, label in checks:
+ if np.linalg.norm(actual - expected) > tolerance:
+ raise ValueError(
+ f'Inconsistent grid frame: {label} corners do not '
+ f'coincide ({actual} vs {expected}).')
+ if len(frame.outer) != len(frame.wall):
+ raise ValueError('Grid frame wall and outer point counts differ.')
+ if len(frame.side_start) != len(frame.side_end):
+ raise ValueError('Grid frame side point counts differ.')
+ return frame
+
+
+def side_segment(p_wall: np.ndarray, p_outer: np.ndarray,
+ first_spacing: float, count: int) -> np.ndarray:
+ p_wall = np.asarray(p_wall, dtype=float)
+ p_outer = np.asarray(p_outer, dtype=float)
+ length = float(np.linalg.norm(p_outer - p_wall))
+ if length <= 0.0:
+ raise ValueError('Degenerate frame side (wall touches outer).')
+ distances = geometric_distances(length, first_spacing, count)
+ direction = (p_outer - p_wall) / length
+ return p_wall[None, :] + distances[:, None] * direction[None, :]
+
+
+def _polygon_centroid(loop: np.ndarray) -> np.ndarray:
+ """Area centroid of a closed polygon (first point == last point)."""
+ x, y = loop[:-1, 0], loop[:-1, 1]
+ x_next, y_next = loop[1:, 0], loop[1:, 1]
+ cross = x * y_next - x_next * y
+ area = 0.5 * np.sum(cross)
+ if abs(area) < 1.0e-12:
+ return loop[:-1].mean(axis=0)
+ cx = np.sum((x + x_next) * cross) / (6.0 * area)
+ cy = np.sum((y + y_next) * cross) / (6.0 * area)
+ return np.array([cx, cy])
+
+
+def _te_bisector(contour: np.ndarray) -> np.ndarray:
+ """Downstream direction bisecting the TE wedge exterior (horizontal
+ for symmetric airfoils, tilted by camber/reflex)."""
+ upper = contour[0] - contour[1]
+ lower = contour[-1] - contour[-2]
+ upper = upper / (np.linalg.norm(upper) or 1.0)
+ lower = lower / (np.linalg.norm(lower) or 1.0)
+ bisector = upper + lower
+ norm = np.linalg.norm(bisector)
+ if norm <= 1.0e-12 or bisector[0] <= 0.0:
+ return np.array([1.0, 0.0])
+ return bisector / norm
+
+
+def _curved_seam(te_point: np.ndarray, bisector: np.ndarray,
+ seam_target: np.ndarray, first_spacing: float,
+ count: int, control_fraction: float = 0.15) -> np.ndarray:
+ """O-grid seam line: quadratic Bezier leaving the TE along the wedge
+ bisector and bending onto the outer seam point. The bisector start
+ gives the near-wall cells on both sides of the seam equal room (a
+ straight seam folds cells at reflexed/cambered trailing edges)."""
+ seam_length = float(np.linalg.norm(seam_target - te_point))
+ control = te_point + bisector * control_fraction * seam_length
+ parameters = np.linspace(0.0, 1.0, 600)[:, None]
+ bezier = ((1.0 - parameters) ** 2 * te_point[None, :] +
+ 2.0 * (1.0 - parameters) * parameters * control[None, :] +
+ parameters ** 2 * seam_target[None, :])
+ from StructuredCore import polyline_cumulative, sample_polyline_at
+ distances = geometric_distances(
+ polyline_cumulative(bezier)[-1], first_spacing, count)
+ return sample_polyline_at(bezier, distances)
+
+
+def _te_spacing(contour: np.ndarray) -> float:
+ first = np.linalg.norm(contour[1] - contour[0])
+ last = np.linalg.norm(contour[-1] - contour[-2])
+ return 0.5 * (first + last)
+
+
+def _wake_cut(start_point: np.ndarray, x_outlet: float, spacing: float,
+ wake_points: int) -> np.ndarray:
+ length = x_outlet - start_point[0]
+ if length <= 0.0:
+ raise ValueError('Wake length must reach past the trailing edge.')
+ distances = geometric_distances(length, spacing, wake_points)
+ cut = np.column_stack((
+ start_point[0] + distances,
+ np.full(wake_points, start_point[1]),
+ ))
+ cut[0] = start_point # exact, no float drift
+ return cut
+
+
+def _dedupe(points: np.ndarray) -> np.ndarray:
+ keep = np.ones(len(points), dtype=bool)
+ keep[1:] = np.linalg.norm(np.diff(points, axis=0), axis=1) > 1.0e-12
+ return points[keep]
+
+
+def _straight(p_from, p_to) -> np.ndarray:
+ p_from = np.asarray(p_from, dtype=float)
+ p_to = np.asarray(p_to, dtype=float)
+ length = np.linalg.norm(p_to - p_from)
+ count = max(2, int(np.ceil(length * STRAIGHT_SAMPLES_PER_UNIT)))
+ fractions = np.linspace(0.0, 1.0, count)[:, None]
+ return p_from[None, :] * (1.0 - fractions) + p_to[None, :] * fractions
+
+
+def _arc(center, radius, theta_from, theta_to) -> np.ndarray:
+ theta = np.linspace(theta_from, theta_to, ARC_SAMPLES)
+ return np.column_stack((center[0] + radius * np.cos(theta),
+ center[1] + radius * np.sin(theta)))
+
+
+def tunnel_outline(settings: StructuredMeshSettings, te_point: np.ndarray,
+ te_type: str,
+ seam_direction: np.ndarray | None = None) -> np.ndarray:
+ """Dense outer-curve polyline: open for C, closed (CCW) for O.
+
+ For O topology the loop starts/ends at the seam point where the ray
+ from the TE along ``seam_direction`` (default +x) meets the outer
+ shape.
+ """
+ height = settings.tunnel_height
+ x_te = float(te_point[0])
+ x_outlet = x_te + settings.wake_length
+
+ if settings.topology == 'c':
+ if settings.tunnel_shape == 'legacy':
+ pieces = (
+ _straight((x_outlet, height), (x_te, height)),
+ _arc((x_te, 0.0), height, 0.5 * np.pi, 1.5 * np.pi),
+ _straight((x_te, -height), (x_outlet, -height)),
+ )
+ elif settings.tunnel_shape == 'circular':
+ offset = x_outlet - 0.5
+ if offset >= 0.95 * height:
+ raise ValueError(
+ 'Wake length reaches past the circular farfield; '
+ 'increase the radius or shorten the wake.')
+ y_cut = np.sqrt(height ** 2 - offset ** 2)
+ theta_outlet = np.arctan2(y_cut, offset)
+ pieces = (
+ _arc((0.5, 0.0), height, theta_outlet,
+ 2.0 * np.pi - theta_outlet),
+ )
+ else:
+ raise ValueError(
+ f'Unknown tunnel shape: {settings.tunnel_shape!r}.')
+ return _dedupe(np.vstack(pieces))
+
+ # O topology: closed loop starting/ending at the seam point where the
+ # TE ray meets the outer shape, sampled counterclockwise.
+ direction = np.array([1.0, 0.0]) if seam_direction is None \
+ else np.asarray(seam_direction, dtype=float)
+ norm = np.linalg.norm(direction)
+ if norm <= 0.0 or direction[0] <= 0.0:
+ direction = np.array([1.0, 0.0])
+ else:
+ direction = direction / norm
+
+ if settings.tunnel_shape == 'legacy':
+ # intersect the ray with the outlet plane x = x_outlet
+ travel = (x_outlet - x_te) / direction[0]
+ y_seam = float(te_point[1]) + travel * direction[1]
+ if abs(y_seam) >= height:
+ raise ValueError('Tunnel height must exceed the TE ray offset '
+ 'at the outlet.')
+ pieces = (
+ _straight((x_outlet, y_seam), (x_outlet, height)),
+ _straight((x_outlet, height), (x_te, height)),
+ _arc((x_te, 0.0), height, 0.5 * np.pi, 1.5 * np.pi),
+ _straight((x_te, -height), (x_outlet, -height)),
+ _straight((x_outlet, -height), (x_outlet, y_seam)),
+ )
+ elif settings.tunnel_shape == 'circular':
+ # intersect the ray with the circle around mid-chord
+ center = np.array([0.5, 0.0])
+ offset = np.asarray(te_point, dtype=float) - center
+ b = float(np.dot(offset, direction))
+ c = float(np.dot(offset, offset)) - height ** 2
+ discriminant = b * b - c
+ if discriminant <= 0.0 or c >= 0.0:
+ raise ValueError('Farfield radius must enclose the airfoil.')
+ travel = -b + np.sqrt(discriminant)
+ seam_point = np.asarray(te_point, dtype=float) + travel * direction
+ theta_seam = float(np.arctan2(seam_point[1] - center[1],
+ seam_point[0] - center[0]))
+ pieces = (
+ _arc(center, height, theta_seam, theta_seam + 2.0 * np.pi),
+ )
+ else:
+ raise ValueError(
+ f'Unknown tunnel shape: {settings.tunnel_shape!r}.')
+ return _dedupe(np.vstack(pieces))
+
+
+def _distribute_outer(outline: np.ndarray, count: int,
+ settings: StructuredMeshSettings,
+ closed: bool) -> np.ndarray:
+ control = settings.boundary_control
+ if control.distribution == 'uniform' or control.clustering_ratio == 1.0:
+ outer = distribute_on_polyline(outline, count, 'uniform')
+ else:
+ # Cluster toward the outlet ends: split at the arclength midpoint
+ # (upstream apex for C, seam-opposite point for O), cluster each
+ # half toward its outlet/seam end.
+ half_count = count // 2 + 1
+ rest_count = count - half_count + 1
+ from StructuredCore import polyline_cumulative, sample_polyline_at
+ cumulative = polyline_cumulative(outline)
+ split_distance = 0.5 * cumulative[-1]
+ split_index = int(np.searchsorted(cumulative, split_distance))
+ split_point = sample_polyline_at(
+ outline, np.array([split_distance]))[0]
+ first_half = np.vstack((outline[:split_index], split_point[None, :]))
+ second_half = np.vstack((split_point[None, :],
+ outline[split_index:]))
+ first = distribute_on_polyline(
+ _dedupe(first_half), half_count, 'clustered',
+ control.clustering_ratio)
+ second = distribute_on_polyline(
+ _dedupe(second_half)[::-1], rest_count, 'clustered',
+ control.clustering_ratio)[::-1]
+ outer = np.vstack((first, second[1:]))
+ if closed:
+ outer[-1] = outer[0]
+ return outer
+
+
+def _build_c_frames(contour: np.ndarray, settings: StructuredMeshSettings,
+ te_type: str):
+ te_point = 0.5 * (contour[0] + contour[-1])
+ x_outlet = float(te_point[0]) + settings.wake_length
+ spacing = _te_spacing(contour)
+
+ if te_type == 'sharp':
+ cut = _wake_cut(te_point, x_outlet, spacing, settings.wake_points)
+ upper_cut = cut
+ lower_cut = cut
+ else:
+ upper_cut = _wake_cut(contour[0], x_outlet, spacing,
+ settings.wake_points)
+ lower_cut = _wake_cut(contour[-1], x_outlet, spacing,
+ settings.wake_points)
+
+ wall = np.vstack((upper_cut[::-1][:-1], contour, lower_cut[1:]))
+ outline = tunnel_outline(settings, te_point, te_type)
+ outer = _distribute_outer(outline, len(wall), settings, closed=False)
+ side_count = settings.normal_divisions + 1
+ side_start = side_segment(wall[0], outer[0],
+ settings.first_layer_thickness, side_count)
+ side_end = side_segment(wall[-1], outer[-1],
+ settings.first_layer_thickness, side_count)
+ contour_start = len(upper_cut) - 1
+ main = GridFrame(
+ wall=wall, outer=outer,
+ side_start=side_start, side_end=side_end,
+ kind='c', te_type=te_type,
+ metadata={
+ 'wake_cut_matched': te_type == 'sharp',
+ 'x_outlet': x_outlet,
+ 'contour_slice': (contour_start, contour_start + len(contour)),
+ },
+ )
+ validate_frame(main)
+ if te_type == 'sharp':
+ return [main]
+
+ base = sample_te_base(contour[-1], contour[0], spacing)
+ outlet_segment = np.column_stack((
+ np.full(len(base), x_outlet),
+ np.linspace(lower_cut[-1][1], upper_cut[-1][1], len(base)),
+ ))
+ strip = GridFrame(
+ wall=lower_cut, outer=upper_cut,
+ side_start=base, side_end=outlet_segment,
+ kind='wake_strip', te_type='blunt',
+ metadata={'base': base, 'x_outlet': x_outlet},
+ )
+ validate_frame(strip)
+ return [main, strip]
+
+
+def _build_o_frame(contour: np.ndarray, settings: StructuredMeshSettings,
+ te_type: str) -> GridFrame:
+ spacing = _te_spacing(contour)
+ if te_type == 'sharp':
+ wall = contour.copy()
+ wall[-1] = wall[0] # exact loop closure
+ contour_slice = (0, len(wall))
+ else:
+ base = sample_te_base(contour[-1], contour[0], spacing)
+ wall = np.vstack((contour, base[1:]))
+ contour_slice = (0, len(contour))
+
+ te_point = wall[0]
+ center = _polygon_centroid(wall)
+ seam_direction = te_point - center
+ outline = tunnel_outline(settings, te_point, te_type,
+ seam_direction=seam_direction)
+ outer = _distribute_outer(outline, len(wall), settings, closed=True)
+ seam = _curved_seam(te_point, _te_bisector(contour), outer[0],
+ settings.first_layer_thickness,
+ settings.normal_divisions + 1)
+ frame = GridFrame(
+ wall=wall, outer=outer,
+ side_start=seam, side_end=seam.copy(),
+ kind='o', te_type=te_type, periodic=True,
+ metadata={'contour_slice': contour_slice},
+ )
+ return validate_frame(frame)
diff --git a/src/SvpMethod.py b/src/SvpMethod.py
deleted file mode 100644
index b0a1e7f..0000000
--- a/src/SvpMethod.py
+++ /dev/null
@@ -1,389 +0,0 @@
-# -*- coding: utf-8 -*-
-
-"""
-Source-vortex panel method
-http://nbviewer.ipython.org/github/barbagroup/AeroPython/blob/master/
-lessons/11_Lesson11_vortexSourcePanelMethod.ipynb
-
-Code under MIT license. (c)2014 Lorena A. Barba, Olivier Mesnard.
-"""
-
-import os
-import math
-
-import numpy
-from scipy import integrate
-
-from PySide6 import QtCore
-
-import logging
-logger = logging.getLogger(__name__)
-
-
-class Panel:
- """Contains information related to one panel."""
- def __init__(self, xa, ya, xb, yb):
- """Creates a panel.
-
- Arguments
- ---------
- xa, ya -- Cartesian coordinates of the first end-point.
- xb, yb -- Cartesian coordinates of the second end-point.
- """
- self.xa, self.ya = xa, ya
- self.xb, self.yb = xb, yb
-
- self.xc, self.yc = (xa+xb)/2, (ya+yb)/2 # control-point (center-point)
- self.length = math.sqrt((xb-xa)**2+(yb-ya)**2) # length of the panel
-
- # orientation of the panel (angle between x-axis and panel's normal)
- if xb-xa <= 0.:
- self.beta = math.acos((yb-ya)/self.length)
- elif xb-xa > 0.:
- self.beta = math.pi + math.acos(-(yb-ya)/self.length)
-
- # location of the panel
- if self.beta <= math.pi:
- self.loc = 'upper'
- else:
- self.loc = 'lower'
-
- self.sigma = 0. # source strength
- self.vt = 0. # tangential velocity
- self.cp = 0. # pressure coefficient
-
-
-class Freestream:
- """Freestream conditions."""
- def __init__(self, u_inf=1.0, alpha=0.0):
- """Sets the freestream conditions.
-
- Arguments
- ---------
- u_inf -- Farfield speed (default 1.0).
- alpha -- Angle of attack in degrees (default 0.0).
- """
- self.u_inf = u_inf
- self.alpha = alpha*math.pi/180 # degrees --> radians
-
-#
-# functions
-#
-
-
-def define_panels(x, y, N=40):
- """Discretizes the geometry into panels using 'cosine' method.
-
- Arguments
- ---------
- x, y -- Cartesian coordinates of the geometry (1D arrays).
- N - number of panels (default 40).
-
- Returns
- -------
- panels -- Numpy array of panels.
- """
- R = (x.max()-x.min())/2 # radius of the circle
- x_center = (x.max()+x.min())/2 # x-coord of the center
- x_circle = x_center + R*numpy.cos(numpy.linspace(0, 2*math.pi, N+1)) # x-coord of the circle points
-
- x_ends = numpy.copy(x_circle) # projection of the x-coord on the surface
- y_ends = numpy.empty_like(x_ends) # initialization of the y-coord Numpy array
-
- x, y = numpy.append(x, x[0]), numpy.append(y, y[0]) # extend arrays using numpy.append
-
- # computes the y-coordinate of end-points
- I = 0
- for i in range(N):
- while I < len(x)-1:
- if (x[I] <= x_ends[i] <= x[I+1]) or (x[I+1] <= x_ends[i] <= x[I]):
- break
- else:
- I += 1
-
- a = (y[I+1]-y[I])/(x[I+1]-x[I])
- b = y[I+1] - a*x[I+1]
- y_ends[i] = a*x_ends[i] + b
- y_ends[N] = y_ends[0]
-
- panels = numpy.empty(N, dtype=object)
- for i in range(N):
- panels[i] = Panel(x_ends[i], y_ends[i], x_ends[i+1], y_ends[i+1])
-
- return panels
-
-
-def integral(x, y, panel, dxdz, dydz):
- """Evaluates the contribution of a panel at one point.
-
- Arguments
- ---------
- x, y -- Cartesian coordinates of the point.
- panel -- panel which contribution is evaluated.
- dxdz -- derivative of x in the z-direction.
- dydz -- derivative of y in the z-direction.
-
- Returns
- -------
- Integral over the panel of the influence at one point.
- """
- def func(s):
- return (((x - (panel.xa - math.sin(panel.beta)*s))*dxdz
- + (y - (panel.ya + math.cos(panel.beta)*s))*dydz)
- / ((x - (panel.xa - math.sin(panel.beta)*s))**2
- + (y - (panel.ya + math.cos(panel.beta)*s))**2))
- return integrate.quad(lambda s: func(s), 0., panel.length)[0]
-
-
-def source_matrix(panels):
- """Builds the source matrix.
-
- Arguments
- ---------
- panels -- array of panels.
-
- Returns
- -------
- A -- NxN matrix (N is the number of panels).
- """
- N = len(panels)
- A = numpy.empty((N, N), dtype=float)
- numpy.fill_diagonal(A, 0.5)
-
- for i, p_i in enumerate(panels):
- for j, p_j in enumerate(panels):
- if i != j:
- A[i,j] = 0.5/math.pi*integral(p_i.xc, p_i.yc, p_j, math.cos(p_i.beta), math.sin(p_i.beta))
-
- return A
-
-
-def vortex_array(panels):
- """Builds the vortex array.
-
- Arguments
- ---------
- panels - array of panels.
-
- Returns
- -------
- a -- 1D array (Nx1, N is the number of panels).
- """
- a = numpy.zeros(len(panels), dtype=float)
-
- for i, p_i in enumerate(panels):
- for j, p_j in enumerate(panels):
- if i != j:
- a[i] -= 0.5/math.pi*integral(p_i.xc, p_i.yc, p_j, +math.sin(p_i.beta), -math.cos(p_i.beta))
-
- return a
-
-
-def kutta_array(panels):
- """Builds the Kutta-condition array.
-
- Arguments
- ---------
- panels -- array of panels.
-
- Returns
- -------
- a -- 1D array (Nx1, N is the number of panels).
- """
- N = len(panels)
- a = numpy.zeros(N+1, dtype=float)
-
- a[0] = 0.5/math.pi*integral(panels[N-1].xc, panels[N-1].yc, panels[0],
- -math.sin(panels[N-1].beta), +math.cos(panels[N-1].beta))
- a[N-1] = 0.5/math.pi*integral(panels[0].xc, panels[0].yc, panels[N-1],
- -math.sin(panels[0].beta), +math.cos(panels[0].beta))
-
- for i, panel in enumerate(panels[1:N-1]):
- a[i] = 0.5/math.pi*(integral(panels[0].xc, panels[0].yc, panel,
- -math.sin(panels[0].beta), +math.cos(panels[0].beta))
- + integral(panels[N-1].xc, panels[N-1].yc, panel,
- -math.sin(panels[N-1].beta), +math.cos(panels[N-1].beta)) )
-
- a[N] -= 0.5/math.pi*(integral(panels[0].xc, panels[0].yc, panel,
- +math.cos(panels[0].beta), +math.sin(panels[0].beta))
- + integral(panels[N-1].xc, panels[N-1].yc, panel,
- +math.cos(panels[N-1].beta), +math.sin(panels[N-1].beta)) )
-
- return a
-
-
-def build_matrix(panels):
- """Builds the matrix of the linear system.
-
- Arguments
- ---------
- panels -- array of panels.
-
- Returns
- -------
- A -- (N+1)x(N+1) matrix (N is the number of panels).
- """
- N = len(panels)
- A = numpy.empty((N+1, N+1), dtype=float)
-
- AS = source_matrix(panels)
- av = vortex_array(panels)
- ak = kutta_array(panels)
-
- A[0:N,0:N], A[0:N,N], A[N,:] = AS[:,:], av[:], ak[:]
-
- return A
-
-
-def build_rhs(panels, freestream):
- """Builds the RHS of the linear system.
-
- Arguments
- ---------
- panels -- array of panels.
- freestream -- farfield conditions.
-
- Returns
- -------
- b -- 1D array ((N+1)x1, N is the number of panels).
- """
- N = len(panels)
- b = numpy.empty(N+1,dtype=float)
-
- for i, panel in enumerate(panels):
- b[i] = - freestream.u_inf * math.cos(freestream.alpha - panel.beta)
- b[N] = -freestream.u_inf*( math.sin(freestream.alpha-panels[0].beta)
- +math.sin(freestream.alpha-panels[N-1].beta) )
-
- return b
-
-
-def get_tangential_velocity(panels, freestream, gamma):
- """Computes the tangential velocity on the surface.
-
- Arguments
- ---------
- panels -- array of panels.
- freestream -- farfield conditions.
- gamma -- circulation density.
- """
- N = len(panels)
- A = numpy.empty((N, N+1), dtype=float)
- numpy.fill_diagonal(A, 0.0)
-
- for i, p_i in enumerate(panels):
- for j, p_j in enumerate(panels):
- if i != j:
- A[i,j] = 0.5/math.pi*integral(p_i.xc, p_i.yc, p_j, -math.sin(p_i.beta), +math.cos(p_i.beta))
- A[i,N] -= 0.5/math.pi*integral(p_i.xc, p_i.yc, p_j, +math.cos(p_i.beta), +math.sin(p_i.beta))
-
- b = freestream.u_inf * numpy.sin([freestream.alpha - panel.beta for panel in panels])
-
- var = numpy.append([panel.sigma for panel in panels], gamma)
-
- vt = numpy.dot(A, var) + b
- for i, panel in enumerate(panels):
- panel.vt = vt[i]
-
-
-def get_velocity_field(panels, freestream, X, Y):
- """Returns the velocity field.
-
- Arguments
- ---------
- panels -- array of panels.
- freestream -- farfield conditions.
- X, Y -- mesh grid.
- """
- Nx, Ny = X.shape
- u, v = numpy.empty((Nx, Ny), dtype=float), numpy.empty((Nx, Ny), dtype=float)
-
- for i in range(Nx):
- for j in range(Ny):
- u[i,j] = freestream.u_inf*math.cos(freestream.alpha)\
- + 0.5/math.pi*sum([p.sigma*integral(X[i,j], Y[i,j], p, 1, 0) for p in panels])
- v[i,j] = freestream.u_inf*math.sin(freestream.alpha)\
- + 0.5/math.pi*sum([p.sigma*integral(X[i,j], Y[i,j], p, 0, 1) for p in panels])
-
- return u, v
-
-
-def get_pressure_field(u, v, freestream):
- cp = 1.0 - (u**2+v**2)/freestream.u_inf**2
- return cp
-
-
-def get_meshgrid(panels, Nx, Ny, val_x, val_y):
- # defines a mesh grid
- # Nx, Ny = 20, 20
- # val_x, val_y = 1.0, 2.0
- x_min, x_max = min( panel.xa for panel in panels ), max( panel.xa for panel in panels )
- y_min, y_max = min( panel.ya for panel in panels ), max( panel.ya for panel in panels )
- x_start, x_end = x_min-val_x*(x_max-x_min), x_max+val_x*(x_max-x_min)
- y_start, y_end = y_min-val_y*(y_max-y_min), y_max+val_y*(y_max-y_min)
-
- X, Y = numpy.meshgrid(numpy.linspace(x_start, x_end, Nx), numpy.linspace(y_start, y_end, Ny))
-
- return X, Y
-
-
-def get_pressure_coefficient(panels, freestream):
- """Computes the surface pressure coefficients.
-
- Arguments
- ---------
- panels -- array of panels.
- freestream -- farfield conditions.
- """
- for panel in panels:
- panel.cp = 1.0 - (panel.vt/freestream.u_inf)**2
-
-
-def runSVP(name, x, y, u_inf, alpha, npanel=40):
-
- x = numpy.array(x)
- y = numpy.array(y)
-
- panels = define_panels(x, y, npanel) # discretizes of the geometry into panels
-
- # plots the geometry and the panels
- val_x, val_y = 0.1, 0.2
- x_min, x_max = min( panel.xa for panel in panels ), max( panel.xa for panel in panels )
- #y_min, y_max = min( panel.ya for panel in panels ), max( panel.ya for panel in panels )
- #x_start, x_end = x_min-val_x*(x_max-x_min), x_max+val_x*(x_max-x_min)
- #y_start, y_end = y_min-val_y*(y_max-y_min), y_max+val_y*(y_max-y_min)
-
- # defines and creates the object freestream
- freestream = Freestream(u_inf, alpha)
-
- # calculates the singularity matrix
- A = build_matrix(panels)
- # calculates the freestream RHS
- b = build_rhs(panels, freestream)
-
- # solves the linear system
- variables = numpy.linalg.solve(A, b)
-
- for i, panel in enumerate(panels):
- panel.sigma = variables[i]
- gamma = variables[-1]
-
- # time = QtCore.QTime()
- # time.start()
- # computes the tangential velocity at each panel center.
- get_tangential_velocity(panels, freestream, gamma)
-
- # computes surface pressure coefficient
- get_pressure_coefficient(panels, freestream)
-
- # calculates the accuracy
- accuracy = sum([panel.sigma*panel.length for panel in panels])
- logger.debug('Accuracy (sum of source/sink strengths) = {}'.format(accuracy))
-
- # calculates of the lift
- cl = gamma*sum(panel.length for panel in panels) / \
- (0.5*freestream.u_inf*(x_max-x_min))
-
- logger.info(f'Aerodynamic properties of {os.path.splitext(name)[0]}:')
- logger.info(f'Cl = {cl:6.3f} [-] at Uinf = {u_inf:6.3f} [m/s], '
- f'and AOA = {alpha:6.3f} [degree]')
diff --git a/src/ToolBox.py b/src/ToolBox.py
index 07fe226..80660ee 100644
--- a/src/ToolBox.py
+++ b/src/ToolBox.py
@@ -1,1213 +1,2037 @@
-# -*- coding: utf-8 -*-
-
-import os
-import numpy as np
-
-from PySide6 import QtGui, QtCore, QtWidgets
-
-import PyAero
-import Airfoil
-import FileDialog
-import FileSystem
-import SvpMethod
-import SplineRefine
-import TrailingEdge
-import Meshing
-import ContourAnalysis as ca
-from Settings import ICONS_L
-
-import logging
-logger = logging.getLogger(__name__)
-
-
-class Toolbox(QtWidgets.QToolBox):
-
- def __init__(self, parent):
- """Main menus for PyAero functionality.
- Inserted in left pane of splitter window which in turn is the app's
- CentralWidget.
-
- Args:
- parent (QWidget): MainWindow from PyAero.py
- """
- super().__init__()
-
- self.parent = parent
-
- # set the style (css)
- style = """
- QToolBox::tab {
- border: 3px;
- background-color: #DDDDDD;
- color: black;
- }
- QToolBox::tab:pressed {
- background-color: #CCCCCD;
- }
- QToolBox::tab:selected {
- font: bold;
- }
- """
- self.setStyleSheet(style)
-
- # create toolbox items
- self.itemFileSystem()
- self.itemAeropython()
- self.itemBoundaryCondtions()
- self.itemContourAnalysis()
- self.itemSplineRefine()
- self.itemMeshing()
-
- self.makeToolbox()
-
- self.currentChanged.connect(self.toolboxChanged)
-
- def toolboxChanged(self):
- # tb1 = 'Airfoil Database'
- # tb2 = 'Contour Splining and Refinement'
- # tb4 = 'Meshing'
- # tb6 = 'Aerodynamics Boundary Conditions'
- # tb5 = 'Aerodynamics (Panel Code)'
- # tb3 = 'Contour Analysis'
-
- if self.currentIndex() == self.tb1:
- self.parent.centralwidget.tabs.setCurrentIndex(0)
-
- if self.currentIndex() == self.tb3:
- self.parent.centralwidget.tabs.setCurrentIndex(1)
-
- # update points on airfoil when toolbox changed to meshing
- if self.currentIndex() == self.tb4 and self.parent.airfoil:
- pts = len(self.parent.airfoil.spline_data[0][0])
- self.points_on_airfoil.setText(str(pts))
-
- def itemFileSystem(self):
-
- self.item_fs = QtWidgets.QWidget()
- layout = QtWidgets.QVBoxLayout()
- self.item_fs.setLayout(layout)
-
- # instance of QFileSystemModel
- filesystem_model = FileSystem.FileSystemModel()
- root_path = filesystem_model.rootPath()
-
- self.tree = QtWidgets.QTreeView()
- self.tree.setModel(filesystem_model)
- self.tree.setRootIndex(filesystem_model.index(root_path))
- self.tree.setAnimated(True)
-
- # hide size column
- self.tree.setColumnHidden(1, True)
- # hide type column
- self.tree.setColumnHidden(2, True)
- # hide date modified column
- self.tree.setColumnHidden(3, True)
-
- # hide the header line of the filesystem tree
- # the header line would consist of name, date, type, size
- # the latter three are hidden anyway (see above)
- header = self.tree.header()
- header.hide()
-
- # handler
- self.tree.clicked.connect(filesystem_model.onFileSelected)
- self.tree.doubleClicked.connect(filesystem_model.onFileLoad)
-
- layout.addWidget(self.tree, stretch=12)
- # layout.setAlignment(QtCore.Qt.AlignTop)
-
- self.header = QtWidgets.QLabel('Loaded airfoil(s)')
- self.header.setEnabled(False)
- layout.addStretch(stretch=2)
- layout.addWidget(self.header)
-
- self.listwidget = ListWidget(self.parent)
- self.listwidget.setEnabled(False)
- # allow only single selections
- self.listwidget.setSelectionMode(QtWidgets.QAbstractItemView.
- SingleSelection)
- layout.addWidget(self.listwidget, stretch=5)
- layout.addStretch(stretch=1)
-
- def itemAeropython(self):
-
- form = QtWidgets.QFormLayout()
-
- label1 = QtWidgets.QLabel(u'Angle of attack (°)')
- self.aoaAP = QtWidgets.QDoubleSpinBox()
- self.aoaAP.setSingleStep(0.1)
- self.aoaAP.setDecimals(1)
- self.aoaAP.setRange(-10.0, 10.0)
- self.aoaAP.setValue(0.0)
- form.addRow(label1, self.aoaAP)
-
- label2 = QtWidgets.QLabel('Freestream velocity (m/s)')
- self.freestream = QtWidgets.QDoubleSpinBox()
- self.freestream.setSingleStep(0.1)
- self.freestream.setDecimals(2)
- self.freestream.setRange(0.0, 100.0)
- self.freestream.setValue(10.0)
- form.addRow(label2, self.freestream)
-
- label3 = QtWidgets.QLabel('Number of panels (-)')
- self.panels = QtWidgets.QSpinBox()
- self.panels.setRange(10, 500)
- self.panels.setValue(40)
- form.addRow(label3, self.panels)
-
- panelMethodButton = QtWidgets.QPushButton('Calculate lift coefficient')
- form.addRow(panelMethodButton)
-
- self.item_ap = QtWidgets.QGroupBox('AeroPython Panel Method')
- self.item_ap.setLayout(form)
-
- panelMethodButton.clicked.connect(self.runPanelMethod)
-
- def itemBoundaryCondtions(self):
-
- form = QtWidgets.QFormLayout()
-
- label = QtWidgets.QLabel(u'Reynolds Number (-)')
- self.reynolds = QtWidgets.QDoubleSpinBox()
- self.reynolds.setSingleStep(10000.0)
- self.reynolds.setDecimals(2)
- self.reynolds.setRange(0.0, 1.0e10)
- self.reynolds.setValue(100000.0)
- self.reynolds.valueChanged.connect(self.valuechange)
- form.addRow(label, self.reynolds)
-
- label = QtWidgets.QLabel(u'Chord Length (m)')
- self.chord = QtWidgets.QDoubleSpinBox()
- self.chord.setSingleStep(0.01)
- self.chord.setDecimals(2)
- self.chord.setRange(0.0, 1.0e10)
- self.chord.setValue(1.0)
- self.chord.valueChanged.connect(self.valuechange)
- form.addRow(label, self.chord)
-
- # angle of attack (from, to, step)
- # from
- label = QtWidgets.QLabel(u'Angle of Attack (from) (°)')
- self.aoaf = QtWidgets.QDoubleSpinBox()
- self.aoaf.setSingleStep(0.1)
- self.aoaf.setDecimals(2)
- self.aoaf.setRange(-90.0, 90.0)
- self.aoaf.setValue(-10.0)
- form.addRow(label, self.aoaf)
- # to
- label = QtWidgets.QLabel(u'Angle of Attack (to) (°)')
- self.aoat = QtWidgets.QDoubleSpinBox()
- self.aoat.setSingleStep(0.1)
- self.aoat.setDecimals(2)
- self.aoat.setRange(-90.0, 90.0)
- self.aoat.setValue(10.0)
- form.addRow(label, self.aoat)
- # step
- label = QtWidgets.QLabel(u'Angle of Attack (step) (°)')
- self.aoas = QtWidgets.QDoubleSpinBox()
- self.aoas.setSingleStep(0.1)
- self.aoas.setDecimals(2)
- self.aoas.setRange(0.0, 90.0)
- self.aoas.setValue(1.0)
- form.addRow(label, self.aoas)
-
- self.aoaf.valueChanged.connect(self.valuechange)
- self.aoat.valueChanged.connect(self.valuechange)
- self.aoas.valueChanged.connect(self.valuechange)
-
- label = QtWidgets.QLabel(u'Freestream Turbulence Intensity (%)')
- self.turbulence = QtWidgets.QDoubleSpinBox()
- self.turbulence.setSingleStep(0.1)
- self.turbulence.setDecimals(2)
- self.turbulence.setRange(0.0, 100.0)
- self.turbulence.setValue(2.0)
- self.turbulence.valueChanged.connect(self.valuechange)
- form.addRow(label, self.turbulence)
-
- label = QtWidgets.QLabel(u'Freestream Length Scale (m)')
- self.length_sc = QtWidgets.QDoubleSpinBox()
- self.length_sc.setSingleStep(0.01)
- self.length_sc.setDecimals(3)
- self.length_sc.setRange(1.e-6, 1.0e10)
- self.length_sc.setValue(0.05)
- self.length_sc.valueChanged.connect(self.valuechange)
- form.addRow(label, self.length_sc)
-
- label = QtWidgets.QLabel(u'Pressure (Pa)')
- self.pressure = QtWidgets.QDoubleSpinBox()
- self.pressure.setSingleStep(1000.0)
- self.pressure.setDecimals(2)
- self.pressure.setRange(0.0, 1.0e10)
- self.pressure.setValue(101325.0)
- self.pressure.valueChanged.connect(self.valuechange)
- form.addRow(label, self.pressure)
-
- label = QtWidgets.QLabel(u'Temperature (°C)')
- self.temperature = QtWidgets.QDoubleSpinBox()
- self.temperature.setSingleStep(1.0)
- self.temperature.setDecimals(2)
- self.temperature.setRange(-273.15, 1.0e10)
- self.temperature.setValue(20.0)
- self.temperature.valueChanged.connect(self.valuechange)
- form.addRow(label, self.temperature)
-
- label = QtWidgets.QLabel(u'Flat plate y\u207a (-)')
- self.yplus = QtWidgets.QDoubleSpinBox()
- self.yplus.setSingleStep(1.0)
- self.yplus.setDecimals(2)
- self.yplus.setRange(1e-6, 1.0e10)
- self.yplus.setValue(30.0)
- self.yplus.valueChanged.connect(self.valuechange)
- form.addRow(label, self.yplus)
-
- self.textedit = QtWidgets.QTextEdit()
- self.textedit.setReadOnly(True)
- self.textedit.selectionChanged.connect(self.copy_to_clipboard)
- # update text box (so everything is computed from initial values)
- self.valuechange()
-
- copy_button = QtWidgets.QPushButton('Copy to clipboard')
- copy_button.setGeometry(10, 10, 200, 50)
- copy_button.clicked.connect(self.copy_all_to_clipboard)
-
- text_and_button = QtWidgets.QHBoxLayout()
- text_and_button.addWidget(self.textedit, stretch=10)
- text_and_button.addWidget(copy_button)
-
- form.addRow(text_and_button)
-
- self.item_abc = QtWidgets.QGroupBox(
- 'Aerodynamic boundary conditions for CFD')
- self.item_abc.setLayout(form)
-
- def copy_to_clipboard(self):
- """ Copy any selected text in the self.textedit to the clipboard """
- self.textedit.copy()
-
- def copy_all_to_clipboard(self):
- """ Copy any selected text in the self.textedit to the clipboard """
- self.textedit.selectAll()
- self.textedit.copy()
- # weird way to unselect the text again
- # https://stackoverflow.com/a/25348576/2264936
- cursor = self.textedit.textCursor()
- cursor.clearSelection()
- self.textedit.setTextCursor(cursor)
- vsb = self.textedit.verticalScrollBar()
- vsb.setValue(QtWidgets.QAbstractSlider.SliderToMaximum)
-
- def valuechange(self):
- # checks that from and to do not overlap
- if self.aoaf.value() >= self.aoat.value():
- self.aoaf.setValue(self.aoat.value() - self.aoas.value())
- if self.aoat.value() <= self.aoaf.value():
- self.aoat.setValue(self.aoaf.value() + self.aoas.value())
-
- gas_constant = 287.14
- temperature = self.temperature.value() + 273.15
- self.density = self.pressure.value() / gas_constant / temperature
- num = int((self.aoat.value() - self.aoaf.value()) / self.aoas.value() + 1)
- self.aoa = np.linspace(self.aoaf.value(), self.aoat.value(),
- num=num, endpoint=True)
-
- def dynamic_viscosity(temperature):
- # Sutherland formula for air
- C = 120.0
- lamb = 1.512041288e-6
- vis = lamb * temperature**1.5 / (temperature + C)
- return vis
-
- # calculate results wrt given inputs
- self.dynamic_viscosity = dynamic_viscosity(temperature)
- self.kinematic_viscosity = self.dynamic_viscosity / self.density
- velocity = self.reynolds.value() / self.chord.value() * \
- self.kinematic_viscosity
- uprime = velocity * self.turbulence.value() / 100.0
- tke = 3.0 / 2.0 * uprime**2
- self.u_velocity = velocity * np.cos(self.aoa * np.pi / 180.0)
- self.v_velocity = velocity * np.sin(self.aoa * np.pi / 180.0)
-
- # calculate 1st cell thickness from y-plus and Reynolds, etc.
- RE = self.reynolds.value()
- log10 = np.log10(RE)
- logRE = np.power(log10, 2.58)
- if RE < 5.1e6:
- friction_coefficient = 0.455 / logRE
- else:
- friction_coefficient = 0.455 / logRE - 1700.0 / RE
- wall_shear_stress = friction_coefficient * 0.5 * self.density * velocity**2
- friction_velocity = np.sqrt(wall_shear_stress / self.density)
- wall_distance = self.yplus.value() * self.dynamic_viscosity / self.density / friction_velocity
-
- # text for displaying the results
- newline = ' '
- self.te_text = 'CFD Boundary Conditions ' + newline
- self.te_text += f'Reynolds (-): {self.reynolds.value()}' + newline
- self.te_text += f'Pressure (Pa): {self.pressure.value()}' + newline
- self.te_text += f'Temperature (C): {self.temperature.value()}' + newline
- self.te_text += f'Temperature (K): {self.temperature.value()+273.15}' + newline
- self.te_text += f'Density (kg/(m3 )): {self.density}' + newline
- self.te_text += f'Dynamic viscosity (kg/(m.s)): {self.dynamic_viscosity}' + newline
- self.te_text += f'Kinematic viscosity (m/s) {self.kinematic_viscosity}:' + newline
- self.te_text += f'1st cell layer thickness (m) , for y+ ={self.yplus.value()}' + newline
- self.te_text += '{:16.8f}'.format(wall_distance) + newline
- self.te_text += 'TKE (m2 /s2 ), Length-scale (m) ' + newline
- self.te_text += '{:16.8f} {:16.8f}'.\
- format(tke, self.length_sc.value()) + newline
- self.te_text += 'AOA (°) u-velocity (m/s) v-velocity (m/s) ' + newline
- for i, _ in enumerate(self.u_velocity):
- self.te_text += '{: >5.2f} {: >16.8f} {: >16.8f}{}'.format(
- self.aoa[i],
- self.u_velocity[i],
- self.v_velocity[i],
- newline)
- self.textedit.setStyleSheet('font-family: Courier; font-size: 12px; ')
-
- # update the text boxwith the current values
- self.textedit.setHtml(self.te_text)
-
- def itemContourAnalysis(self):
-
- box = QtWidgets.QVBoxLayout()
-
- vlayout = QtWidgets.QVBoxLayout()
- gb = QtWidgets.QGroupBox('Select contour to analyze')
- self.b1 = QtWidgets.QRadioButton('Original')
- self.b2 = QtWidgets.QRadioButton('Refined')
- self.b2.setChecked(True)
- vlayout.addWidget(self.b1)
- vlayout.addWidget(self.b2)
- gb.setLayout(vlayout)
- box.addWidget(gb)
-
- vlayout = QtWidgets.QVBoxLayout()
- self.cgb = QtWidgets.QGroupBox('Select plot quantity')
- self.cpb1 = QtWidgets.QRadioButton('Gradient')
- self.cpb2 = QtWidgets.QRadioButton('Curvature')
- self.cpb3 = QtWidgets.QRadioButton('Radius of Curvature')
- self.cpb1.setChecked(True)
- vlayout.addWidget(self.cpb1)
- vlayout.addWidget(self.cpb2)
- vlayout.addWidget(self.cpb3)
- self.cgb.setLayout(vlayout)
- self.cgb.setEnabled(False)
- box.addWidget(self.cgb)
-
- analyzeButton = QtWidgets.QPushButton('Analyze Contour')
- analyzeButton.setGeometry(10, 10, 200, 50)
- box.addWidget(analyzeButton)
-
- box.addStretch(1)
-
- self.item_ca = QtWidgets.QWidget()
- self.item_ca.setLayout(box)
-
- analyzeButton.clicked.connect(self.analyzeAirfoil)
-
- def itemMeshing(self):
-
- self.form_mesh_airfoil = QtWidgets.QFormLayout()
-
- label = QtWidgets.QLabel(u'Gridpoints along airfoil')
- label.setToolTip('Number of points as derived from splining')
- points = 0
- self.points_on_airfoil = QtWidgets.QLineEdit(str(points))
- self.points_on_airfoil.setEnabled(False)
- self.form_mesh_airfoil.addRow(label, self.points_on_airfoil)
-
- label = QtWidgets.QLabel(u'Divisions normal to airfoil')
- label.setToolTip('Number of points in the mesh which is constructed ' +
- ' normal to the airfoil contour')
- self.points_n = QtWidgets.QSpinBox()
- self.points_n.setSingleStep(1)
- self.points_n.setRange(1, 500)
- self.points_n.setValue(15)
- self.form_mesh_airfoil.addRow(label, self.points_n)
-
- label = QtWidgets.QLabel('1st cell layer thickness (m)')
- label.setToolTip('Thickness of 1st cell layer perpendicular to the airfoil')
- self.normal_thickness = QtWidgets.QDoubleSpinBox()
- self.normal_thickness.setSingleStep(0.001)
- self.normal_thickness.setRange(1.e-10, 1.e10)
- self.normal_thickness.setDecimals(8)
- self.normal_thickness.setValue(0.00400)
- self.form_mesh_airfoil.addRow(label, self.normal_thickness)
-
- label = QtWidgets.QLabel('Cell growth rate (-)')
- label.setToolTip('Rate at which 1st cell layer grows')
- self.ratio = QtWidgets.QDoubleSpinBox()
- self.ratio.setSingleStep(0.01)
- self.ratio.setRange(1., 100.)
- self.ratio.setValue(1.05)
- self.ratio.setDecimals(3)
- self.form_mesh_airfoil.addRow(label, self.ratio)
-
- self.form_mesh_TE = QtWidgets.QFormLayout()
-
- label = QtWidgets.QLabel(u'Divisions at trailing edge')
- label.setToolTip('Number of subdivisions along the vertical part of the TE')
- self.te_div = QtWidgets.QSpinBox()
- self.te_div.setSingleStep(1)
- self.te_div.setRange(1, 20)
- self.te_div.setValue(3)
- self.form_mesh_TE.addRow(label, self.te_div)
-
- label = QtWidgets.QLabel(u'Divisions downstream')
- label.setToolTip('Number of subdivisions downstream within the TE block')
- self.points_te = QtWidgets.QSpinBox()
- self.points_te.setSingleStep(1)
- self.points_te.setRange(1, 100)
- self.points_te.setValue(15)
- self.form_mesh_TE.addRow(label, self.points_te)
-
- label = QtWidgets.QLabel('1st cell layer thickness (m)')
- label.setToolTip('Thickness of first cell layer in downstream direction')
- self.length_te = QtWidgets.QDoubleSpinBox()
- self.length_te.setSingleStep(0.001)
- self.length_te.setRange(1.e-10, 1.e10)
- self.length_te.setDecimals(8)
- self.length_te.setValue(0.00400)
- self.form_mesh_TE.addRow(label, self.length_te)
-
- label = QtWidgets.QLabel('Cell growth rate (-)')
- label.setToolTip('Rate at which 1st cell layer downstream the TE grows')
- self.ratio_te = QtWidgets.QDoubleSpinBox()
- self.ratio_te.setSingleStep(0.01)
- self.ratio_te.setRange(1., 100.)
- self.ratio_te.setValue(1.05)
- self.ratio_te.setDecimals(3)
- self.form_mesh_TE.addRow(label, self.ratio_te)
-
- self.form_mesh_tunnel = QtWidgets.QFormLayout()
-
- label = QtWidgets.QLabel('Windtunnel Height (chords)')
- label.setToolTip('The height of the windtunnel in units ' +
- 'of chord length')
- self.tunnel_height = QtWidgets.QDoubleSpinBox()
- self.tunnel_height.setSingleStep(0.1)
- self.tunnel_height.setRange(0.1, 100.)
- self.tunnel_height.setValue(3.5)
- self.tunnel_height.setDecimals(1)
- self.form_mesh_tunnel.addRow(label, self.tunnel_height)
-
- label = QtWidgets.QLabel(u'Divisions of Tunnel Height')
- self.divisions_height = QtWidgets.QSpinBox()
- self.divisions_height.setSingleStep(10)
- self.divisions_height.setRange(1, 1000)
- self.divisions_height.setValue(100)
- self.form_mesh_tunnel.addRow(label, self.divisions_height)
-
- label = QtWidgets.QLabel('Cell Thickness ratio (-)')
- self.ratio_height = QtWidgets.QDoubleSpinBox()
- self.ratio_height.setSingleStep(1.0)
- self.ratio_height.setRange(0.1, 100.)
- self.ratio_height.setValue(10.0)
- self.ratio_height.setDecimals(1)
- self.form_mesh_tunnel.addRow(label, self.ratio_height)
-
- label = QtWidgets.QLabel('Distribution biasing')
- self.dist = QtWidgets.QComboBox()
- self.dist.addItems(['symmetric', 'lower', 'upper'])
- self.dist.setCurrentIndex(0)
- self.form_mesh_tunnel.addRow(label, self.dist)
-
- self.form_mesh_wake = QtWidgets.QFormLayout()
-
- label = QtWidgets.QLabel('Windtunnel Wake (chords)')
- label.setToolTip('The length of the wake of the windtunnel in ' +
- 'units of chord length')
- self.tunnel_wake = QtWidgets.QDoubleSpinBox()
- self.tunnel_wake.setSingleStep(0.1)
- self.tunnel_wake.setRange(0.1, 100.)
- self.tunnel_wake.setValue(7.0)
- self.tunnel_wake.setDecimals(1)
- self.form_mesh_wake.addRow(label, self.tunnel_wake)
-
- label = QtWidgets.QLabel(u'Divisions in the wake')
- self.divisions_wake = QtWidgets.QSpinBox()
- self.divisions_wake.setSingleStep(10)
- self.divisions_wake.setRange(1, 1000)
- self.divisions_wake.setValue(100)
- self.form_mesh_wake.addRow(label, self.divisions_wake)
-
- label = QtWidgets.QLabel('Cell Thickness ratio (-)')
- label.setToolTip('Thickness of the last cell vs. the first cell in ' +
- 'the wake mesh block')
- self.ratio_wake = QtWidgets.QDoubleSpinBox()
- self.ratio_wake.setSingleStep(0.1)
- self.ratio_wake.setRange(0.01, 100.0)
- self.ratio_wake.setValue(15.0)
- self.ratio_wake.setDecimals(1)
- self.form_mesh_wake.addRow(label, self.ratio_wake)
-
- label = QtWidgets.QLabel('Equalize vertical wake line at (%)')
- label.setToolTip('Equalize the wake line vertically. ' +
- 'Homogeneous vertical distribution at x% downstream')
- self.spread = QtWidgets.QDoubleSpinBox()
- self.spread.setSingleStep(5.0)
- self.spread.setRange(10.0, 90.0)
- self.spread.setValue(30.0)
- self.spread.setDecimals(1)
- self.form_mesh_wake.addRow(label, self.spread)
-
- # smoothing parameters
- label = QtWidgets.QLabel('Smoothing')
- label.setToolTip('Specify algorithm and parameters for smoothing')
- self.btn_smoother_1 = QtWidgets.QRadioButton('Simple (fast)')
- self.btn_smoother_2 = QtWidgets.QRadioButton('Elliptic (medium)')
- self.btn_smoother_3 = QtWidgets.QRadioButton('Angle based (slow)')
- # initialize simple smoother
- self.btn_smoother_1.setChecked(True)
- self.smoothing_algorithm = 'simple'
-
- self.btn_smoother_1.clicked.connect(self.smoother_btn_clicked)
- self.btn_smoother_2.clicked.connect(self.smoother_btn_clicked)
- self.btn_smoother_3.clicked.connect(self.smoother_btn_clicked)
-
- smoother_settings = QtWidgets.QFormLayout()
-
- label = QtWidgets.QLabel('Iterations')
- self.smoother_iterations = QtWidgets.QSpinBox()
- self.smoother_iterations.setValue(20)
- self.smoother_iterations.setSingleStep(5)
- self.smoother_iterations.setRange(0, 1000)
- self.smoother_iterations.setEnabled(False)
- smoother_settings.addRow(label, self.smoother_iterations)
-
- label = QtWidgets.QLabel('Tolerance')
- self.smoother_tolerance = QtWidgets.QLineEdit()
- self.onlyFloat = QtGui.QDoubleValidator()
- self.smoother_tolerance.setValidator(self.onlyFloat)
- self.smoother_tolerance.setText('1.e-5')
- self.onlyFloat.setRange(1.e-8, 1.0)
- self.onlyFloat.setDecimals(8)
- self.smoother_tolerance.setEnabled(False)
- smoother_settings.addRow(label, self.smoother_tolerance)
-
- hbox_smoothing = QtWidgets.QHBoxLayout()
- vbox1 = QtWidgets.QVBoxLayout()
- vbox2 = QtWidgets.QVBoxLayout()
- vbox1.addWidget(self.btn_smoother_1)
- vbox1.addWidget(self.btn_smoother_2)
- vbox1.addWidget(self.btn_smoother_3)
- vbox2.addLayout(smoother_settings)
- hbox_smoothing.addLayout(vbox1)
- hbox_smoothing.addLayout(vbox2)
-
- vbox = QtWidgets.QVBoxLayout()
- vbox.addLayout(self.form_mesh_airfoil)
- box_airfoil = QtWidgets.QGroupBox('Airfoil contour mesh')
- box_airfoil.setLayout(vbox)
-
- vbox = QtWidgets.QVBoxLayout()
- vbox.addLayout(self.form_mesh_TE)
- box_TE = QtWidgets.QGroupBox('Airfoil trailing edge mesh')
- box_TE.setLayout(vbox)
-
- vbox = QtWidgets.QVBoxLayout()
- vbox.addLayout(self.form_mesh_tunnel)
- box_tunnel = QtWidgets.QGroupBox('Windtunnel mesh (around airfoil)')
- box_tunnel.setLayout(vbox)
-
- vbox = QtWidgets.QVBoxLayout()
- vbox.addLayout(self.form_mesh_wake)
- box_wake = QtWidgets.QGroupBox('Windtunnel mesh (wake)')
- box_wake.setLayout(vbox)
-
- box_smoothing = QtWidgets.QGroupBox('Smoothing')
- box_smoothing.setLayout(hbox_smoothing)
-
- self.createMeshButton = QtWidgets.QPushButton('Create Mesh')
- hbl_cm = QtWidgets.QHBoxLayout()
- hbl_cm.addStretch(stretch=1)
- hbl_cm.addWidget(self.createMeshButton, stretch=4)
- hbl_cm.addStretch(stretch=1)
-
- # boundary definitions
- label = QtWidgets.QLabel('Boundary definitions:')
- label.setToolTip('Here you can define the names of the boundaries ' +
- 'for the mesh export')
-
- grid1 = QtWidgets.QGridLayout()
- grid1.addWidget(label, 0, 0)
-
- # export menu and boundary definitions
- self.form_bnd = QtWidgets.QFormLayout()
- header_1 = QtWidgets.QLabel('Boundary')
- header_1.setStyleSheet('font-weight: bold;')
- header_2 = QtWidgets.QLabel('Name')
- header_2.setStyleSheet('font-weight: bold;')
- self.form_bnd.addRow(header_1, header_2)
-
- label = QtWidgets.QLabel('Airfoil')
- label.setToolTip('Name of the boundary definition for the airfoil')
- self.lineedit_airfoil = QtWidgets.QLineEdit('Airfoil')
- self.form_bnd.addRow(label, self.lineedit_airfoil)
-
- label = QtWidgets.QLabel('Inlet (C-arc)')
- label.setToolTip('Name of the boundary definition for the inlet')
- self.lineedit_inlet = QtWidgets.QLineEdit('Inlet')
- self.form_bnd.addRow(label, self.lineedit_inlet)
-
- label = QtWidgets.QLabel('Outlet')
- label.setToolTip('Name of the boundary definition for the outlet')
- self.lineedit_outlet = QtWidgets.QLineEdit('Outlet')
- self.form_bnd.addRow(label, self.lineedit_outlet)
-
- label = QtWidgets.QLabel('Top')
- label.setToolTip('Name of the boundary definition for the top of the windtunnel')
- self.lineedit_top = QtWidgets.QLineEdit('Top')
- self.form_bnd.addRow(label, self.lineedit_top)
-
- label = QtWidgets.QLabel('Bottom')
- label.setToolTip('Name of the boundary definition for the bottom of the windtunnel')
- self.lineedit_bottom = QtWidgets.QLineEdit('Bottom')
- self.form_bnd.addRow(label, self.lineedit_bottom)
-
- self.check_FIRE = QtWidgets.QCheckBox('AVL FIRE')
- self.check_SU2 = QtWidgets.QCheckBox('SU2')
- self.check_GMSH = QtWidgets.QCheckBox('GMSH')
- self.check_VTK = QtWidgets.QCheckBox('VTK (VTU)')
- self.check_FIRE.setChecked(True)
- self.check_SU2.setChecked(True)
- self.check_GMSH.setChecked(False)
- self.check_VTK.setChecked(False)
-
- label = QtWidgets.QLabel('Export format:')
- label.setToolTip('Check format to be exported')
- grid = QtWidgets.QGridLayout()
- grid.addWidget(label, 0, 0)
- grid.addWidget(self.check_FIRE, 1, 1)
- grid.addWidget(self.check_SU2, 1, 2)
- grid.addWidget(self.check_GMSH, 1, 3)
- grid.addWidget(self.check_VTK, 2, 1)
-
- exportMeshButton = QtWidgets.QPushButton('Export Mesh')
- hbl = QtWidgets.QHBoxLayout()
- hbl.addStretch(stretch=1)
- hbl.addWidget(exportMeshButton, stretch=4)
- hbl.addStretch(stretch=1)
-
- vbl1 = QtWidgets.QVBoxLayout()
- vbl1.addLayout(grid1)
- vbl1.addLayout(self.form_bnd)
- vbl1.addLayout(grid)
- vbl1.addLayout(hbl)
-
- self.box_meshexport = QtWidgets.QGroupBox('Mesh Export')
- self.box_meshexport.setLayout(vbl1)
- self.box_meshexport.setEnabled(False)
-
- vbl = QtWidgets.QVBoxLayout()
- vbl.addStretch(1)
- vbl.addWidget(box_airfoil)
- vbl.addWidget(box_TE)
- vbl.addWidget(box_tunnel)
- vbl.addWidget(box_wake)
- vbl.addWidget(box_smoothing)
- vbl.addLayout(hbl_cm)
- vbl.addStretch(1)
- vbl.addWidget(self.box_meshexport)
- vbl.addStretch(10)
-
- self.item_msh = QtWidgets.QWidget()
- self.item_msh.setLayout(vbl)
-
- self.createMeshButton.clicked.connect(self.generateMesh)
- exportMeshButton.clicked.connect(self.exportMesh)
-
- def itemSplineRefine(self):
-
- form = QtWidgets.QFormLayout()
-
- label = QtWidgets.QLabel(u'Refinement tolerance (°)')
- self.tolerance = QtWidgets.QDoubleSpinBox()
- self.tolerance.setSingleStep(0.1)
- self.tolerance.setDecimals(1)
- self.tolerance.setRange(50.0, 177.0)
- self.tolerance.setValue(172.0)
- form.addRow(label, self.tolerance)
-
- label = QtWidgets.QLabel(u'Refine trailing edge (old segments)')
- label.setToolTip('Specify the number of segments at the trailing edge which should be refined.')
- self.ref_te = QtWidgets.QSpinBox()
- self.ref_te.setSingleStep(1)
- self.ref_te.setRange(1, 50)
- self.ref_te.setValue(3)
- form.addRow(label, self.ref_te)
-
- label = QtWidgets.QLabel(u'Refine trailing edge (new segments)')
- self.ref_te_n = QtWidgets.QSpinBox()
- self.ref_te_n.setSingleStep(1)
- self.ref_te_n.setRange(1, 100)
- self.ref_te_n.setValue(6)
- form.addRow(label, self.ref_te_n)
-
- label = QtWidgets.QLabel(u'Refine trailing edge ratio')
- self.ref_te_ratio = QtWidgets.QDoubleSpinBox()
- self.ref_te_ratio.setSingleStep(0.1)
- self.ref_te_ratio.setDecimals(1)
- self.ref_te_ratio.setRange(1., 10.)
- self.ref_te_ratio.setValue(3.0)
- form.addRow(label, self.ref_te_ratio)
-
- label = QtWidgets.QLabel('Number points on spline (-)')
- self.points = QtWidgets.QSpinBox()
- self.points.setSingleStep(10)
- self.points.setRange(10, 1000)
- self.points.setValue(200)
- form.addRow(label, self.points)
-
- self.splineButton = QtWidgets.QPushButton('Spline and Refine')
- hbl = QtWidgets.QHBoxLayout()
- hbl.addStretch(stretch=1)
- hbl.addWidget(self.splineButton, stretch=4)
- hbl.addStretch(stretch=1)
-
- vbox = QtWidgets.QVBoxLayout()
- vbox.addLayout(form)
- vbox.addLayout(hbl)
- box = QtWidgets.QGroupBox('Airfoil contour refinement')
- box.setLayout(vbox)
-
- form1 = QtWidgets.QFormLayout()
-
- label = QtWidgets.QLabel(u'Upper side blending length (%)')
- self.blend_u = QtWidgets.QDoubleSpinBox()
- self.blend_u.setSingleStep(1.0)
- self.blend_u.setDecimals(1)
- self.blend_u.setRange(0.1, 100.0)
- self.blend_u.setValue(30.0)
- form1.addRow(label, self.blend_u)
- label = QtWidgets.QLabel(u'Lower side blending length (%)')
- self.blend_l = QtWidgets.QDoubleSpinBox()
- self.blend_l.setSingleStep(1.0)
- self.blend_l.setDecimals(1)
- self.blend_l.setRange(0.1, 100.0)
- self.blend_l.setValue(30.0)
- form1.addRow(label, self.blend_l)
-
- label = QtWidgets.QLabel(u'Upper blending polynomial exponent (-)')
- self.exponent_u = QtWidgets.QDoubleSpinBox()
- self.exponent_u.setSingleStep(0.1)
- self.exponent_u.setDecimals(1)
- self.exponent_u.setRange(1.0, 10.0)
- self.exponent_u.setValue(3.0)
- form1.addRow(label, self.exponent_u)
- label = QtWidgets.QLabel(u'Lower blending polynomial exponent (-)')
- self.exponent_l = QtWidgets.QDoubleSpinBox()
- self.exponent_l.setSingleStep(0.1)
- self.exponent_l.setDecimals(1)
- self.exponent_l.setRange(1.0, 10.0)
- self.exponent_l.setValue(3.0)
- form1.addRow(label, self.exponent_l)
-
- label = QtWidgets.QLabel(u'Trailing edge thickness relative to chord (%)')
- self.thickness = QtWidgets.QDoubleSpinBox()
- self.thickness.setSingleStep(0.05)
- self.thickness.setDecimals(2)
- self.thickness.setRange(0.0, 10.0)
- self.thickness.setValue(0.4)
- form1.addRow(label, self.thickness)
-
- self.trailingButton = QtWidgets.QPushButton('Add Trailing Edge')
- self.trailingButton.setEnabled(False)
- hbl1 = QtWidgets.QHBoxLayout()
- hbl1.addStretch(stretch=1)
- hbl1.addWidget(self.trailingButton, stretch=4)
- hbl1.addStretch(stretch=1)
-
- vbox = QtWidgets.QVBoxLayout()
- vbox.addLayout(form1)
- vbox.addLayout(hbl1)
- box1 = QtWidgets.QGroupBox('Airfoil trailing edge')
- box1.setLayout(vbox)
-
- # export menu
- name = ''
- hbox = QtWidgets.QHBoxLayout()
- self.exportContourButton = QtWidgets.QPushButton('Export Contour')
- self.exportContourButton.setEnabled(False)
- hbox.addWidget(self.exportContourButton)
-
- box2 = QtWidgets.QGroupBox('Export modified contour')
- box2.setLayout(hbox)
-
- vbl = QtWidgets.QVBoxLayout()
- vbl.addStretch(1)
- vbl.addWidget(box)
- vbl.addStretch(1)
- vbl.addWidget(box1)
- vbl.addStretch(1)
- vbl.addWidget(box2)
- vbl.addStretch(10)
-
- self.item_cm = QtWidgets.QWidget()
- self.item_cm.setLayout(vbl)
-
- self.splineButton.clicked.connect(self.spline_and_refine)
- self.trailingButton.clicked.connect(self.makeTrailingEdge)
- self.exportContourButton.clicked.connect(self.exportContour)
-
- def makeToolbox(self):
-
- # populate toolbox
- self.tb1 = self.addItem(self.item_fs, 'Airfoil Database')
- self.tb2 = self.addItem(self.item_cm,
- 'Contour Splining and Refinement')
- self.tb4 = self.addItem(self.item_msh, 'Meshing')
- self.tb6 = self.addItem(self.item_abc,
- 'CFD Boundary Conditions')
- self.tb5 = self.addItem(self.item_ap, 'Aerodynamics (Panel Code)')
- self.tb3 = self.addItem(self.item_ca, 'Contour Analysis')
-
- self.setItemToolTip(0, 'Airfoil database ' +
- '(browse filesystem)')
- self.setItemToolTip(1, 'Spline and refine the contour')
- self.setItemToolTip(2, 'Generate a 2D mesh around the ' +
- 'selected airfoil')
- self.setItemToolTip(3,
- 'Compute aerodynamic boundary conditions based' +
- ' on Reynolds number and thermodynamics')
- self.setItemToolTip(4, 'Compute panel based aerodynamic ' +
- 'coefficients')
- self.setItemToolTip(5, 'Analyze the curvature of the ' +
- 'selected airfoil')
-
- self.setItemIcon(0, QtGui.QIcon(os.path.join(ICONS_L, 'airfoil.png')))
- self.setItemIcon(1, QtGui.QIcon(os.path.join(ICONS_L, 'Pixel editor.png')))
- self.setItemIcon(2, QtGui.QIcon(os.path.join(ICONS_L, 'mesh.png')))
- self.setItemIcon(3, QtGui.QIcon(os.path.join(ICONS_L, 'Fast delivery.png')))
- self.setItemIcon(4, QtGui.QIcon(os.path.join(ICONS_L, 'Fast delivery.png')))
- self.setItemIcon(5, QtGui.QIcon(os.path.join(ICONS_L, 'Pixel editor.png')))
-
- # preselect airfoil database box
- self.setCurrentIndex(self.tb1)
-
- def smoother_btn_clicked(self):
- if self.btn_smoother_1.isChecked():
- self.smoothing_algorithm = 'simple'
- self.smoother_iterations.setEnabled(False)
- self.smoother_tolerance.setEnabled(False)
- elif self.btn_smoother_2.isChecked():
- self.smoothing_algorithm = 'elliptic'
- self.smoother_iterations.setEnabled(True)
- self.smoother_tolerance.setEnabled(True)
- elif self.btn_smoother_3.isChecked():
- self.smoothing_algorithm = 'angle_based'
- self.smoother_iterations.setEnabled(True)
- self.smoother_tolerance.setEnabled(True)
-
- def toggleRawPoints(self):
- """Toggle points of raw airfoil contour (on/off)"""
- if hasattr(self.parent.airfoil, 'polygonMarkersGroup'):
- visible = self.parent.airfoil.polygonMarkersGroup.isVisible()
- self.parent.airfoil.polygonMarkersGroup.setVisible(not visible)
-
- def toggleRawContour(self):
- """Toggle contour polygon of raw airfoil contour (on/off)"""
- if hasattr(self.parent.airfoil, 'contourPolygon'):
- visible = self.parent.airfoil.contourPolygon.isVisible()
- self.parent.airfoil.contourPolygon.setVisible(not visible)
-
- def toggleSplinePoints(self):
- """Toggle points of raw airfoil contour (on/off)"""
- if hasattr(self.parent.airfoil, 'splineMarkersGroup'):
- visible = self.parent.airfoil.splineMarkersGroup.isVisible()
- self.parent.airfoil.splineMarkersGroup.setVisible(not visible)
-
- def toggleSpline(self):
- if hasattr(self.parent.airfoil, 'contourSpline'):
- visible = self.parent.airfoil.contourSpline.isVisible()
- self.parent.airfoil.contourSpline.setVisible(not visible)
-
- def toggleChord(self):
- """Toggle visibility of the airfoil chord"""
- if hasattr(self.parent.airfoil, 'chord'):
- visible = self.parent.airfoil.chord.isVisible()
- self.parent.airfoil.chord.setVisible(not visible)
-
- def toggleMesh(self):
- """Toggle visibility of the mesh lines"""
- if hasattr(self.parent.airfoil, 'mesh'):
- visible = self.parent.airfoil.mesh.isVisible()
- self.parent.airfoil.mesh.setVisible(not visible)
-
- def toggleLeCircle(self):
- """Toggle visibility of the leading edge circle"""
- if hasattr(self.parent.airfoil, 'le_circle'):
- visible = self.parent.airfoil.le_circle.isVisible()
- self.parent.airfoil.le_circle.setVisible(not visible)
-
- def toggleMeshBlocks(self):
- """Toggle visibility of the mesh blocking structure"""
- if hasattr(self.parent.airfoil, 'mesh_blocks'):
- visible = self.parent.airfoil.mesh_blocks.isVisible()
- self.parent.airfoil.mesh_blocks.setVisible(not visible)
-
- def toggleCamberLine(self):
- """Toggle visibility of the airfoil camber line"""
- if hasattr(self.parent.airfoil, 'camberline'):
- visible = self.parent.airfoil.camberline.isVisible()
- self.parent.airfoil.camberline.setVisible(not visible)
-
- def runPanelMethod(self):
- """Gui callback to run AeroPython panel method in module PSvpMethod"""
-
- if self.parent.airfoil:
- # get coordinates of airfoil (raw data or if available spline)
- if self.parent.airfoil.spline_data:
- x, y = self.parent.airfoil.spline_data[0]
- else:
- x, y = self.parent.airfoil.raw_coordinates
-
- u_inf = self.freestream.value()
- alpha = self.aoaAP.value()
- panels = self.panels.value()
- SvpMethod.runSVP(self.parent.airfoil.name,
- x, y, u_inf, alpha, panels)
- else:
- self.parent.slots.messageBox('No airfoil loaded.')
- return
-
- def spline_and_refine(self):
- """Spline and refine airfoil"""
-
- if self.parent.airfoil:
-
- self.parent.airfoil.has_TE = False
-
- refine = SplineRefine.SplineRefine()
- refine.doSplineRefine(tolerance=self.tolerance.value(),
- points=self.points.value(),
- ref_te=self.ref_te.value(),
- ref_te_n=self.ref_te_n.value(),
- ref_te_ratio=self.ref_te_ratio.value())
-
- # add splined and refined contour to the airfoil contourGroup
- # makeSplineMarkers call within makeContourSpline
- self.parent.airfoil.makeContourSpline()
-
- # get LE radius, etc.
- spline_data = self.parent.airfoil.spline_data
- curvature_data = ca.ContourAnalysis.getCurvature(spline_data)
- rc, xc, yc, xle, yle, le_id = \
- ca.ContourAnalysis.getLeRadius(spline_data, curvature_data)
- refine.makeLeCircle(rc, xc, yc, xle, yle)
-
- # calculate thickness and camber
- camber = refine.getCamberThickness(spline_data, le_id)
- # draw camber
- self.parent.airfoil.drawCamber(camber)
-
- logger.info('Leading edge radius: {:11.8f}'.format(rc))
- logger.info('Leading edge circle tangent at point: {}'.format(le_id))
-
- # enable trailing edge button
- self.trailingButton.setEnabled(True)
-
- # enable export button
- self.exportContourButton.setEnabled(True)
-
- else:
- self.parent.slots.messageBox('No airfoil loaded.')
- return
-
- def makeTrailingEdge(self):
-
- if self.parent.airfoil:
-
- self.parent.airfoil.has_TE = True
-
- if not hasattr(self.parent.airfoil, 'spline_data'):
- message = 'Splining needs to be done first.'
- self.parent.slots.messageBox(message)
- return
-
- trailing = TrailingEdge.TrailingEdge()
- trailing.trailingEdge(blend=self.blend_u.value() / 100.0,
- ex=self.exponent_u.value(),
- thickness=self.thickness.value(),
- side='upper')
- self.addTEtoScene()
-
- trailing.trailingEdge(blend=self.blend_l.value() / 100.0,
- ex=self.exponent_l.value(),
- thickness=self.thickness.value(),
- side='lower')
- self.addTEtoScene()
- else:
- self.parent.slots.messageBox('No airfoil loaded.')
- return
-
- def addTEtoScene(self):
-
- # add modified spline contour to the airfoil contourGroup
- # makeSplineMarkers call within makeContourSpline
- self.parent.airfoil.makeContourSpline()
- self.parent.airfoil.contourSpline.brush.setStyle(QtCore.Qt.SolidPattern)
- color = QtGui.QColor()
- color.setNamedColor('#7c8696')
- self.parent.airfoil.contourSpline.brush.setColor(color)
- # FIXME
- # FIXME check if redundant, because already set elsewhere
- # FIXME
- self.parent.airfoil.polygonMarkersGroup.setZValue(100)
- self.parent.airfoil.chord.setZValue(99)
- self.parent.airfoil.camberline.setZValue(99)
-
- self.parent.view.adjustMarkerSize()
-
- def generateMesh(self):
- self.wind_tunnel = Meshing.Windtunnel()
- self.wind_tunnel.makeMesh()
-
- def analyzeAirfoil(self):
- """Airfoil contour analysis with respect to geometric features"""
-
- if not self.parent.airfoil:
- self.parent.slots.messageBox('No airfoil loaded.')
- return
-
- # switch tab contour analysis
- self.parent.centralwidget.tabs.setCurrentIndex(1)
- # keep tab 'Contour Analysis'
- self.setCurrentIndex(self.tb3)
-
- # enable radio buttons for plotting when analysis starts
- self.cgb.setEnabled(True)
-
- # analyse contour
- self.parent.contourview.analyze()
-
- # connect signals to slots
- # lambda allows to send extra parameters
- self.cpb1.clicked.connect(lambda:
- self.parent.contourview
- .drawContour('gradient'))
- self.cpb2.clicked.connect(lambda:
- self.parent.contourview
- .drawContour('curvature'))
- self.cpb3.clicked.connect(lambda:
- self.parent.contourview
- .drawContour('radius'))
-
- def exportMesh(self):
-
- file_dialog = FileDialog.Dialog()
- file_dialog.setFilter('Mesh files (*.flma *.su2 *.msh *.inp *.cgns *.vtk)')
- filename, extension = os.path.splitext(self.parent.airfoil.name)
- filename, _ = file_dialog.saveFilename(filename)
-
- if not filename:
- logger.info('No file selected. Nothing saved.')
- return
-
- # clean extension again (because added by file_dialog return)
- filename, extension = os.path.splitext(filename)
-
- # add boundary definition attributes to mesh object
- self.wind_tunnel.boundary_airfoil = self.lineedit_airfoil.text()
- self.wind_tunnel.boundary_inlet = self.lineedit_inlet.text()
- self.wind_tunnel.boundary_outlet = self.lineedit_outlet.text()
- self.wind_tunnel.boundary_top = self.lineedit_top.text()
- self.wind_tunnel.boundary_bottom = self.lineedit_bottom.text()
-
- if self.check_FIRE.isChecked():
- name = filename + '.flma'
- Meshing.BlockMesh.writeFLMA(self.wind_tunnel,
- name=name)
- if self.check_SU2.isChecked():
- name = filename + '.su2'
- Meshing.BlockMesh.writeSU2_nolib(self.wind_tunnel, name=name)
- if self.check_GMSH.isChecked():
- name = filename + '.msh'
- Meshing.BlockMesh.writeGMSH_nolib(self.wind_tunnel, name=name)
- if self.check_VTK.isChecked():
- name = filename + '.vtu'
- Meshing.BlockMesh.writeVTK_nolib(self.wind_tunnel, name=name)
-
- def exportContour(self):
-
- file_dialog = FileDialog.Dialog()
- file_dialog.setFilter('Airfoil contour files (*.dat *.txt)')
- filename, _ = file_dialog.saveFilename(self.parent.airfoil.name)
-
- if not filename:
- logger.info('No file selected. Nothing saved.')
- return
-
- # get coordinates of modified contour
- x, y = self.parent.airfoil.spline_data[0]
- airfoil_name = self.parent.airfoil.name
-
- try:
- # export modified contour
- with open(filename, 'w') as f:
- f.write('#\n')
- f.write('# File created with ' + PyAero.__appname__ + '\n')
- f.write('# Version: ' + PyAero.__version__ + '\n')
- f.write('# Author: ' + PyAero.__author__ + '\n')
- f.write('#\n')
- f.write('# Derived from: %s\n' % (str(airfoil_name).strip()))
- f.write('# Number of points: %s\n' % (len(x)))
- f.write('#\n')
- for i, _ in enumerate(x):
- f.write('{:10.6f} {:10.6f}\n'.format(x[i], y[i]))
- except IOError as error:
- logger.info('IO error: {}'.format(error))
-
- # log to message window
- logger.info('Contour saved as {}'.format(filename))
-
-
-class ListWidget(QtWidgets.QListWidget):
- """Subclassing QListWidget in order to be able to catch key press
- events
- """
- def __init__(self, parent):
- super().__init__()
- self.parent = parent
-
- self.itemClicked.connect(self.listItemClicked)
- self.itemDoubleClicked.connect(self.listItemDoubleClicked)
-
- # get MainWindow instance (overcomes handling parents)
- self.mainwindow = QtCore.QCoreApplication.instance().mainwindow
-
- def keyPressEvent(self, event):
- key = event.key()
-
- if key == QtCore.Qt.Key_Delete:
- item = self.selectedItems()[0]
- row = self.row(item)
- self.takeItem(row)
-
- for airfoil in self.parent.airfoils:
- if item.text() == airfoil.name:
- name = airfoil.name
- self.parent.slots.removeAirfoil(name=name)
- break
-
- # call original implementation of QListWidget keyPressEvent handler
- super().keyPressEvent(event)
-
- def listItemClicked(self, item):
- """show information of airfoil in message window"""
- pass
-
- def listItemDoubleClicked(self, item):
- """make double clicked name in listwidget new active airfoil"""
- for airfoil in self.parent.airfoils:
- if airfoil.name == item.text():
- # first clear all items from the scene
- self.parent.scene.clear()
- # activate double clicked airfoil
- airfoil.makeAirfoil()
- # add all airfoil items (contour markers) to the scene
- airfoil.addToScene(self.parent.scene)
- # make double clicked airfoil the currently active airfoil
- self.parent.airfoil = airfoil
- # adjust the marker size again
- self.mainwindow.view.adjustMarkerSize()
- break
+# -*- coding: utf-8 -*-
+
+import os
+import shutil
+from string import Template
+from types import SimpleNamespace
+
+from PySide6 import QtCore, QtGui, QtWidgets
+
+from CSTAirfoil import (
+ METHOD_BSPLINE,
+ METHOD_CST_MODIFIED,
+ cst_parameters_from_spline_data,
+ format_cst_parameters_text,
+)
+import FileSystem
+import FileDialog
+import FileOperations
+import Icons
+import MeshGraphics
+import Meshing
+import MeshBuilders
+import ToolboxBoundaryConditions
+import ToolboxPages
+import ToolboxServices
+import UiExport
+from ToolboxWidgets import PAGE_BODY_WIDTH, WorkflowStepButton
+from Utils import get_main_window
+
+import logging
+logger = logging.getLogger(__name__)
+
+
+class Toolbox(QtWidgets.QWidget):
+ currentChanged = QtCore.Signal(int)
+
+ def __init__(self):
+ """Workflow-oriented sidebar for PyAero."""
+ super().__init__()
+
+ self.mw = get_main_window()
+ if not hasattr(self.mw, '_viewer_subject'):
+ self.mw._viewer_subject = 'airfoil'
+ self.wind_tunnel = None
+ self.metric_test_result = None
+ self.metric_test_display = None
+ self.workflow = None
+ self._current_index = -1
+ self._last_workflow_index = 0
+ self._page_titles = []
+ self._page_descriptions = []
+ self._page_buttons = []
+
+ self._buildShell()
+
+ ToolboxPages.build_file_system_panel(self)
+ ToolboxPages.build_aerodynamics_panel(self)
+ ToolboxPages.build_boundary_conditions_panel(self)
+ ToolboxPages.build_contour_analysis_panel(self)
+ ToolboxPages.build_spline_refine_panel(self)
+ ToolboxPages.build_meshing_panel(self)
+ ToolboxPages.build_metric_tests_panel(self)
+
+ self.makeToolbox()
+ self.workflow = ToolboxServices.ToolboxWorkflowController(self, self.mw)
+
+ self.currentChanged.connect(self.toolboxChanged)
+ self.refreshWorkflowState()
+
+ def _buildShell(self):
+ palette = {
+ 'airfoil_bg': '#fdf8fb',
+ 'airfoil_border': '#e7dae4',
+ 'airfoil_inner_bg': '#f6edf3',
+ 'airfoil_inner_border': '#dccad6',
+ 'airfoil_title': '#926178',
+ 'airfoil_hover_border': '#c9a7b8',
+ 'workflow_bg': '#f5fbfc',
+ 'workflow_border': '#d6e8ea',
+ 'workflow_title': '#4f7e84',
+ 'workflow_hover': '#edf7f8',
+ 'current_bg': '#f7fbf5',
+ 'current_border': '#d8e4d0',
+ 'current_title': '#667d52',
+ 'current_subtitle': '#718562',
+ 'current_nav_bg': '#eef5e8',
+ 'current_nav_border': '#cadabf',
+ 'current_nav_accent': '#84aa67',
+ 'current_nav_text': '#4b623d',
+ }
+ style = Template("""
+ QWidget {
+ color: #1f2933;
+ }
+ QLabel#sectionTitle,
+ QLabel#summaryEyebrow,
+ QLabel#pageTitle {
+ color: #6b7788;
+ font-size: 14px;
+ font-weight: 700;
+ letter-spacing: 0.08em;
+ }
+ QFrame#workflowSummaryCard[paneTone="airfoil"] {
+ background: $airfoil_bg;
+ border: 1px solid $airfoil_border;
+ border-radius: 12px;
+ }
+ QFrame#workflowSummaryCard[paneTone="airfoil"] QLabel#summaryEyebrow {
+ color: $airfoil_title;
+ }
+ QFrame#workflowSummaryCard[paneTone="airfoil"] QPushButton#summaryActionButton {
+ border-color: $airfoil_inner_border;
+ }
+ QFrame#workflowSummaryCard[paneTone="airfoil"] QPushButton#summaryActionButton:hover {
+ background: #fffafc;
+ border-color: $airfoil_hover_border;
+ }
+ QFrame#summaryNameCard[paneTone="airfoil"] {
+ background: $airfoil_inner_bg;
+ border: 1px solid $airfoil_inner_border;
+ border-radius: 10px;
+ }
+ QLabel#summaryName {
+ color: #203244;
+ font-size: 13px;
+ font-weight: 600;
+ }
+ QLabel#summaryMeta {
+ color: #5f6c78;
+ font-size: 12px;
+ }
+ QLabel#summaryStatus {
+ color: #314154;
+ font-size: 12px;
+ }
+ QPushButton#summaryActionButton {
+ background: #ffffff;
+ border: 1px solid #d8e3ee;
+ border-radius: 8px;
+ color: #223041;
+ font-weight: 600;
+ padding: 6px 10px;
+ }
+ QPushButton#summaryActionButton:hover {
+ background: #f6fafc;
+ border-color: #abc3d9;
+ }
+ QPushButton#summaryActionButton:disabled {
+ color: #8a97a8;
+ background: #f5f7fa;
+ border-color: #e5ebf1;
+ }
+ QFrame#workflowNavCard[paneTone="workflow"] {
+ background: $workflow_bg;
+ border: 1px solid $workflow_border;
+ border-radius: 12px;
+ }
+ QFrame#workflowNavCard[paneTone="workflow"] QLabel#sectionTitle {
+ color: $workflow_title;
+ }
+ QFrame#workflowNavCard[paneTone="workflow"] QPushButton[navRole="step"] {
+ background: transparent;
+ border: none;
+ border-radius: 8px;
+ border-left: 0px solid transparent;
+ color: #6b7788;
+ font-weight: 600;
+ text-align: left;
+ padding: 11px 12px 11px 18px;
+ }
+ QFrame#workflowNavCard[paneTone="workflow"] QPushButton[navRole="step"]:hover {
+ background: $workflow_hover;
+ }
+ QFrame#workflowNavCard[paneTone="workflow"] QPushButton[navRole="step"]:checked {
+ background: $current_nav_bg;
+ border-top: 1px solid $current_nav_border;
+ border-right: 1px solid $current_nav_border;
+ border-bottom: 1px solid $current_nav_border;
+ border-left: 10px solid $current_nav_accent;
+ color: $current_nav_text;
+ font-weight: 700;
+ padding: 11px 12px 11px 10px;
+ }
+ QFrame#workflowNavCard[paneTone="workflow"] QPushButton[navRole="step"][workflowStatus="disabled"] {
+ color: #6b7788;
+ }
+ QFrame#workflowPageCard[paneTone="current"] {
+ background: $current_bg;
+ border: 1px solid $current_border;
+ border-radius: 12px;
+ }
+ QFrame#workflowPageCard[paneTone="current"] QLabel#pageTitle {
+ color: $current_title;
+ }
+ QFrame#workflowPageCard[paneTone="current"] QLabel#pageSubtitle {
+ color: $current_subtitle;
+ }
+ QGroupBox {
+ background: #ffffff;
+ border: 1px solid #e1e9f0;
+ border-radius: 10px;
+ margin-top: 15px;
+ padding: 14px 14px 12px 14px;
+ }
+ QGroupBox::title {
+ subcontrol-origin: margin;
+ left: 14px;
+ padding: 0 4px;
+ color: #62778b;
+ font-size: 12px;
+ font-weight: 700;
+ }
+ QLineEdit,
+ QTextEdit,
+ QSpinBox,
+ QDoubleSpinBox,
+ QComboBox {
+ background: #fbfdff;
+ border: 1px solid #d7e2ed;
+ border-radius: 6px;
+ padding: 6px 8px;
+ selection-background-color: #dcecf7;
+ selection-color: #1d3248;
+ }
+ QLineEdit:focus,
+ QTextEdit:focus,
+ QSpinBox:focus,
+ QDoubleSpinBox:focus,
+ QComboBox:focus {
+ background: #ffffff;
+ border-color: #9fbfda;
+ }
+ QLabel#pageSubtitle {
+ color: #66778c;
+ font-size: 12px;
+ font-weight: 500;
+ }
+ QLabel[pageFieldLabel="true"] {
+ color: #506273;
+ font-size: 12px;
+ font-weight: 600;
+ }
+ QCheckBox[pageOption="true"],
+ QRadioButton[pageChoice="true"] {
+ color: #31475c;
+ font-size: 12px;
+ font-weight: 600;
+ spacing: 8px;
+ }
+ QCheckBox[pageOption="true"]::indicator,
+ QRadioButton[pageChoice="true"]::indicator {
+ width: 14px;
+ height: 14px;
+ }
+ QTextEdit#pageTextPanel {
+ background: #fcfdff;
+ border: 1px solid #d7e2ed;
+ border-radius: 8px;
+ color: #213142;
+ }
+ QLabel#librarySelection {
+ color: #16212d;
+ font-size: 14px;
+ font-weight: 700;
+ }
+ QLabel#libraryMeta {
+ color: #5e6d7f;
+ font-size: 12px;
+ }
+ QRadioButton[librarySource="true"] {
+ background: #ffffff;
+ border: 1px solid #d7e2ed;
+ border-radius: 8px;
+ color: #223041;
+ font-weight: 600;
+ padding: 6px 12px;
+ }
+ QRadioButton[librarySource="true"]::indicator {
+ width: 0px;
+ height: 0px;
+ }
+ QRadioButton[librarySource="true"]:hover {
+ border-color: #abc3d9;
+ }
+ QRadioButton[librarySource="true"]:checked {
+ background: #eef6fb;
+ border-color: #bdd3e3;
+ color: #1c2c40;
+ }
+ QComboBox#librarySourceCombo {
+ background: #ffffff;
+ border: 1px solid #d9d7d1;
+ border-radius: 4px;
+ color: #223041;
+ font-weight: 600;
+ min-width: 120px;
+ padding: 6px 30px 6px 10px;
+ }
+ QComboBox#librarySourceCombo:hover {
+ border-color: #bca88d;
+ }
+ QLineEdit#librarySearch {
+ background: #ffffff;
+ border: 1px solid #d7e2ed;
+ border-radius: 8px;
+ padding: 8px 10px;
+ }
+ QListWidget#airfoilLibraryList {
+ background: #fdfefd;
+ border: 1px solid #dbe7ef;
+ border-radius: 8px;
+ padding: 4px;
+ }
+ QListWidget#airfoilLibraryList::item {
+ border-bottom: 1px solid #e7eff5;
+ padding: 8px 10px;
+ }
+ QListWidget#airfoilLibraryList::item:selected {
+ background: #edf6fb;
+ color: #1c2c40;
+ }
+ QPushButton#libraryActionButton,
+ QPushButton#libraryPrimaryButton {
+ background: #ffffff;
+ border: 1px solid #d7e2ed;
+ border-radius: 8px;
+ color: #223041;
+ font-weight: 600;
+ padding: 7px 12px;
+ }
+ QPushButton#libraryActionButton:hover,
+ QPushButton#libraryPrimaryButton:hover {
+ border-color: #abc3d9;
+ background: #f7fbfe;
+ }
+ QPushButton#libraryPrimaryButton {
+ background: #eef6fb;
+ border-color: #bdd3e3;
+ color: #1c2c40;
+ }
+ QPushButton#libraryPrimaryButton:disabled {
+ background: #f5f7fa;
+ border-color: #e5ebf1;
+ color: #8a97a8;
+ }
+ QFrame[pageSectionCard="true"] {
+ background: #ffffff;
+ border: 1px solid #e1e9f0;
+ border-radius: 10px;
+ }
+ QLabel[pageSectionTitle="true"] {
+ color: #1b2a3a;
+ font-size: 15px;
+ font-weight: 700;
+ }
+ QLabel[pageSectionHint="true"] {
+ color: #66778c;
+ font-size: 12px;
+ }
+ QToolButton[sectionToggle="true"] {
+ background: transparent;
+ border: none;
+ color: #4f6174;
+ font-weight: 600;
+ padding: 4px 0;
+ text-align: left;
+ }
+ QToolButton[sectionToggle="true"]:hover {
+ color: #223041;
+ }
+ QPushButton#pagePrimaryActionButton,
+ QPushButton#pageSecondaryActionButton {
+ border-radius: 8px;
+ font-weight: 600;
+ padding: 8px 14px;
+ }
+ QPushButton#pagePrimaryActionButton {
+ background: #eef6fb;
+ border: 1px solid #bdd3e3;
+ color: #1c2c40;
+ }
+ QPushButton#pagePrimaryActionButton:hover {
+ background: #f7fbfe;
+ border-color: #9ebbd3;
+ }
+ QPushButton#pageSecondaryActionButton {
+ background: #ffffff;
+ border: 1px solid #d7e2ed;
+ color: #223041;
+ }
+ QPushButton#pageSecondaryActionButton:hover {
+ border-color: #abc3d9;
+ }
+ QScrollArea {
+ border: none;
+ background: transparent;
+ }
+ QWidget#workflowPageBody {
+ background: transparent;
+ }
+ """).substitute(palette)
+ self.setStyleSheet(style)
+
+ self.summary_card = self._buildSummaryCard()
+ self.summary_card.setProperty('paneTone', 'airfoil')
+
+ self.workflow_steps = QtWidgets.QWidget()
+ self.workflow_steps_layout = QtWidgets.QVBoxLayout()
+ self.workflow_steps_layout.setContentsMargins(0, 0, 0, 0)
+ self.workflow_steps_layout.setSpacing(4)
+ self.workflow_steps.setLayout(self.workflow_steps_layout)
+
+ self.workflow_card = QtWidgets.QFrame()
+ self.workflow_card.setObjectName('workflowNavCard')
+ self.workflow_card.setProperty('paneTone', 'workflow')
+ workflow_card_layout = QtWidgets.QVBoxLayout()
+ workflow_card_layout.setContentsMargins(14, 14, 14, 14)
+ workflow_card_layout.setSpacing(8)
+ self.workflow_card.setLayout(workflow_card_layout)
+
+ workflow_title = QtWidgets.QLabel('WORKFLOW')
+ workflow_title.setObjectName('sectionTitle')
+ workflow_card_layout.addWidget(workflow_title)
+ workflow_card_layout.addWidget(self.workflow_steps)
+
+ self.page_card = QtWidgets.QFrame()
+ self.page_card.setObjectName('workflowPageCard')
+ self.page_card.setProperty('paneTone', 'current')
+ page_layout = QtWidgets.QVBoxLayout()
+ page_layout.setContentsMargins(18, 16, 18, 18)
+ page_layout.setSpacing(8)
+ self.page_card.setLayout(page_layout)
+
+ self.page_title_label = QtWidgets.QLabel('')
+ self.page_title_label.setObjectName('pageTitle')
+ self.page_description_label = QtWidgets.QLabel('')
+ self.page_description_label.setObjectName('pageSubtitle')
+ self.page_description_label.setWordWrap(True)
+
+ self.page_stack = QtWidgets.QStackedWidget()
+ self.page_stack.setSizePolicy(
+ QtWidgets.QSizePolicy.Expanding,
+ QtWidgets.QSizePolicy.Expanding,
+ )
+
+ page_layout.addWidget(self.page_title_label)
+ page_layout.addWidget(self.page_description_label)
+ page_layout.addWidget(self.page_stack, stretch=1)
+
+ layout = QtWidgets.QVBoxLayout()
+ layout.setContentsMargins(0, 0, 0, 0)
+ layout.setSpacing(14)
+ layout.addWidget(self.summary_card)
+ layout.addWidget(self.workflow_card)
+ layout.addWidget(self.page_card, stretch=1)
+ self.setLayout(layout)
+
+ def _buildSummaryCard(self):
+ card = QtWidgets.QFrame()
+ card.setObjectName('workflowSummaryCard')
+
+ layout = QtWidgets.QVBoxLayout()
+ layout.setContentsMargins(18, 16, 18, 16)
+ layout.setSpacing(8)
+ card.setLayout(layout)
+
+ header = QtWidgets.QHBoxLayout()
+ header.setContentsMargins(0, 0, 0, 0)
+ header.setSpacing(8)
+
+ eyebrow = QtWidgets.QLabel('ACTIVE AIRFOIL')
+ eyebrow.setObjectName('summaryEyebrow')
+ header.addWidget(eyebrow)
+ header.addStretch(1)
+
+ self.summary_remove_button = self._makeSummaryActionButton('Remove')
+ self.summary_remove_button.clicked.connect(
+ lambda *_: self.mw.slots.removeAirfoil()
+ )
+ header.addWidget(self.summary_remove_button)
+ layout.addLayout(header)
+
+ self.summary_name_card = QtWidgets.QFrame()
+ self.summary_name_card.setObjectName('summaryNameCard')
+ self.summary_name_card.setProperty('paneTone', 'airfoil')
+ summary_name_layout = QtWidgets.QHBoxLayout()
+ summary_name_layout.setContentsMargins(12, 9, 12, 9)
+ summary_name_layout.setSpacing(0)
+ self.summary_name_card.setLayout(summary_name_layout)
+
+ self.summary_name_label = QtWidgets.QLabel('No airfoil loaded')
+ self.summary_name_label.setObjectName('summaryName')
+ summary_name_layout.addWidget(self.summary_name_label)
+ summary_name_layout.addStretch(1)
+ layout.addWidget(self.summary_name_card)
+
+ self.summary_meta_label = QtWidgets.QLabel('Open an airfoil to begin')
+ self.summary_meta_label.setObjectName('summaryMeta')
+ self.summary_meta_label.setWordWrap(True)
+ layout.addWidget(self.summary_meta_label)
+
+ self.summary_geometry_label = QtWidgets.QLabel('Geometry: waiting')
+ self.summary_geometry_label.setObjectName('summaryStatus')
+ self.summary_geometry_label.setWordWrap(True)
+ layout.addWidget(self.summary_geometry_label)
+
+ self.summary_mesh_label = QtWidgets.QLabel('Mesh: not available')
+ self.summary_mesh_label.setObjectName('summaryStatus')
+ self.summary_mesh_label.setWordWrap(True)
+ layout.addWidget(self.summary_mesh_label)
+
+ return card
+
+ def _makeSummaryActionButton(self, text):
+ button = QtWidgets.QPushButton(text)
+ button.setObjectName('summaryActionButton')
+ button.setCursor(QtCore.Qt.PointingHandCursor)
+ return button
+
+ def addPage(self, widget, title, description, icon, scrollable=True):
+ index = self.page_stack.count()
+ if scrollable:
+ widget.setMaximumWidth(PAGE_BODY_WIDTH)
+ widget.setSizePolicy(
+ QtWidgets.QSizePolicy.Preferred,
+ QtWidgets.QSizePolicy.Maximum,
+ )
+ page_body = QtWidgets.QWidget()
+ page_body.setObjectName('workflowPageBody')
+ body_layout = QtWidgets.QHBoxLayout()
+ body_layout.setContentsMargins(0, 0, 0, 0)
+ body_layout.setSpacing(0)
+ body_layout.addWidget(widget, 0, QtCore.Qt.AlignTop)
+ body_layout.addStretch(1)
+ page_body.setLayout(body_layout)
+
+ scroll = QtWidgets.QScrollArea()
+ scroll.setWidgetResizable(True)
+ scroll.setFrameShape(QtWidgets.QFrame.NoFrame)
+ scroll.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff)
+ scroll.setAlignment(QtCore.Qt.AlignTop | QtCore.Qt.AlignLeft)
+ scroll.setWidget(page_body)
+ page_widget = scroll
+ else:
+ page_widget = widget
+ self.page_stack.addWidget(page_widget)
+
+ button = WorkflowStepButton(title=title, subtitle=description)
+ if icon:
+ button.setIcon(Icons.icon(icon))
+ button.setIconSize(QtCore.QSize(20, 20))
+ button.clicked.connect(
+ lambda _checked=False, idx=index: self.setCurrentIndex(idx)
+ )
+ self.workflow_steps_layout.addWidget(button)
+
+ self._page_buttons.append(button)
+ self._page_titles.append(title)
+ self._page_descriptions.append(description)
+ return index
+
+ def currentIndex(self):
+ return self._current_index
+
+ def currentPageTitle(self):
+ if self._current_index < 0 or self._current_index >= len(self._page_titles):
+ return ''
+ return self._page_titles[self._current_index]
+
+ def pageCount(self):
+ return self.page_stack.count()
+
+ def pageTitle(self, index):
+ if index < 0 or index >= len(self._page_titles):
+ return ''
+ return self._page_titles[index]
+
+ def lastWorkflowIndex(self):
+ return self._last_workflow_index
+
+ def setCurrentIndex(self, index):
+ if index < 0 or index >= self.page_stack.count():
+ return
+ if index == self._current_index:
+ return
+
+ self._current_index = index
+ if hasattr(self, 'tb3') and index != self.tb3:
+ self._last_workflow_index = index
+
+ self.page_stack.setCurrentIndex(index)
+ for page_index, button in enumerate(self._page_buttons):
+ blocker = QtCore.QSignalBlocker(button)
+ button.setChecked(page_index == index)
+ del blocker
+
+ self._updateCurrentPageHeader()
+ self.currentChanged.emit(index)
+
+ def _updateCurrentPageHeader(self):
+ if self._current_index < 0:
+ self.page_title_label.setText('')
+ self.page_description_label.setText('')
+ return
+
+ button = self._page_buttons[self._current_index]
+ self.page_title_label.setText(self._pageTitleText(self._current_index))
+ self.page_description_label.setText(
+ self._pageHeaderSubtitle(self._current_index, button)
+ )
+
+ def _pageTitleText(self, index):
+ title = self._page_titles[index]
+ if hasattr(self, 'tb1') and index == self.tb1:
+ count = getattr(self, '_airfoil_library_visible_count', None)
+ if count is not None:
+ return f'{title} ({count})'.upper()
+ return title.upper()
+
+ def _pageHeaderSubtitle(self, index, button):
+ if hasattr(self, 'tb1') and index == self.tb1:
+ return self._page_descriptions[index]
+ return button.subtitle or self._page_descriptions[index]
+
+ def refreshWorkflowState(self):
+ airfoil = self._active_airfoil()
+
+ self.summary_remove_button.setEnabled(airfoil is not None)
+ self.summary_remove_button.setVisible(airfoil is not None)
+
+ if airfoil is None:
+ self.summary_name_label.setText('No airfoil loaded')
+ self.summary_meta_label.setText(
+ 'Choose an airfoil from the library below to begin.'
+ )
+ self.summary_geometry_label.setText('Geometry: waiting for an airfoil')
+ self.summary_mesh_label.setText('Mesh: not generated')
+ else:
+ self.summary_name_label.setText(airfoil.name)
+ source_path = getattr(airfoil, 'source_path', None)
+ source_text = FileSystem.describe_airfoil_source(
+ source_path,
+ mainwindow=self.mw,
+ )
+ self.summary_meta_label.setText(source_text or 'Working airfoil ready')
+ self.summary_geometry_label.setText(
+ f'Geometry: {self._geometryStatusText(airfoil)}'
+ )
+ self.summary_mesh_label.setText(
+ f'Mesh: {self._meshStatusText(airfoil)}'
+ )
+
+ fill_toggle = getattr(
+ getattr(self.mw, 'mainArea', None),
+ 'airfoil_spline_fill_checkbox',
+ None,
+ )
+ if fill_toggle is not None:
+ can_fill_spline = airfoil is not None and airfoil.has_spline
+ fill_toggle.setEnabled(can_fill_spline)
+ if can_fill_spline:
+ self.applySplineFillPreference(airfoil)
+
+ self._updateGeometryActionButtons(airfoil)
+ if hasattr(self, 'hybrid_staged_controls_changed'):
+ self.hybrid_staged_controls_changed()
+
+ if not hasattr(self, 'tb1'):
+ return
+
+ if airfoil is None:
+ self._setPageStatus(
+ self.tb1,
+ 'ready',
+ 'Open or drag in an airfoil contour',
+ )
+ self._setPageStatus(self.tb2, 'disabled', 'Select an airfoil first')
+ self._setPageStatus(self.tb4, 'disabled', 'Prepare contour first')
+ self._setPageStatus(
+ self.tb7,
+ 'ready',
+ self._metricTestStatusSummary(),
+ )
+ self._setPageStatus(self.tb6, 'info', 'Freestream helper inputs')
+ self._setPageStatus(self.tb5, 'info', 'Reserved for quick aero tools')
+ self._setPageStatus(self.tb3, 'disabled', 'Secondary analysis workspace')
+ self._updateCurrentPageHeader()
+ return
+
+ self._setPageStatus(self.tb1, 'done', 'Working airfoil selected')
+
+ method_label = self._geometryMethodLabel(airfoil)
+ if airfoil.has_TE:
+ geometry_status = ('done', f'{method_label} ready, trailing edge adjusted')
+ elif airfoil.has_spline:
+ geometry_status = ('done', f'{method_label} ready for meshing')
+ else:
+ geometry_status = ('ready', 'Raw contour ready for refinement')
+ self._setPageStatus(self.tb2, *geometry_status)
+
+ if airfoil.mesh_model is not None:
+ stats = airfoil.mesh_model.mesh_statistics()
+ mesh_detail = f'{stats.cell_count} cells / {stats.block_count} blocks'
+ self._setPageStatus(self.tb4, 'done', mesh_detail)
+ elif airfoil.has_spline:
+ self._setPageStatus(self.tb4, 'ready', 'Ready to generate the mesh')
+ else:
+ self._setPageStatus(self.tb4, 'disabled', 'Prepare contour first')
+
+ metric_status, metric_detail = self._metricTestWorkflowState()
+ self._setPageStatus(self.tb7, metric_status, metric_detail)
+ self._setPageStatus(self.tb6, 'ready', 'Freestream and y+ helper inputs')
+ self._setPageStatus(self.tb5, 'info', 'Future quick-aero workspace')
+ contour_status = 'ready' if airfoil.has_spline else 'disabled'
+ contour_detail = (
+ 'Secondary analysis workspace'
+ if airfoil.has_spline else
+ 'Prepare contour first'
+ )
+ self._setPageStatus(self.tb3, contour_status, contour_detail)
+ self._updateCurrentPageHeader()
+
+ def _setPageStatus(self, index, status, detail):
+ self._page_buttons[index].set_status(status, detail)
+
+ def _updateGeometryActionButtons(self, airfoil):
+ has_spline = airfoil is not None and airfoil.has_spline
+ has_camber = has_spline and getattr(airfoil, 'camber_data', None) is not None
+ has_cst = self._activeCstParameterData(airfoil) is not None
+
+ for attribute_name, enabled in (
+ ('trailingButton', has_spline),
+ ('exportContourButton', has_spline),
+ ('exportCamberButton', has_camber),
+ ('exportCstButton', has_cst),
+ ('cstParametersButton', has_cst),
+ ):
+ button = getattr(self, attribute_name, None)
+ if button is not None:
+ button.setEnabled(enabled)
+
+ def _geometryStatusText(self, airfoil):
+ method_label = self._geometryMethodLabel(airfoil)
+ if airfoil.has_TE:
+ return f'{method_label} ready, trailing edge adjusted'
+ if airfoil.has_spline:
+ return f'{method_label} ready'
+ return 'raw contour loaded'
+
+ def _meshStatusText(self, airfoil):
+ if airfoil.mesh_model is None:
+ return 'not generated'
+ stats = airfoil.mesh_model.mesh_statistics()
+ summary = f'{stats.cell_count} cells across {stats.block_count} blocks'
+ wind_tunnel = getattr(self, 'wind_tunnel', None)
+ if wind_tunnel is not None and getattr(wind_tunnel, 'mesh_engine', None) == 'metric_based':
+ layout_plan = getattr(wind_tunnel, 'layout_plan', None)
+ singularity_count = len(getattr(layout_plan, 'singularities', []))
+ return f'{summary} (Metric based, {singularity_count} singularities)'
+ if wind_tunnel is None or getattr(wind_tunnel, 'mesh_engine', None) != 'hybrid_staged':
+ return summary
+
+ stage_state = getattr(wind_tunnel, 'hybrid_stage_state', {}) or {}
+ if stage_state.get('stage1', False):
+ return f'{summary} (Stage 1 applied)'
+ if stage_state.get('stage2', False):
+ return f'{summary} (Stage 2 applied)'
+ if stage_state.get('stage4', False):
+ return f'{summary} (Stage 4 only)'
+ return summary
+
+ def _metricTestStatusSummary(self):
+ result = getattr(self, 'metric_test_result', None)
+ if result is None:
+ return 'Rectangle and circular-hole sandbox available'
+
+ stats = result.mesh.mesh_statistics()
+ warnings = len(getattr(result, 'warnings', []) or [])
+ detail = f'{stats.cell_count} triangles'
+ if warnings:
+ detail += f', {warnings} warnings'
+ return detail
+
+ def _metricTestWorkflowState(self):
+ result = getattr(self, 'metric_test_result', None)
+ if result is None:
+ return 'ready', self._metricTestStatusSummary()
+
+ stats = result.mesh.mesh_statistics()
+ warnings = len(getattr(result, 'warnings', []) or [])
+ detail = (
+ f'{stats.cell_count} triangles / {stats.vertex_count} vertices'
+ )
+ if warnings:
+ detail += f' ({warnings} warnings)'
+ return 'info', detail
+ return 'done', detail
+
+ def toolboxChanged(self, _index=None):
+ if self.currentIndex() == self.tb3:
+ self.mw.mainArea.tabs.setCurrentIndex(1)
+ else:
+ self.mw.mainArea.tabs.setCurrentIndex(0)
+ if self.currentIndex() == self.tb7:
+ self.showMetricTestScene()
+ else:
+ self.restoreAirfoilScene()
+
+ if self.currentIndex() == self.tb4:
+ points = 0
+ if self.mw.airfoil and self.mw.airfoil.has_spline:
+ points = self.mw.airfoil.spline_data.point_count
+ self.points_on_airfoil.setText(str(points))
+
+ def selectedAirfoilLibrarySource(self):
+ if not hasattr(self, 'airfoil_library_source_buttons'):
+ return 'bundled'
+ for source, button in self.airfoil_library_source_buttons.items():
+ if button.isChecked():
+ return source
+ return 'bundled'
+
+ def currentAirfoilLibraryPath(self):
+ if not hasattr(self, 'airfoil_library_list'):
+ return None
+ item = self.airfoil_library_list.currentItem()
+ if item is None:
+ return None
+ return item.data(QtCore.Qt.UserRole)
+
+ def refreshAirfoilLibrary(self):
+ if not hasattr(self, 'airfoil_library_list'):
+ return
+
+ selected_path = self.currentAirfoilLibraryPath()
+ search_text = self.airfoil_library_search.text().strip().lower()
+ source = self.selectedAirfoilLibrarySource()
+ entries = FileSystem.list_airfoil_library_entries(
+ source=source,
+ mainwindow=self.mw,
+ )
+
+ if search_text:
+ entries = [
+ entry for entry in entries
+ if search_text in entry.name.lower() or
+ search_text in entry.relative_path.lower()
+ ]
+
+ blocker = QtCore.QSignalBlocker(self.airfoil_library_list)
+ self.airfoil_library_list.clear()
+ selected_item = None
+
+ for entry in entries:
+ label = entry.name
+ if source == 'all':
+ label = f'{entry.name} [{entry.source_label}]'
+ item = QtWidgets.QListWidgetItem(label)
+ item.setToolTip(entry.path)
+ item.setData(QtCore.Qt.UserRole, entry.path)
+ item.setData(QtCore.Qt.UserRole + 1, entry.relative_path)
+ item.setData(QtCore.Qt.UserRole + 2, entry.source_label)
+ item.setData(QtCore.Qt.UserRole + 3, entry.name)
+ item.setSizeHint(QtCore.QSize(0, 34))
+ self.airfoil_library_list.addItem(item)
+
+ if selected_path and os.path.abspath(entry.path) == os.path.abspath(selected_path):
+ selected_item = item
+
+ if selected_item is not None:
+ self.airfoil_library_list.setCurrentItem(selected_item)
+ elif self.airfoil_library_list.count() > 0:
+ self.airfoil_library_list.setCurrentRow(0)
+ del blocker
+
+ self._airfoil_library_visible_count = len(entries)
+ source_label = {
+ 'bundled': 'Bundled library',
+ 'local': 'Local library',
+ 'all': 'All airfoils',
+ }[source]
+ if entries:
+ self._airfoil_library_status_text = ''
+ elif search_text:
+ self._airfoil_library_status_text = f'No matches for "{search_text}".'
+ elif source == 'local':
+ self._airfoil_library_status_text = (
+ 'No local airfoils yet. Use Add To Local... to build your library.'
+ )
+ else:
+ self._airfoil_library_status_text = (
+ f'No airfoils available in {source_label.lower()}.'
+ )
+
+ self.updateAirfoilLibraryDetails(self.airfoil_library_list.currentItem())
+ self._updateCurrentPageHeader()
+
+ def selectAirfoilLibraryPath(self, path):
+ if not hasattr(self, 'airfoil_library_list'):
+ return
+
+ blocker = QtCore.QSignalBlocker(self.airfoil_library_list)
+ if not path:
+ self.airfoil_library_list.setCurrentRow(-1)
+ del blocker
+ self.updateAirfoilLibraryDetails(None)
+ return
+
+ target_path = os.path.abspath(path)
+ self.airfoil_library_list.setCurrentRow(-1)
+
+ for row in range(self.airfoil_library_list.count()):
+ item = self.airfoil_library_list.item(row)
+ item_path = item.data(QtCore.Qt.UserRole)
+ if item_path and os.path.abspath(item_path) == target_path:
+ self.airfoil_library_list.setCurrentItem(item)
+ break
+
+ del blocker
+ self.updateAirfoilLibraryDetails(self.airfoil_library_list.currentItem())
+
+ def updateAirfoilLibraryDetails(self, current=None, _previous=None):
+ if not hasattr(self, 'airfoil_library_status_label'):
+ return
+
+ self.airfoil_library_load_button.setEnabled(current is not None)
+ status_text = getattr(self, '_airfoil_library_status_text', '')
+ self.airfoil_library_status_label.setVisible(bool(status_text))
+ self.airfoil_library_status_label.setText(status_text)
+
+ def loadSelectedLibraryAirfoil(self, item=None):
+ selected_item = item or self.airfoil_library_list.currentItem()
+ if selected_item is None:
+ return
+
+ path = selected_item.data(QtCore.Qt.UserRole)
+ if not path:
+ return
+ self.mw.slots.openFile(path)
+
+ def importAirfoilToLocalLibrary(self):
+ file_dialog = FileDialog.Dialog()
+ filename, _ = file_dialog.open_filename(
+ title='Import Airfoil To Local Library',
+ filter=FileOperations.CONTOUR_FILTER,
+ )
+
+ if not filename:
+ logger.info('No file selected. Nothing imported.')
+ return
+
+ local_root = FileSystem.local_library_root(self.mw)
+ destination = os.path.join(local_root, os.path.basename(filename))
+
+ if os.path.abspath(filename) != os.path.abspath(destination):
+ if os.path.exists(destination):
+ answer = QtWidgets.QMessageBox.question(
+ self.mw,
+ 'Replace Airfoil?',
+ f'{os.path.basename(destination)} already exists in the local library. Replace it?',
+ QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.No,
+ QtWidgets.QMessageBox.No,
+ )
+ if answer != QtWidgets.QMessageBox.Yes:
+ return
+ try:
+ shutil.copy2(filename, destination)
+ except OSError as error:
+ FileOperations.report_io_error(
+ 'import airfoil to local library',
+ destination,
+ error,
+ mainwindow=self.mw,
+ )
+ return
+ logger.info(f'Imported airfoil to local library: {destination}')
+ else:
+ logger.info(f'Airfoil already in local library: {destination}')
+
+ local_button = getattr(self, 'airfoil_library_source_buttons', {}).get('local')
+ if local_button is not None:
+ local_button.setChecked(True)
+ self.refreshAirfoilLibrary()
+ self.selectAirfoilLibraryPath(destination)
+
+ def copy_to_clipboard(self):
+ """Copy any selected text in the boundary-condition view."""
+ self.textedit.copy()
+
+ def copy_all_to_clipboard(self):
+ """Copy the full boundary-condition view to the clipboard."""
+ self.textedit.selectAll()
+ self.textedit.copy()
+ cursor = self.textedit.textCursor()
+ cursor.clearSelection()
+ self.textedit.setTextCursor(cursor)
+ vsb = self.textedit.verticalScrollBar()
+ vsb.setValue(QtWidgets.QAbstractSlider.SliderToMaximum)
+
+ def boundary_condition_inputs(self):
+ return ToolboxBoundaryConditions.BoundaryConditionInputs(
+ reynolds=self.reynolds.value(),
+ chord=self.chord.value(),
+ aoa_from=self.aoaf.value(),
+ aoa_to=self.aoat.value(),
+ aoa_step=self.aoas.value(),
+ turbulence=self.turbulence.value(),
+ length_scale=self.length_sc.value(),
+ pressure=self.pressure.value(),
+ temperature_c=self.temperature.value(),
+ yplus=self.yplus.value(),
+ )
+
+ def valuechange(self):
+ if self.aoaf.value() >= self.aoat.value():
+ self.aoaf.setValue(self.aoat.value() - self.aoas.value())
+ if self.aoat.value() <= self.aoaf.value():
+ self.aoat.setValue(self.aoaf.value() + self.aoas.value())
+
+ inputs = self.boundary_condition_inputs()
+ results = ToolboxBoundaryConditions.calculate_boundary_conditions(inputs)
+
+ self.density = results.density
+ self.dynamic_viscosity = results.dynamic_viscosity
+ self.kinematic_viscosity = results.kinematic_viscosity
+ self.aoa = results.aoa
+ self.u_velocity = results.u_velocity
+ self.v_velocity = results.v_velocity
+ self.wall_distance = results.wall_distance
+ self.tke = results.tke
+ self.temperature_k = results.temperature_k
+ self.te_text = ToolboxBoundaryConditions.format_boundary_conditions_html(
+ inputs,
+ results,
+ )
+ self.textedit.setStyleSheet(
+ 'font-family: "Menlo", "Monaco", "Courier New"; font-size: 12px; '
+ )
+ self.textedit.setHtml(self.te_text)
+
+ def makeToolbox(self):
+ self.tb1 = self.addPage(
+ self.item_fs,
+ title='Airfoil Library',
+ description='Pick an airfoil and bring it into the workspace.',
+ icon='airfoil-library',
+ scrollable=False,
+ )
+ self.tb2 = self.addPage(
+ self.item_cm,
+ title='Geometry Prep',
+ description='Refine the contour and prepare the trailing edge.',
+ icon='geometry-prep',
+ )
+ self.tb4 = self.addPage(
+ self.item_msh,
+ title='Mesh',
+ description='Set block sizes and export the tunnel mesh.',
+ icon='mesh',
+ )
+ self.tb7 = self.addPage(
+ self.item_metric_tests,
+ title='Metric Tests',
+ description='Try the cleanroom triangulation core on simple domains.',
+ icon='mesh',
+ )
+ self.tb6 = self.addPage(
+ self.item_abc,
+ title='CFD Inputs',
+ description='Prepare freestream and wall-distance inputs.',
+ icon='cfd-inputs',
+ )
+ self.tb5 = self.addPage(
+ self.item_ap,
+ title='Aerodynamics',
+ description='Run a quick panel-method estimate.',
+ icon='aerodynamics',
+ )
+ self.tb3 = self.addPage(
+ self.item_ca,
+ title='Contour Analysis',
+ description='Inspect gradient, curvature, and radius.',
+ icon='contour-analysis',
+ )
+
+ self.setCurrentIndex(self.tb1)
+
+ def smoother_btn_clicked(self):
+ elliptic_controls = [
+ self.outer_boundary_slide,
+ self.elliptic_relaxation,
+ self.protected_guide_relaxation,
+ self.protected_guide_layers,
+ self.protected_guide_decay,
+ self.protected_guide_smoothing,
+ ]
+
+ if self.btn_smoother_1.isChecked():
+ self.smoothing_algorithm = 'simple'
+ self.smoother_iterations.setEnabled(False)
+ self.smoother_tolerance.setEnabled(False)
+ for control in elliptic_controls:
+ control.setEnabled(False)
+ elif self.btn_smoother_2.isChecked():
+ self.smoothing_algorithm = 'elliptic'
+ self.smoother_iterations.setEnabled(True)
+ self.smoother_tolerance.setEnabled(True)
+ for control in elliptic_controls:
+ control.setEnabled(True)
+ elif self.btn_smoother_3.isChecked():
+ self.smoothing_algorithm = 'angle_based'
+ self.smoother_iterations.setEnabled(True)
+ self.smoother_tolerance.setEnabled(True)
+ for control in elliptic_controls:
+ control.setEnabled(False)
+
+ def mesh_engine_changed(self, _index=None):
+ selector = getattr(self, 'meshEngineSelector', None)
+ if selector is None:
+ self.mesh_engine = 'metric_based'
+ return
+
+ engine = selector.currentData()
+ self.mesh_engine = engine or 'metric_based'
+ if self.mesh_engine == 'experimental':
+ self.mesh_engine = 'experimental_c'
+ is_hybrid_staged = self.mesh_engine == 'hybrid_staged'
+
+ airfoil_group = getattr(self, 'mesh_airfoil_group', None)
+ if airfoil_group is not None:
+ show_airfoil = self.mesh_engine in (
+ 'standard',
+ 'hybrid',
+ 'hybrid_staged',
+ 'metric_based',
+ )
+ airfoil_group.setVisible(show_airfoil)
+ airfoil_group.setEnabled(show_airfoil)
+
+ te_group = getattr(self, 'mesh_te_group', None)
+ if te_group is not None:
+ show_te = self.mesh_engine in ('standard', 'hybrid')
+ te_group.setVisible(show_te)
+ te_group.setEnabled(show_te)
+
+ smoothing_group = getattr(self, 'mesh_smoothing_group', None)
+ if smoothing_group is not None:
+ show_smoothing = self.mesh_engine in ('standard', 'hybrid')
+ smoothing_group.setVisible(show_smoothing)
+ smoothing_group.setEnabled(show_smoothing)
+
+ tunnel_group = getattr(self, 'mesh_tunnel_group', None)
+ if tunnel_group is not None:
+ # the structured engine owns its tunnel parameters
+ show_tunnel = self.mesh_engine != 'structured'
+ tunnel_group.setVisible(show_tunnel)
+ tunnel_group.setEnabled(show_tunnel)
+
+ wake_group = getattr(self, 'mesh_wake_group', None)
+ if wake_group is not None:
+ wake_group.setVisible(True)
+ wake_group.setEnabled(True)
+
+ for engine_name, engine_group in getattr(
+ self,
+ 'mesh_engine_specific_groups',
+ {},
+ ).items():
+ is_active = engine_name == self.mesh_engine
+ engine_group.setVisible(is_active)
+ engine_group.setEnabled(is_active)
+
+ wake_group = getattr(self, 'mesh_wake_group', None)
+ if wake_group is not None:
+ farfield_selector = getattr(
+ self,
+ 'experimental_o_farfield_shape',
+ None,
+ )
+ farfield_shape = (
+ farfield_selector.currentData()
+ if farfield_selector is not None else 'wind_tunnel'
+ )
+ wake_group.setEnabled(
+ not (
+ self.mesh_engine == 'experimental_o' and
+ farfield_shape == 'circle'
+ )
+ )
+ create_mesh_panel = getattr(self, 'createMeshActionPanel', None)
+ if create_mesh_panel is not None:
+ create_mesh_panel.setVisible(not is_hybrid_staged)
+ self.hybrid_controls_changed()
+ self.hybrid_staged_controls_changed()
+
+ def hybrid_controls_changed(self, _index=None):
+ stage2_enabled = bool(
+ getattr(self, 'hybrid_stage2_enabled', None) and
+ self.hybrid_stage2_enabled.isChecked()
+ )
+ stage1_enabled = bool(
+ getattr(self, 'hybrid_stage1_enabled', None) and
+ self.hybrid_stage1_enabled.isChecked()
+ )
+
+ for control_name in (
+ 'hybrid_stage2_sweeps',
+ 'hybrid_redistribute_u',
+ 'hybrid_redistribute_v'):
+ control = getattr(self, control_name, None)
+ if control is not None:
+ control.setEnabled(stage2_enabled)
+
+ stage1_algorithm = getattr(self, 'hybrid_stage1_algorithm', None)
+ algorithm_key = (
+ stage1_algorithm.currentData()
+ if stage1_algorithm is not None else 'angle_based'
+ )
+ stage1_controls_enabled = stage1_enabled and algorithm_key != 'none'
+
+ for control_name in (
+ 'hybrid_stage1_algorithm',
+ 'hybrid_stage1_iterations',
+ 'hybrid_stage1_tolerance'):
+ control = getattr(self, control_name, None)
+ if control is not None:
+ control.setEnabled(stage1_enabled)
+
+ relaxation = getattr(self, 'hybrid_stage1_relaxation', None)
+ if relaxation is not None:
+ relaxation.setEnabled(stage1_controls_enabled and algorithm_key == 'elliptic')
+
+ def hybrid_staged_controls_changed(self, _index=None):
+ engine_active = getattr(self, 'mesh_engine', 'standard') == 'hybrid_staged'
+ airfoil = self._active_airfoil()
+ airfoil_ready = airfoil is not None and airfoil.has_spline
+ wind_tunnel = getattr(self, 'wind_tunnel', None)
+ stage_state = getattr(wind_tunnel, 'hybrid_stage_state', {}) or {}
+ stage4_ready = bool(
+ wind_tunnel is not None and
+ getattr(wind_tunnel, 'mesh_engine', None) == 'hybrid_staged' and
+ stage_state.get('stage4', False)
+ )
+
+ for control_name in (
+ 'hybrid_staged_stage2_sweeps',
+ 'hybrid_staged_redistribute_u',
+ 'hybrid_staged_redistribute_v'):
+ control = getattr(self, control_name, None)
+ if control is not None:
+ control.setEnabled(engine_active and stage4_ready)
+
+ algorithm = getattr(self, 'hybrid_staged_stage1_algorithm', None)
+ algorithm_key = (
+ algorithm.currentData()
+ if algorithm is not None else 'angle_based'
+ )
+ for control_name in (
+ 'hybrid_staged_stage1_algorithm',
+ 'hybrid_staged_stage1_iterations',
+ 'hybrid_staged_stage1_tolerance'):
+ control = getattr(self, control_name, None)
+ if control is not None:
+ control.setEnabled(engine_active and stage4_ready)
+
+ relaxation = getattr(self, 'hybrid_staged_stage1_relaxation', None)
+ if relaxation is not None:
+ relaxation.setEnabled(
+ engine_active and
+ stage4_ready and
+ algorithm_key == 'elliptic'
+ )
+
+ stage4_button = getattr(self, 'hybridStagedStage4Button', None)
+ if stage4_button is not None:
+ stage4_button.setEnabled(engine_active and airfoil_ready)
+
+ stage2_button = getattr(self, 'hybridStagedStage2Button', None)
+ if stage2_button is not None:
+ stage2_button.setEnabled(engine_active and stage4_ready)
+
+ stage1_button = getattr(self, 'hybridStagedStage1Button', None)
+ if stage1_button is not None:
+ stage1_button.setEnabled(engine_active and stage4_ready)
+
+ status = getattr(self, 'hybrid_staged_status_label', None)
+ if status is not None:
+ status.setText(self._hybridStagedStatusText())
+
+ def _active_airfoil(self):
+ return getattr(self.mw, 'airfoil', None)
+
+ def _viewerSubject(self):
+ return getattr(self.mw, '_viewer_subject', 'airfoil')
+
+ def _activeViewerTarget(self):
+ if self._viewerSubject() == 'metric_test':
+ return getattr(self, 'metric_test_display', None)
+ return self._active_airfoil()
+
+ def _toggleAirfoilItem(self, attribute_name):
+ target = self._activeViewerTarget()
+ if target is None:
+ return
+
+ item = getattr(target, attribute_name, None)
+ if item is None:
+ return
+
+ item.setVisible(not item.isVisible())
+
+ def _setMetricTestViewerControls(self):
+ controls = (
+ 'airfoil_points_checkbox',
+ 'airfoil_raw_contour_checkbox',
+ 'airfoil_spline_points_checkbox',
+ 'airfoil_spline_contour_checkbox',
+ 'airfoil_spline_fill_checkbox',
+ 'airfoil_chord_checkbox',
+ 'leading_edge_circle_checkbox',
+ 'airfoil_camber_line_checkbox',
+ 'airfoil_camber_circles_checkbox',
+ 'airfoil_max_thickness_checkbox',
+ 'airfoil_max_camber_checkbox',
+ )
+ for name in controls:
+ button = getattr(self.mw.mainArea, name, None)
+ if button is None:
+ continue
+ blocker = QtCore.QSignalBlocker(button)
+ button.setChecked(False)
+ button.setEnabled(False)
+ del blocker
+
+ for name in ('mesh_checkbox', 'mesh_blocks_checkbox'):
+ button = getattr(self.mw.mainArea, name, None)
+ if button is None:
+ continue
+ blocker = QtCore.QSignalBlocker(button)
+ button.setChecked(True)
+ button.setEnabled(True)
+ del blocker
+
+ def showMetricTestScene(self):
+ result = getattr(self, 'metric_test_result', None)
+ if result is None:
+ return
+
+ renderer = MeshGraphics.MeshSceneRenderer(self.mw)
+ self.mw._viewer_subject = 'metric_test'
+ self.metric_test_display = SimpleNamespace(mesh=None, mesh_blocks=None)
+ self.mw.scene.clear()
+ renderer.render_unstructured_mesh(
+ self.metric_test_display,
+ result.mesh_data,
+ )
+ renderer.render_constraint_outline(
+ self.metric_test_display,
+ result.loops,
+ )
+ self._setMetricTestViewerControls()
+ self.mw.slots.onViewAll()
+
+ def restoreAirfoilScene(self):
+ if self._viewerSubject() != 'metric_test':
+ return
+
+ self.metric_test_display = None
+ self.mw._viewer_subject = 'airfoil'
+ airfoil = self._active_airfoil()
+ if airfoil is not None:
+ self.mw.slots.activateAirfoil(airfoil)
+ return
+
+ self.mw.scene.clear()
+ self.mw.mainArea.resetAirfoilViewControls()
+
+ def _smootherToleranceValue(self):
+ return self._parsedFloatText(self.smoother_tolerance, 1.0e-5)
+
+ def _experimentalToleranceValue(self):
+ return self._parsedFloatText(
+ self.experimental_smoothing_tolerance,
+ 1.0e-5,
+ )
+
+ def _experimentalOToleranceValue(self):
+ return self._parsedFloatText(
+ self.experimental_o_smoothing_tolerance,
+ 1.0e-5,
+ )
+
+ def _hybridToleranceValue(self):
+ return self._parsedFloatText(self.hybrid_stage1_tolerance, 1.0e-4)
+
+ def _hybridStagedToleranceValue(self):
+ return self._parsedFloatText(
+ self.hybrid_staged_stage1_tolerance,
+ 1.0e-4,
+ )
+
+ def _parsedFloatText(self, widget, default):
+ text = widget.text().strip()
+ if not text:
+ return float(default)
+
+ normalized = text.replace(' ', '').replace(',', '.')
+ value = float(normalized)
+ if normalized != text:
+ widget.setText(normalized)
+ return value
+
+ def _hybridStagedStatusText(self):
+ airfoil = self._active_airfoil()
+ if airfoil is None:
+ return 'Load an airfoil and prepare its contour before running Stage 4.'
+ if not airfoil.has_spline:
+ return 'Prepare the contour first. Stage 4 uses the prepared spline geometry.'
+
+ wind_tunnel = getattr(self, 'wind_tunnel', None)
+ if wind_tunnel is None or getattr(wind_tunnel, 'mesh_engine', None) != 'hybrid_staged':
+ return 'Stage 4 has not been run yet. Run Stage 4 to build the layout-driven mesh.'
+
+ stage_state = getattr(wind_tunnel, 'hybrid_stage_state', {}) or {}
+ if not stage_state.get('stage4', False):
+ return 'Stage 4 has not been run yet. Run Stage 4 to build the layout-driven mesh.'
+
+ layout_plan = getattr(wind_tunnel, 'layout_plan', None)
+ stage4_metadata = getattr(wind_tunnel, 'pipeline_metadata', {}).get('stage4', {})
+ block_count = len(getattr(wind_tunnel, 'blocks', []) or [])
+ element_count = int(stage4_metadata.get('element_count', 0))
+ singularity_count = int(stage4_metadata.get('singularity_count', 0))
+ if layout_plan is not None:
+ element_count = max(element_count, len(getattr(layout_plan, 'boundary_loops', [])))
+ singularity_count = max(
+ singularity_count,
+ len(getattr(layout_plan, 'singularities', [])),
+ )
+
+ stage2_text = 'applied' if stage_state.get('stage2', False) else 'not applied'
+ stage1_text = 'applied' if stage_state.get('stage1', False) else 'not applied'
+ return (
+ f'Stage 4 ready: {block_count} blocks across {element_count} element loops, '
+ f'{singularity_count} singularities. Stage 2 is {stage2_text}. '
+ f'Stage 1 is {stage1_text}. Run Stage 4 again any time to restart from the geometry.'
+ )
+
+ def selectedSplineMethod(self):
+ method_selector = getattr(self, 'spline_method', None)
+ if method_selector is None:
+ return METHOD_CST_MODIFIED
+ method = method_selector.currentData()
+ return method or METHOD_CST_MODIFIED
+
+ def selectedSplineMethodLabel(self):
+ if self.selectedSplineMethod() == METHOD_CST_MODIFIED:
+ return 'CST'
+ return 'B-spline'
+
+ def _activeCstParameterData(self, airfoil=None):
+ airfoil = airfoil or self._active_airfoil()
+ if airfoil is None or not getattr(airfoil, 'has_spline', False):
+ return None
+
+ spline_data = getattr(airfoil, 'spline_data', None)
+ if spline_data is None:
+ return None
+
+ try:
+ return cst_parameters_from_spline_data(spline_data)
+ except ValueError:
+ return None
+
+ def updateSplineMethodControls(self):
+ cst_enabled = self.selectedSplineMethod() == METHOD_CST_MODIFIED
+ cst_order = getattr(self, 'cst_order', None)
+ if cst_order is not None:
+ cst_order.setEnabled(cst_enabled)
+
+ def _geometryMethodLabel(self, airfoil):
+ spline_data = getattr(airfoil, 'spline_data', None)
+ if spline_data is None:
+ return self.selectedSplineMethodLabel()
+
+ metadata = getattr(spline_data, 'metadata', {}) or {}
+ label = metadata.get('label')
+ if label:
+ return label
+ if getattr(spline_data, 'method', METHOD_BSPLINE) == METHOD_CST_MODIFIED:
+ return 'CST'
+ return 'B-spline'
+
+ def spline_refine_settings(self):
+ return ToolboxServices.SplineRefineSettings(
+ tolerance=self.tolerance.value(),
+ points=self.points.value(),
+ ref_te=self.ref_te.value(),
+ ref_te_n=self.ref_te_n.value(),
+ ref_te_ratio=self.ref_te_ratio.value(),
+ method=self.selectedSplineMethod(),
+ cst_order=self.cst_order.value(),
+ )
+
+ def trailing_edge_settings(self):
+ return ToolboxServices.TrailingEdgeSettings(
+ upper_blend=self.blend_u.value() / 100.0,
+ lower_blend=self.blend_l.value() / 100.0,
+ upper_exponent=self.exponent_u.value(),
+ lower_exponent=self.exponent_l.value(),
+ thickness=self.thickness.value(),
+ )
+
+ def mesh_generation_settings(self):
+ trailing_edge_divisions = (
+ self.te_div.value() if hasattr(self, 'te_div') else 3
+ )
+ tunnel_smoothing_iterations = (
+ self.smoother_iterations.value()
+ if hasattr(self, 'smoother_iterations') else 10
+ )
+ tunnel_smoothing_tolerance = (
+ self._smootherToleranceValue()
+ if hasattr(self, 'smoother_tolerance') else 1.0e-4
+ )
+ tunnel_outer_boundary_slide = (
+ self.outer_boundary_slide.value()
+ if hasattr(self, 'outer_boundary_slide') else 0.0
+ )
+ tunnel_elliptic_relaxation = (
+ self.elliptic_relaxation.value()
+ if hasattr(self, 'elliptic_relaxation') else 0.40
+ )
+ protected_guide_relaxation = (
+ self.protected_guide_relaxation.value()
+ if hasattr(self, 'protected_guide_relaxation') else 0.25
+ )
+ protected_guide_layers = (
+ self.protected_guide_layers.value()
+ if hasattr(self, 'protected_guide_layers') else 5
+ )
+ protected_guide_decay = (
+ self.protected_guide_decay.value()
+ if hasattr(self, 'protected_guide_decay') else 0.80
+ )
+ protected_guide_smoothing = (
+ self.protected_guide_smoothing.value()
+ if hasattr(self, 'protected_guide_smoothing') else 3
+ )
+ if getattr(self, 'mesh_engine', 'standard') == 'hybrid_staged':
+ trailing_edge_divisions = self.hybrid_staged_te_divisions.value()
+ tunnel_smoothing_iterations = 10
+ tunnel_smoothing_tolerance = 1.0e-4
+ tunnel_outer_boundary_slide = 0.0
+ tunnel_elliptic_relaxation = 0.40
+ protected_guide_relaxation = 0.25
+ protected_guide_layers = 5
+ protected_guide_decay = 0.80
+ protected_guide_smoothing = 3
+
+ return Meshing.WindtunnelMeshSettings(
+ airfoil=MeshBuilders.AirfoilBlockSettings(
+ name='block_airfoil',
+ divisions=self.points_n.value(),
+ growth=self.ratio.value(),
+ thickness=self.normal_thickness.value(),
+ ),
+ trailing_edge=MeshBuilders.TrailingEdgeBlockSettings(
+ name='block_TE',
+ trailing_edge_divisions=trailing_edge_divisions,
+ thickness=(
+ self.length_te.value() if hasattr(self, 'length_te') else
+ self.normal_thickness.value()
+ ),
+ divisions=(
+ self.points_te.value() if hasattr(self, 'points_te') else
+ max(8, self.points_n.value())
+ ),
+ growth=(
+ self.ratio_te.value() if hasattr(self, 'ratio_te') else
+ max(1.0, self.ratio.value())
+ ),
+ ),
+ tunnel=MeshBuilders.TunnelBlockSettings(
+ name='block_tunnel',
+ tunnel_height=self.tunnel_height.value(),
+ divisions_height=self.divisions_height.value(),
+ height_growth=(
+ self.ratio_height.value() if hasattr(self, 'ratio_height')
+ else 1.0
+ ),
+ distribution=(
+ self.dist.currentText() if hasattr(self, 'dist')
+ else 'symmetric'
+ ),
+ smoothing_algorithm=getattr(
+ self,
+ 'smoothing_algorithm',
+ 'elliptic',
+ ),
+ smoothing_iterations=tunnel_smoothing_iterations,
+ smoothing_tolerance=tunnel_smoothing_tolerance,
+ outer_boundary_slide=tunnel_outer_boundary_slide,
+ elliptic_relaxation=tunnel_elliptic_relaxation,
+ protected_guide_relaxation=protected_guide_relaxation,
+ protected_guide_layers=protected_guide_layers,
+ protected_guide_decay=protected_guide_decay,
+ protected_guide_smoothing=protected_guide_smoothing,
+ ),
+ wake=MeshBuilders.WakeBlockSettings(
+ name='block_tunnel_wake',
+ tunnel_wake=self.tunnel_wake.value(),
+ divisions=(
+ self.divisions_wake.value()
+ if hasattr(self, 'divisions_wake') else 120
+ ),
+ growth=(
+ self.ratio_wake.value()
+ if hasattr(self, 'ratio_wake') else 1.0
+ ),
+ spread=(
+ self.spread.value() / 100.0
+ if hasattr(self, 'spread') else 0.3
+ ),
+ ),
+ engine=getattr(self, 'mesh_engine', 'metric_based'),
+ metric_based=Meshing.HybridQuadPipelineSettings(
+ layout_strategy=self.metric_layout_strategy.currentData(),
+ protect_near_wall=False,
+ singularity_template=self.metric_singularity_template.currentData(),
+ stage2=Meshing.HybridStage2Settings(enabled=False, sweeps=0),
+ stage1=Meshing.HybridStage1Settings(
+ enabled=False,
+ algorithm='none',
+ iterations=0,
+ ),
+ ),
+ hybrid=Meshing.HybridQuadPipelineSettings(
+ layout_strategy='multi_element_oc',
+ ),
+ hybrid_staged=Meshing.HybridQuadPipelineSettings(
+ layout_strategy='multi_element_oc',
+ protect_near_wall=True,
+ stage2=Meshing.HybridStage2Settings(
+ enabled=True,
+ sweeps=2,
+ redistribute_u=True,
+ redistribute_v=True,
+ ),
+ stage1=Meshing.HybridStage1Settings(
+ enabled=True,
+ algorithm='angle_based',
+ iterations=15,
+ tolerance=1.0e-4,
+ relaxation=0.60,
+ ),
+ ),
+ experimental=Meshing.ExperimentalCGridSettings(
+ name='block_experimental_c_grid',
+ surface_points=0,
+ normal_divisions=100,
+ first_layer_thickness=0.004,
+ wake_points=100,
+ farfield_wake_length_ratio=7.0,
+ farfield_wake_start_ratio=10.0,
+ initial_smoothing_iterations=100,
+ final_smoothing_iterations=20,
+ local_te_smoothing_iterations=10,
+ smoothing_tolerance=1.0e-5,
+ relaxation=0.60,
+ ),
+ experimental_o=Meshing.ExperimentalOGridSettings(
+ name='block_experimental_o_grid',
+ surface_points=0,
+ normal_divisions=100,
+ first_layer_thickness=0.004,
+ farfield_shape='wind_tunnel',
+ initial_smoothing_iterations=100,
+ final_smoothing_iterations=20,
+ smoothing_tolerance=1.0e-5,
+ relaxation=0.60,
+ ),
+ structured=(
+ ToolboxPages.structured_settings_from_toolbox(self)
+ if hasattr(self, 'structured_topology')
+ else Meshing.StructuredMeshSettings()
+ ),
+ )
+
+ def mesh_export_settings(self):
+ formats = []
+ if self.check_FIRE.isChecked():
+ formats.append('flma')
+ if self.check_SU2.isChecked():
+ formats.append('su2')
+ if self.check_GMSH.isChecked():
+ formats.append('gmsh')
+ if self.check_VTU.isChecked():
+ formats.append('vtu')
+
+ return ToolboxServices.MeshExportSettings(
+ boundary_definitions={
+ 'airfoil': self.lineedit_airfoil.text(),
+ 'inlet': self.lineedit_inlet.text(),
+ 'outlet': self.lineedit_outlet.text(),
+ 'top': self.lineedit_top.text(),
+ 'bottom': self.lineedit_bottom.text(),
+ },
+ formats=formats,
+ )
+
+ def metric_test_settings(self):
+ return ToolboxServices.MetricTestSettings(
+ example=self.metric_test_example.currentData(),
+ width=self.metric_test_width.value(),
+ height=self.metric_test_height.value(),
+ hole_radius=self.metric_test_hole_radius.value(),
+ hole_spacing=self.metric_test_hole_spacing.value(),
+ outer_resolution=self.metric_test_outer_resolution.value(),
+ hole_resolution=self.metric_test_hole_resolution.value(),
+ interior_x=self.metric_test_interior_x.value(),
+ interior_y=self.metric_test_interior_y.value(),
+ airfoil_path=getattr(self.mw, 'DEFAULT_AIRFOIL', None),
+ )
+
+ def selected_contour_analysis_quantity(self):
+ if self.cpb2.isChecked():
+ return 'curvature'
+ if self.cpb3.isChecked():
+ return 'radius'
+ return 'gradient'
+
+ def toggleRawPoints(self):
+ self._toggleAirfoilItem('polygonMarkersGroup')
+
+ def toggleRawContour(self):
+ self._toggleAirfoilItem('contourPolygon')
+
+ def toggleSplinePoints(self):
+ self._toggleAirfoilItem('splineMarkersGroup')
+
+ def toggleSpline(self):
+ self._toggleAirfoilItem('contourSpline')
+
+ def toggleChord(self):
+ self._toggleAirfoilItem('chord')
+
+ def toggleMesh(self):
+ self._toggleAirfoilItem('mesh')
+
+ def toggleLeCircle(self):
+ self._toggleAirfoilItem('le_circle')
+
+ def toggleMeshBlocks(self):
+ self._toggleAirfoilItem('mesh_blocks')
+
+ def toggleCamberLine(self):
+ self._toggleAirfoilItem('camberline')
+
+ def toggleCamberCircles(self):
+ self._toggleAirfoilItem('camber_circles')
+
+ def toggleMaxThicknessMarker(self):
+ self._toggleAirfoilItem('max_thickness_marker')
+
+ def toggleMaxCamberMarker(self):
+ self._toggleAirfoilItem('max_camber_marker')
+
+ def splineFillEnabled(self):
+ fill_toggle = getattr(
+ getattr(self.mw, 'mainArea', None),
+ 'airfoil_spline_fill_checkbox',
+ None,
+ )
+ return bool(fill_toggle and fill_toggle.isChecked())
+
+ def applySplineFillPreference(self, airfoil=None):
+ target = airfoil or self._active_airfoil()
+ if target is None:
+ return
+ target.setSplineFillEnabled(self.splineFillEnabled())
+
+ def toggleSplineFill(self, _checked=None):
+ self.applySplineFillPreference()
+
+ def showCstParameters(self, _checked=None):
+ dialog, error_message = self.createCstParametersDialog()
+ if dialog is None:
+ self.mw.slots.messageBox(error_message)
+ return
+ dialog.exec()
+
+ def createCstParametersDialog(self):
+ airfoil = self._active_airfoil()
+ if airfoil is None:
+ return None, 'No airfoil loaded.'
+
+ parameter_data = self._activeCstParameterData(airfoil)
+ if parameter_data is None:
+ return (
+ None,
+ 'The current prepared contour does not have CST parameters.',
+ )
+
+ return self.buildCstParametersDialog(airfoil, parameter_data), None
+
+ def buildCstParametersDialog(self, airfoil, parameter_data):
+ dialog = QtWidgets.QDialog(self.mw)
+ dialog.setWindowTitle('CST Parameters')
+ dialog.resize(760, 560)
+
+ layout = QtWidgets.QVBoxLayout()
+ textedit = QtWidgets.QTextEdit()
+ textedit.setReadOnly(True)
+ textedit.setAcceptRichText(False)
+ textedit.setLineWrapMode(QtWidgets.QTextEdit.LineWrapMode.NoWrap)
+ textedit.setFont(
+ QtGui.QFontDatabase.systemFont(
+ QtGui.QFontDatabase.SystemFont.FixedFont
+ )
+ )
+ textedit.setPlainText(
+ f'Airfoil: {airfoil.name}\n\n'
+ f'{format_cst_parameters_text(parameter_data)}'
+ )
+ layout.addWidget(textedit)
+
+ button_row = QtWidgets.QHBoxLayout()
+ copy_button = QtWidgets.QPushButton('Copy')
+ export_png_button = QtWidgets.QPushButton('Export PNG...')
+ export_json_button = QtWidgets.QPushButton('Export JSON...')
+ export_csv_button = QtWidgets.QPushButton('Export CSV...')
+ close_button = QtWidgets.QPushButton('Close')
+
+ copy_button.clicked.connect(
+ lambda *_: QtGui.QGuiApplication.clipboard().setText(
+ textedit.toPlainText()
+ )
+ )
+ export_png_button.clicked.connect(
+ lambda *_: UiExport.export_widget_as_png(
+ mainwindow=self.mw,
+ widget=dialog,
+ default_name='cst_parameters_dialog.png',
+ dialog_title='Export CST Parameters Dialog As',
+ success_label='CST parameters dialog',
+ rounded_radius=18.0,
+ )
+ )
+ export_json_button.clicked.connect(
+ lambda *_: self.exportCst(default_extension='.json')
+ )
+ export_csv_button.clicked.connect(
+ lambda *_: self.exportCst(default_extension='.csv')
+ )
+ close_button.clicked.connect(dialog.accept)
+
+ button_row.addWidget(copy_button)
+ button_row.addStretch(1)
+ button_row.addWidget(export_png_button)
+ button_row.addWidget(export_json_button)
+ button_row.addWidget(export_csv_button)
+ button_row.addWidget(close_button)
+ layout.addLayout(button_row)
+
+ dialog.setLayout(layout)
+ return dialog
+
+ def spline_and_refine(self):
+ self.workflow.spline_and_refine(self.spline_refine_settings())
+ self.refreshWorkflowState()
+
+ def makeTrailingEdge(self):
+ self.workflow.add_trailing_edge(self.trailing_edge_settings())
+ self.refreshWorkflowState()
+
+ def generateMesh(self):
+ wind_tunnel = self.workflow.generate_mesh(
+ self.mesh_generation_settings()
+ )
+ if wind_tunnel is not None:
+ self.wind_tunnel = wind_tunnel
+ self.refreshWorkflowState()
+
+ def generateHybridStage4(self):
+ wind_tunnel = self.workflow.generate_hybrid_stage4(
+ self.mesh_generation_settings()
+ )
+ if wind_tunnel is not None:
+ self.wind_tunnel = wind_tunnel
+ self.refreshWorkflowState()
+
+ def applyHybridStage2(self):
+ wind_tunnel = self.workflow.apply_hybrid_stage2(
+ self.wind_tunnel,
+ self.mesh_generation_settings(),
+ )
+ if wind_tunnel is not None:
+ self.wind_tunnel = wind_tunnel
+ self.refreshWorkflowState()
+
+ def applyHybridStage1(self):
+ wind_tunnel = self.workflow.apply_hybrid_stage1(
+ self.wind_tunnel,
+ self.mesh_generation_settings(),
+ )
+ if wind_tunnel is not None:
+ self.wind_tunnel = wind_tunnel
+ self.refreshWorkflowState()
+
+ def metricTestControlsChanged(self, _index=None):
+ example = self.metric_test_example.currentData()
+ uses_spacing = example == 'rectangle_two_circles'
+ uses_circle_radius = example in ('rectangle_circle', 'rectangle_two_circles')
+ uses_hole_resolution = example in (
+ 'rectangle_circle',
+ 'rectangle_two_circles',
+ )
+
+ self.metric_test_hole_radius.setEnabled(uses_circle_radius)
+ self.metric_test_hole_spacing.setEnabled(uses_spacing)
+ self.metric_test_hole_resolution.setEnabled(uses_hole_resolution)
+
+ def generateMetricTest(self):
+ result = self.workflow.generate_metric_test(
+ self.metric_test_settings()
+ )
+ if result is None:
+ return
+
+ self.metric_test_result = result
+ stats = result.mesh.mesh_statistics()
+ warning_text = ''
+ if result.warnings:
+ warning_text = ' Warning: ' + ' '.join(result.warnings)
+ self.metric_test_status.setText(
+ f'{stats.cell_count} triangles, {stats.vertex_count} vertices, '
+ f'{len(result.loops)} loops.{warning_text}'
+ )
+ if self.currentIndex() == self.tb7:
+ self.showMetricTestScene()
+ self.refreshWorkflowState()
+
+ def clearMetricTest(self):
+ self.metric_test_result = None
+ self.metric_test_display = None
+ self.metric_test_status.setText(
+ 'Choose an example and click Generate Example to draw the triangulation in the viewer.'
+ )
+ if self.currentIndex() == self.tb7:
+ self.restoreAirfoilScene()
+ self.refreshWorkflowState()
+
+ def analyzeAirfoil(self):
+ self.workflow.prepare_contour_analysis()
+
+ def drawContourAnalysis(self):
+ self.workflow.draw_contour_analysis(
+ self.selected_contour_analysis_quantity()
+ )
+
+ def exportMesh(self):
+ airfoil = self._active_airfoil()
+ if airfoil is None:
+ self.mw.slots.messageBox('No airfoil loaded.')
+ return
+ if self.wind_tunnel is None:
+ self.mw.slots.messageBox('Please generate a mesh first.')
+ return
+
+ export_settings = self.mesh_export_settings()
+ if not export_settings.formats:
+ self.mw.slots.messageBox('Please select at least one export format.')
+ return
+
+ filename = FileOperations.choose_mesh_export_basename(
+ airfoil,
+ export_settings.formats,
+ mainwindow=self.mw,
+ )
+ if not filename:
+ logger.info('No file selected. Nothing saved.')
+ return
+
+ self.workflow.export_mesh(
+ self.wind_tunnel,
+ filename,
+ export_settings,
+ )
+
+ def exportContour(self, _checked=None):
+ airfoil = self._active_airfoil()
+ if airfoil is None:
+ self.mw.slots.messageBox('No airfoil loaded.')
+ return
+
+ filename = FileOperations.choose_contour_save_filename(
+ airfoil,
+ title='Export Contour',
+ mainwindow=self.mw,
+ )
+ if not filename:
+ logger.info('No file selected. Nothing saved.')
+ return
+ self.workflow.export_contour(filename)
+
+ def exportCamber(self, _checked=None):
+ airfoil = self._active_airfoil()
+ if airfoil is None:
+ self.mw.slots.messageBox('No airfoil loaded.')
+ return
+
+ filename = FileOperations.choose_camber_save_filename(
+ airfoil,
+ title='Export Camber',
+ mainwindow=self.mw,
+ )
+ if not filename:
+ logger.info('No file selected. Nothing saved.')
+ return
+ self.workflow.export_camber(filename)
+
+ def exportCst(self, _checked=None, default_extension='.json'):
+ airfoil = self._active_airfoil()
+ if airfoil is None:
+ self.mw.slots.messageBox('No airfoil loaded.')
+ return
+
+ filename = FileOperations.choose_cst_save_filename(
+ airfoil,
+ title='Export CST Parameters',
+ default_extension=default_extension,
+ mainwindow=self.mw,
+ )
+ if not filename:
+ logger.info('No file selected. Nothing saved.')
+ return
+ self.workflow.export_cst(filename)
diff --git a/src/ToolboxBoundaryConditions.py b/src/ToolboxBoundaryConditions.py
new file mode 100644
index 0000000..02dbeed
--- /dev/null
+++ b/src/ToolboxBoundaryConditions.py
@@ -0,0 +1,115 @@
+from __future__ import annotations
+
+from dataclasses import dataclass
+
+import numpy as np
+
+
+@dataclass(slots=True)
+class BoundaryConditionInputs:
+ reynolds: float
+ chord: float
+ aoa_from: float
+ aoa_to: float
+ aoa_step: float
+ turbulence: float
+ length_scale: float
+ pressure: float
+ temperature_c: float
+ yplus: float
+
+
+@dataclass(slots=True)
+class BoundaryConditionResults:
+ density: float
+ dynamic_viscosity: float
+ kinematic_viscosity: float
+ aoa: np.ndarray
+ u_velocity: np.ndarray
+ v_velocity: np.ndarray
+ wall_distance: float
+ tke: float
+ temperature_k: float
+
+
+def calculate_boundary_conditions(inputs: BoundaryConditionInputs):
+ gas_constant = 287.14
+ temperature_k = inputs.temperature_c + 273.15
+ density = inputs.pressure / gas_constant / temperature_k
+ num = int((inputs.aoa_to - inputs.aoa_from) / inputs.aoa_step + 1)
+ aoa = np.linspace(inputs.aoa_from, inputs.aoa_to, num=num, endpoint=True)
+
+ dynamic_viscosity = _dynamic_viscosity(temperature_k)
+ kinematic_viscosity = dynamic_viscosity / density
+ velocity = inputs.reynolds / inputs.chord * kinematic_viscosity
+ uprime = velocity * inputs.turbulence / 100.0
+ tke = 3.0 / 2.0 * uprime**2
+ u_velocity = velocity * np.cos(aoa * np.pi / 180.0)
+ v_velocity = velocity * np.sin(aoa * np.pi / 180.0)
+
+ reynolds_number = inputs.reynolds
+ log_re = np.power(np.log10(reynolds_number), 2.58)
+ if reynolds_number < 5.1e6:
+ friction_coefficient = 0.455 / log_re
+ else:
+ friction_coefficient = 0.455 / log_re - 1700.0 / reynolds_number
+
+ wall_shear_stress = friction_coefficient * 0.5 * density * velocity**2
+ friction_velocity = np.sqrt(wall_shear_stress / density)
+ wall_distance = (
+ inputs.yplus * dynamic_viscosity / density / friction_velocity
+ )
+
+ return BoundaryConditionResults(
+ density=density,
+ dynamic_viscosity=dynamic_viscosity,
+ kinematic_viscosity=kinematic_viscosity,
+ aoa=aoa,
+ u_velocity=u_velocity,
+ v_velocity=v_velocity,
+ wall_distance=wall_distance,
+ tke=tke,
+ temperature_k=temperature_k,
+ )
+
+
+def format_boundary_conditions_html(inputs, results):
+ newline = ' '
+ html = 'CFD Boundary Conditions ' + newline
+ html += f'Reynolds (-): {inputs.reynolds}' + newline
+ html += f'Pressure (Pa): {inputs.pressure}' + newline
+ html += f'Temperature (C): {inputs.temperature_c}' + newline
+ html += f'Temperature (K): {results.temperature_k}' + newline
+ html += f'Density (kg/(m3 )): {results.density}' + newline
+ html += (
+ f'Dynamic viscosity (kg/(m.s)): {results.dynamic_viscosity}' + newline
+ )
+ html += (
+ f'Kinematic viscosity (m/s) {results.kinematic_viscosity}:'
+ + newline
+ )
+ html += (
+ f'1st cell layer thickness (m) , for y+ ={inputs.yplus}'
+ + newline
+ )
+ html += '{:16.8f}'.format(results.wall_distance) + newline
+ html += 'TKE (m2 /s2 ), Length-scale (m) '
+ html += newline
+ html += '{:16.8f} {:16.8f}'.format(results.tke, inputs.length_scale)
+ html += newline
+ html += 'AOA (°) u-velocity (m/s) v-velocity (m/s) ' + newline
+ for index, _ in enumerate(results.u_velocity):
+ html += '{: >5.2f} {: >16.8f} {: >16.8f}{}'.format(
+ results.aoa[index],
+ results.u_velocity[index],
+ results.v_velocity[index],
+ newline,
+ )
+ return html
+
+
+def _dynamic_viscosity(temperature_k):
+ # Sutherland formula for air
+ c_const = 120.0
+ lamb = 1.512041288e-6
+ return lamb * temperature_k**1.5 / (temperature_k + c_const)
diff --git a/src/ToolboxPages.py b/src/ToolboxPages.py
new file mode 100644
index 0000000..a297857
--- /dev/null
+++ b/src/ToolboxPages.py
@@ -0,0 +1,25 @@
+from ToolboxPagesAirfoil import (
+ build_file_system_panel,
+ build_spline_refine_panel,
+)
+from ToolboxPagesAnalysis import build_contour_analysis_panel
+from ToolboxPagesCfd import (
+ build_aerodynamics_panel,
+ build_boundary_conditions_panel,
+)
+from ToolboxPagesMeshing import (
+ build_meshing_panel,
+ structured_settings_from_toolbox,
+)
+from ToolboxPagesMetricTests import build_metric_tests_panel
+
+__all__ = [
+ 'build_aerodynamics_panel',
+ 'build_boundary_conditions_panel',
+ 'build_contour_analysis_panel',
+ 'build_file_system_panel',
+ 'build_meshing_panel',
+ 'build_metric_tests_panel',
+ 'build_spline_refine_panel',
+ 'structured_settings_from_toolbox',
+]
diff --git a/src/ToolboxPagesAirfoil.py b/src/ToolboxPagesAirfoil.py
new file mode 100644
index 0000000..dea9164
--- /dev/null
+++ b/src/ToolboxPagesAirfoil.py
@@ -0,0 +1,355 @@
+from PySide6 import QtCore, QtWidgets
+
+from CSTAirfoil import METHOD_BSPLINE, METHOD_CST_MODIFIED
+import FileSystem
+from ToolboxWidgets import (
+ configure_form_layout,
+ make_page_label,
+ right_aligned_row,
+)
+
+
+def build_file_system_panel(toolbox):
+ toolbox.item_fs = QtWidgets.QWidget()
+ toolbox.item_fs.setSizePolicy(
+ QtWidgets.QSizePolicy.Expanding,
+ QtWidgets.QSizePolicy.Expanding,
+ )
+ layout = QtWidgets.QVBoxLayout()
+ layout.setContentsMargins(0, 0, 0, 0)
+ layout.setSpacing(12)
+ toolbox.item_fs.setLayout(layout)
+
+ local_root = FileSystem.local_library_root(toolbox.mw)
+ source_row = QtWidgets.QHBoxLayout()
+ source_row.setSpacing(8)
+
+ toolbox.airfoil_library_source_buttons = {}
+ source_group = QtWidgets.QButtonGroup(toolbox.item_fs)
+ for label, key, checked in (
+ ('Bundled', 'bundled', True),
+ ('Local', 'local', False),
+ ('All', 'all', False),
+ ):
+ button = QtWidgets.QRadioButton(label)
+ button.setProperty('librarySource', 'true')
+ button.setChecked(checked)
+ source_group.addButton(button)
+ toolbox.airfoil_library_source_buttons[key] = button
+ source_row.addWidget(button)
+ source_row.addStretch(1)
+ layout.addLayout(source_row)
+
+ toolbox.airfoil_library_search = QtWidgets.QLineEdit()
+ toolbox.airfoil_library_search.setObjectName('librarySearch')
+ toolbox.airfoil_library_search.setPlaceholderText(
+ 'Search airfoils'
+ )
+ layout.addWidget(toolbox.airfoil_library_search)
+
+ toolbox.airfoil_library_list = QtWidgets.QListWidget()
+ toolbox.airfoil_library_list.setObjectName('airfoilLibraryList')
+ toolbox.airfoil_library_list.setSelectionMode(
+ QtWidgets.QAbstractItemView.SingleSelection
+ )
+ layout.addWidget(toolbox.airfoil_library_list, stretch=1)
+
+ footer = QtWidgets.QWidget()
+ footer_layout = QtWidgets.QVBoxLayout()
+ footer_layout.setContentsMargins(0, 0, 0, 0)
+ footer_layout.setSpacing(8)
+ footer.setLayout(footer_layout)
+
+ toolbox.airfoil_library_status_label = QtWidgets.QLabel('')
+ toolbox.airfoil_library_status_label.setObjectName('libraryMeta')
+ toolbox.airfoil_library_status_label.setWordWrap(True)
+ toolbox.airfoil_library_status_label.setVisible(False)
+ footer_layout.addWidget(toolbox.airfoil_library_status_label)
+
+ action_row = QtWidgets.QHBoxLayout()
+ action_row.setSpacing(8)
+ toolbox.airfoil_library_open_button = QtWidgets.QPushButton('Open File...')
+ toolbox.airfoil_library_open_button.setObjectName('libraryActionButton')
+ toolbox.airfoil_library_import_button = QtWidgets.QPushButton('Add To Local...')
+ toolbox.airfoil_library_import_button.setObjectName('libraryActionButton')
+ toolbox.airfoil_library_import_button.setToolTip(
+ f'Copy an external airfoil into {local_root}'
+ )
+ toolbox.airfoil_library_load_button = QtWidgets.QPushButton('Load Selected')
+ toolbox.airfoil_library_load_button.setObjectName('libraryPrimaryButton')
+ toolbox.airfoil_library_load_button.setEnabled(False)
+
+ action_row.addWidget(toolbox.airfoil_library_open_button)
+ action_row.addWidget(toolbox.airfoil_library_import_button)
+ action_row.addStretch(1)
+ action_row.addWidget(toolbox.airfoil_library_load_button)
+ footer_layout.addLayout(action_row)
+
+ layout.addWidget(footer)
+
+ toolbox.airfoil_library_open_button.clicked.connect(toolbox.mw.slots.onOpen)
+ toolbox.airfoil_library_import_button.clicked.connect(
+ toolbox.importAirfoilToLocalLibrary
+ )
+ toolbox.airfoil_library_load_button.clicked.connect(
+ toolbox.loadSelectedLibraryAirfoil
+ )
+ toolbox.airfoil_library_search.textChanged.connect(
+ lambda *_: toolbox.refreshAirfoilLibrary()
+ )
+ for button in toolbox.airfoil_library_source_buttons.values():
+ button.toggled.connect(lambda checked, *_: checked and toolbox.refreshAirfoilLibrary())
+ toolbox.airfoil_library_list.currentItemChanged.connect(
+ toolbox.updateAirfoilLibraryDetails
+ )
+ toolbox.airfoil_library_list.itemDoubleClicked.connect(
+ toolbox.loadSelectedLibraryAirfoil
+ )
+
+ toolbox.refreshAirfoilLibrary()
+
+
+def build_spline_refine_panel(toolbox):
+ refine_card, refine_layout = _create_section_card(
+ 'Refine Contour',
+ 'Create a smoother working contour before meshing. CST uses analytic camber/thickness, while B-spline keeps the legacy simple camber.',
+ )
+
+ refine_form = QtWidgets.QFormLayout()
+ configure_form_layout(refine_form)
+
+ label = make_page_label('Geometry method')
+ toolbox.spline_method = QtWidgets.QComboBox()
+ toolbox.spline_method.addItem('B-spline (legacy)', METHOD_BSPLINE)
+ toolbox.spline_method.addItem('CST', METHOD_CST_MODIFIED)
+ toolbox.spline_method.setCurrentIndex(1)
+ refine_form.addRow(label, toolbox.spline_method)
+
+ label = make_page_label(u'Refine tolerance (°)')
+ toolbox.tolerance = QtWidgets.QDoubleSpinBox()
+ toolbox.tolerance.setSingleStep(0.1)
+ toolbox.tolerance.setDecimals(1)
+ toolbox.tolerance.setRange(50.0, 177.0)
+ toolbox.tolerance.setValue(172.0)
+ refine_form.addRow(label, toolbox.tolerance)
+
+ label = make_page_label('Spline points')
+ toolbox.points = QtWidgets.QSpinBox()
+ toolbox.points.setSingleStep(10)
+ toolbox.points.setRange(10, 1000)
+ toolbox.points.setValue(200)
+ refine_form.addRow(label, toolbox.points)
+ refine_layout.addLayout(refine_form)
+
+ refine_advanced_form = QtWidgets.QFormLayout()
+ configure_form_layout(refine_advanced_form)
+
+ label = make_page_label(u'TE old segments')
+ label.setToolTip(
+ 'Specify the number of segments at the trailing edge which should be refined.'
+ )
+ toolbox.ref_te = QtWidgets.QSpinBox()
+ toolbox.ref_te.setSingleStep(1)
+ toolbox.ref_te.setRange(1, 50)
+ toolbox.ref_te.setValue(3)
+ refine_advanced_form.addRow(label, toolbox.ref_te)
+
+ label = make_page_label(u'TE new segments')
+ toolbox.ref_te_n = QtWidgets.QSpinBox()
+ toolbox.ref_te_n.setSingleStep(1)
+ toolbox.ref_te_n.setRange(1, 100)
+ toolbox.ref_te_n.setValue(6)
+ refine_advanced_form.addRow(label, toolbox.ref_te_n)
+
+ label = make_page_label(u'TE ratio')
+ toolbox.ref_te_ratio = QtWidgets.QDoubleSpinBox()
+ toolbox.ref_te_ratio.setSingleStep(0.1)
+ toolbox.ref_te_ratio.setDecimals(1)
+ toolbox.ref_te_ratio.setRange(1., 10.)
+ toolbox.ref_te_ratio.setValue(3.0)
+ refine_advanced_form.addRow(label, toolbox.ref_te_ratio)
+
+ label = make_page_label('CST order')
+ toolbox.cst_order = QtWidgets.QSpinBox()
+ toolbox.cst_order.setSingleStep(1)
+ toolbox.cst_order.setRange(1, 20)
+ toolbox.cst_order.setValue(8)
+ refine_advanced_form.addRow(label, toolbox.cst_order)
+
+ refine_advanced = _create_advanced_widget(refine_advanced_form)
+ refine_toggle = _create_advanced_toggle(
+ 'More refine options',
+ refine_advanced,
+ )
+ refine_layout.addWidget(refine_toggle)
+ refine_layout.addWidget(refine_advanced)
+
+ toolbox.splineButton = QtWidgets.QPushButton('Prepare and Refine')
+ toolbox.splineButton.setObjectName('pagePrimaryActionButton')
+ refine_layout.addLayout(right_aligned_row(toolbox.splineButton))
+
+ toolbox.cstParametersButton = QtWidgets.QPushButton('Show CST Parameters...')
+ toolbox.cstParametersButton.setObjectName('pageSecondaryActionButton')
+ toolbox.cstParametersButton.setEnabled(False)
+ toolbox.cstParametersButton.setToolTip(
+ 'Display the current CST coefficients and export them as JSON or CSV.'
+ )
+ refine_layout.addLayout(right_aligned_row(toolbox.cstParametersButton))
+
+ trailing_card, trailing_layout = _create_section_card(
+ 'Trailing Edge',
+ 'Add finite thickness only when the mesh needs it.',
+ )
+
+ trailing_form = QtWidgets.QFormLayout()
+ configure_form_layout(trailing_form)
+
+ label = make_page_label(u'TE thickness (% chord)')
+ toolbox.thickness = QtWidgets.QDoubleSpinBox()
+ toolbox.thickness.setSingleStep(0.05)
+ toolbox.thickness.setDecimals(2)
+ toolbox.thickness.setRange(0.0, 10.0)
+ toolbox.thickness.setValue(0.4)
+ trailing_form.addRow(label, toolbox.thickness)
+ trailing_layout.addLayout(trailing_form)
+
+ trailing_advanced_form = QtWidgets.QFormLayout()
+ configure_form_layout(trailing_advanced_form)
+
+ label = make_page_label(u'Upper blend (% chord)')
+ toolbox.blend_u = QtWidgets.QDoubleSpinBox()
+ toolbox.blend_u.setSingleStep(1.0)
+ toolbox.blend_u.setDecimals(1)
+ toolbox.blend_u.setRange(0.1, 100.0)
+ toolbox.blend_u.setValue(30.0)
+ trailing_advanced_form.addRow(label, toolbox.blend_u)
+
+ label = make_page_label(u'Lower blend (% chord)')
+ toolbox.blend_l = QtWidgets.QDoubleSpinBox()
+ toolbox.blend_l.setSingleStep(1.0)
+ toolbox.blend_l.setDecimals(1)
+ toolbox.blend_l.setRange(0.1, 100.0)
+ toolbox.blend_l.setValue(30.0)
+ trailing_advanced_form.addRow(label, toolbox.blend_l)
+
+ label = make_page_label(u'Upper blend exponent')
+ toolbox.exponent_u = QtWidgets.QDoubleSpinBox()
+ toolbox.exponent_u.setSingleStep(0.1)
+ toolbox.exponent_u.setDecimals(1)
+ toolbox.exponent_u.setRange(1.0, 10.0)
+ toolbox.exponent_u.setValue(3.0)
+ trailing_advanced_form.addRow(label, toolbox.exponent_u)
+
+ label = make_page_label(u'Lower blend exponent')
+ toolbox.exponent_l = QtWidgets.QDoubleSpinBox()
+ toolbox.exponent_l.setSingleStep(0.1)
+ toolbox.exponent_l.setDecimals(1)
+ toolbox.exponent_l.setRange(1.0, 10.0)
+ toolbox.exponent_l.setValue(3.0)
+ trailing_advanced_form.addRow(label, toolbox.exponent_l)
+
+ trailing_advanced = _create_advanced_widget(trailing_advanced_form)
+ trailing_toggle = _create_advanced_toggle(
+ 'More TE options',
+ trailing_advanced,
+ )
+ trailing_layout.addWidget(trailing_toggle)
+ trailing_layout.addWidget(trailing_advanced)
+
+ toolbox.trailingButton = QtWidgets.QPushButton('Add Trailing Edge')
+ toolbox.trailingButton.setObjectName('pagePrimaryActionButton')
+ toolbox.trailingButton.setEnabled(False)
+ trailing_layout.addLayout(right_aligned_row(toolbox.trailingButton))
+
+ export_card, export_layout = _create_section_card(
+ 'Export',
+ 'Save the prepared contour and any derived CST or camber data.',
+ )
+ toolbox.exportContourButton = QtWidgets.QPushButton('Contour...')
+ toolbox.exportContourButton.setObjectName('pageSecondaryActionButton')
+ toolbox.exportContourButton.setEnabled(False)
+ toolbox.exportCamberButton = QtWidgets.QPushButton('Camber...')
+ toolbox.exportCamberButton.setObjectName('pageSecondaryActionButton')
+ toolbox.exportCamberButton.setEnabled(False)
+ toolbox.exportCstButton = QtWidgets.QPushButton('CST...')
+ toolbox.exportCstButton.setObjectName('pageSecondaryActionButton')
+ toolbox.exportCstButton.setEnabled(False)
+ export_layout.addLayout(
+ right_aligned_row(
+ toolbox.exportContourButton,
+ toolbox.exportCamberButton,
+ toolbox.exportCstButton,
+ )
+ )
+
+ vbl = QtWidgets.QVBoxLayout()
+ vbl.setContentsMargins(0, 0, 0, 0)
+ vbl.setSpacing(12)
+ vbl.addWidget(refine_card)
+ vbl.addWidget(trailing_card)
+ vbl.addWidget(export_card)
+ vbl.addStretch(1)
+
+ toolbox.item_cm = QtWidgets.QWidget()
+ toolbox.item_cm.setLayout(vbl)
+
+ toolbox.splineButton.clicked.connect(toolbox.spline_and_refine)
+ toolbox.cstParametersButton.clicked.connect(toolbox.showCstParameters)
+ toolbox.trailingButton.clicked.connect(toolbox.makeTrailingEdge)
+ toolbox.exportContourButton.clicked.connect(toolbox.exportContour)
+ toolbox.exportCamberButton.clicked.connect(toolbox.exportCamber)
+ toolbox.exportCstButton.clicked.connect(toolbox.exportCst)
+ toolbox.spline_method.currentIndexChanged.connect(
+ lambda *_: toolbox.updateSplineMethodControls()
+ )
+ toolbox.updateSplineMethodControls()
+
+
+def _create_section_card(title, hint=''):
+ card = QtWidgets.QFrame()
+ card.setProperty('pageSectionCard', 'true')
+
+ layout = QtWidgets.QVBoxLayout()
+ layout.setContentsMargins(14, 14, 14, 14)
+ layout.setSpacing(10)
+ card.setLayout(layout)
+
+ title_label = QtWidgets.QLabel(title)
+ title_label.setProperty('pageSectionTitle', 'true')
+ layout.addWidget(title_label)
+
+ if hint:
+ hint_label = QtWidgets.QLabel(hint)
+ hint_label.setProperty('pageSectionHint', 'true')
+ hint_label.setWordWrap(True)
+ layout.addWidget(hint_label)
+
+ return card, layout
+
+
+def _create_advanced_widget(form_layout):
+ widget = QtWidgets.QWidget()
+ widget.setVisible(False)
+ layout = QtWidgets.QVBoxLayout()
+ layout.setContentsMargins(0, 0, 0, 0)
+ layout.setSpacing(0)
+ layout.addLayout(form_layout)
+ widget.setLayout(layout)
+ return widget
+
+
+def _create_advanced_toggle(text, target_widget):
+ button = QtWidgets.QToolButton()
+ button.setText(text)
+ button.setCheckable(True)
+ button.setChecked(False)
+ button.setArrowType(QtCore.Qt.RightArrow)
+ button.setToolButtonStyle(QtCore.Qt.ToolButtonTextBesideIcon)
+ button.setProperty('sectionToggle', 'true')
+
+ def _sync(checked):
+ target_widget.setVisible(checked)
+ button.setArrowType(QtCore.Qt.DownArrow if checked else QtCore.Qt.RightArrow)
+
+ button.toggled.connect(_sync)
+ return button
diff --git a/src/ToolboxPagesAnalysis.py b/src/ToolboxPagesAnalysis.py
new file mode 100644
index 0000000..6caa056
--- /dev/null
+++ b/src/ToolboxPagesAnalysis.py
@@ -0,0 +1,43 @@
+from PySide6 import QtWidgets
+
+from ToolboxWidgets import make_page_radio, right_aligned_row
+
+
+def build_contour_analysis_panel(toolbox):
+ box = QtWidgets.QVBoxLayout()
+
+ vlayout = QtWidgets.QVBoxLayout()
+ gb = QtWidgets.QGroupBox('Contour Source')
+ toolbox.b1 = make_page_radio('Raw')
+ toolbox.b2 = make_page_radio('Refined')
+ toolbox.b2.setChecked(True)
+ vlayout.addWidget(toolbox.b1)
+ vlayout.addWidget(toolbox.b2)
+ gb.setLayout(vlayout)
+ box.addWidget(gb)
+
+ vlayout = QtWidgets.QVBoxLayout()
+ toolbox.cgb = QtWidgets.QGroupBox('Plot Quantity')
+ toolbox.cpb1 = make_page_radio('Gradient')
+ toolbox.cpb2 = make_page_radio('Curvature')
+ toolbox.cpb3 = make_page_radio('Radius')
+ toolbox.cpb1.setChecked(True)
+ vlayout.addWidget(toolbox.cpb1)
+ vlayout.addWidget(toolbox.cpb2)
+ vlayout.addWidget(toolbox.cpb3)
+ toolbox.cgb.setLayout(vlayout)
+ toolbox.cgb.setEnabled(False)
+ box.addWidget(toolbox.cgb)
+
+ analyze_button = QtWidgets.QPushButton('Analyze Contour')
+ analyze_button.setObjectName('pagePrimaryActionButton')
+ box.addLayout(right_aligned_row(analyze_button))
+ box.addStretch(1)
+
+ toolbox.item_ca = QtWidgets.QWidget()
+ toolbox.item_ca.setLayout(box)
+
+ analyze_button.clicked.connect(toolbox.analyzeAirfoil)
+ toolbox.cpb1.clicked.connect(toolbox.drawContourAnalysis)
+ toolbox.cpb2.clicked.connect(toolbox.drawContourAnalysis)
+ toolbox.cpb3.clicked.connect(toolbox.drawContourAnalysis)
diff --git a/src/ToolboxPagesCfd.py b/src/ToolboxPagesCfd.py
new file mode 100644
index 0000000..3d84f27
--- /dev/null
+++ b/src/ToolboxPagesCfd.py
@@ -0,0 +1,155 @@
+from PySide6 import QtWidgets
+
+from ToolboxWidgets import configure_form_layout, make_page_label, right_aligned_row
+
+
+def build_aerodynamics_panel(toolbox):
+ form = QtWidgets.QFormLayout()
+ configure_form_layout(form)
+
+ label1 = make_page_label(u'AoA (°)')
+ toolbox.aoaAP = QtWidgets.QDoubleSpinBox()
+ toolbox.aoaAP.setSingleStep(0.1)
+ toolbox.aoaAP.setDecimals(1)
+ toolbox.aoaAP.setRange(-10.0, 10.0)
+ toolbox.aoaAP.setValue(0.0)
+ form.addRow(label1, toolbox.aoaAP)
+
+ label2 = make_page_label('Velocity (m/s)')
+ toolbox.freestream = QtWidgets.QDoubleSpinBox()
+ toolbox.freestream.setSingleStep(0.1)
+ toolbox.freestream.setDecimals(2)
+ toolbox.freestream.setRange(0.0, 100.0)
+ toolbox.freestream.setValue(10.0)
+ form.addRow(label2, toolbox.freestream)
+
+ label3 = make_page_label('Panels')
+ toolbox.panels = QtWidgets.QSpinBox()
+ toolbox.panels.setRange(10, 500)
+ toolbox.panels.setValue(40)
+ form.addRow(label3, toolbox.panels)
+
+ panel_method_button = QtWidgets.QPushButton('Calculate lift coefficient')
+ panel_method_button.setObjectName('pagePrimaryActionButton')
+ button_row = right_aligned_row(panel_method_button)
+
+ layout = QtWidgets.QVBoxLayout()
+ layout.addLayout(form)
+ layout.addLayout(button_row)
+
+ toolbox.item_ap = QtWidgets.QGroupBox('Quick Lift Estimate')
+ toolbox.item_ap.setLayout(layout)
+
+
+def build_boundary_conditions_panel(toolbox):
+ form = QtWidgets.QFormLayout()
+ configure_form_layout(form)
+
+ label = make_page_label(u'Reynolds')
+ toolbox.reynolds = QtWidgets.QDoubleSpinBox()
+ toolbox.reynolds.setSingleStep(10000.0)
+ toolbox.reynolds.setDecimals(2)
+ toolbox.reynolds.setRange(0.0, 1.0e10)
+ toolbox.reynolds.setValue(100000.0)
+ toolbox.reynolds.valueChanged.connect(toolbox.valuechange)
+ form.addRow(label, toolbox.reynolds)
+
+ label = make_page_label(u'Chord (m)')
+ toolbox.chord = QtWidgets.QDoubleSpinBox()
+ toolbox.chord.setSingleStep(0.01)
+ toolbox.chord.setDecimals(2)
+ toolbox.chord.setRange(0.0, 1.0e10)
+ toolbox.chord.setValue(1.0)
+ toolbox.chord.valueChanged.connect(toolbox.valuechange)
+ form.addRow(label, toolbox.chord)
+
+ label = make_page_label(u'AoA from (°)')
+ toolbox.aoaf = QtWidgets.QDoubleSpinBox()
+ toolbox.aoaf.setSingleStep(0.1)
+ toolbox.aoaf.setDecimals(2)
+ toolbox.aoaf.setRange(-90.0, 90.0)
+ toolbox.aoaf.setValue(-10.0)
+ form.addRow(label, toolbox.aoaf)
+
+ label = make_page_label(u'AoA to (°)')
+ toolbox.aoat = QtWidgets.QDoubleSpinBox()
+ toolbox.aoat.setSingleStep(0.1)
+ toolbox.aoat.setDecimals(2)
+ toolbox.aoat.setRange(-90.0, 90.0)
+ toolbox.aoat.setValue(10.0)
+ form.addRow(label, toolbox.aoat)
+
+ label = make_page_label(u'AoA step (°)')
+ toolbox.aoas = QtWidgets.QDoubleSpinBox()
+ toolbox.aoas.setSingleStep(0.1)
+ toolbox.aoas.setDecimals(2)
+ toolbox.aoas.setRange(0.0, 90.0)
+ toolbox.aoas.setValue(1.0)
+ form.addRow(label, toolbox.aoas)
+
+ toolbox.aoaf.valueChanged.connect(toolbox.valuechange)
+ toolbox.aoat.valueChanged.connect(toolbox.valuechange)
+ toolbox.aoas.valueChanged.connect(toolbox.valuechange)
+
+ label = make_page_label(u'Turbulence (%)')
+ toolbox.turbulence = QtWidgets.QDoubleSpinBox()
+ toolbox.turbulence.setSingleStep(0.1)
+ toolbox.turbulence.setDecimals(2)
+ toolbox.turbulence.setRange(0.0, 100.0)
+ toolbox.turbulence.setValue(2.0)
+ toolbox.turbulence.valueChanged.connect(toolbox.valuechange)
+ form.addRow(label, toolbox.turbulence)
+
+ label = make_page_label(u'Length scale (m)')
+ toolbox.length_sc = QtWidgets.QDoubleSpinBox()
+ toolbox.length_sc.setSingleStep(0.01)
+ toolbox.length_sc.setDecimals(3)
+ toolbox.length_sc.setRange(1.e-6, 1.0e10)
+ toolbox.length_sc.setValue(0.05)
+ toolbox.length_sc.valueChanged.connect(toolbox.valuechange)
+ form.addRow(label, toolbox.length_sc)
+
+ label = make_page_label(u'Pressure (Pa)')
+ toolbox.pressure = QtWidgets.QDoubleSpinBox()
+ toolbox.pressure.setSingleStep(1000.0)
+ toolbox.pressure.setDecimals(2)
+ toolbox.pressure.setRange(0.0, 1.0e10)
+ toolbox.pressure.setValue(101325.0)
+ toolbox.pressure.valueChanged.connect(toolbox.valuechange)
+ form.addRow(label, toolbox.pressure)
+
+ label = make_page_label(u'Temp (°C)')
+ toolbox.temperature = QtWidgets.QDoubleSpinBox()
+ toolbox.temperature.setSingleStep(1.0)
+ toolbox.temperature.setDecimals(2)
+ toolbox.temperature.setRange(-273.15, 1.0e10)
+ toolbox.temperature.setValue(20.0)
+ toolbox.temperature.valueChanged.connect(toolbox.valuechange)
+ form.addRow(label, toolbox.temperature)
+
+ label = make_page_label(u'Flat plate y+')
+ toolbox.yplus = QtWidgets.QDoubleSpinBox()
+ toolbox.yplus.setSingleStep(1.0)
+ toolbox.yplus.setDecimals(2)
+ toolbox.yplus.setRange(1e-6, 1.0e10)
+ toolbox.yplus.setValue(30.0)
+ toolbox.yplus.valueChanged.connect(toolbox.valuechange)
+ form.addRow(label, toolbox.yplus)
+
+ toolbox.textedit = QtWidgets.QTextEdit()
+ toolbox.textedit.setObjectName('pageTextPanel')
+ toolbox.textedit.setReadOnly(True)
+ toolbox.textedit.selectionChanged.connect(toolbox.copy_to_clipboard)
+ toolbox.valuechange()
+
+ copy_button = QtWidgets.QPushButton('Copy to clipboard')
+ copy_button.setObjectName('pageSecondaryActionButton')
+ copy_button.clicked.connect(toolbox.copy_all_to_clipboard)
+
+ layout = QtWidgets.QVBoxLayout()
+ layout.addLayout(form)
+ layout.addWidget(toolbox.textedit)
+ layout.addLayout(right_aligned_row(copy_button))
+
+ toolbox.item_abc = QtWidgets.QGroupBox('CFD Inputs')
+ toolbox.item_abc.setLayout(layout)
diff --git a/src/ToolboxPagesMeshing.py b/src/ToolboxPagesMeshing.py
new file mode 100644
index 0000000..06f2368
--- /dev/null
+++ b/src/ToolboxPagesMeshing.py
@@ -0,0 +1,1106 @@
+from PySide6 import QtGui, QtWidgets
+
+from ToolboxWidgets import (
+ configure_form_layout,
+ make_page_label,
+ make_page_radio,
+ right_aligned_row,
+)
+
+
+def build_meshing_panel(toolbox):
+ engine_group = _build_engine_group(toolbox)
+ _build_airfoil_mesh_form(toolbox)
+ _build_tunnel_mesh_form(toolbox)
+ metric_group = _build_metric_group(toolbox)
+ structured_group = _build_structured_group(toolbox)
+ export_group = _build_export_group(toolbox)
+
+ box_airfoil = QtWidgets.QGroupBox('Airfoil')
+ airfoil_layout = QtWidgets.QVBoxLayout()
+ airfoil_layout.addLayout(toolbox.form_mesh_airfoil)
+ box_airfoil.setLayout(airfoil_layout)
+
+ box_tunnel = QtWidgets.QGroupBox('Tunnel')
+ tunnel_layout = QtWidgets.QVBoxLayout()
+ tunnel_layout.addLayout(toolbox.form_mesh_tunnel)
+ box_tunnel.setLayout(tunnel_layout)
+
+ toolbox.mesh_airfoil_group = box_airfoil
+ toolbox.mesh_tunnel_group = box_tunnel
+ toolbox.mesh_te_group = None
+ toolbox.mesh_wake_group = None
+ toolbox.mesh_smoothing_group = None
+ toolbox.mesh_standard_only_groups = []
+ toolbox.mesh_shared_groups = [box_airfoil, box_tunnel]
+ toolbox.mesh_engine_specific_groups = {
+ 'metric_based': metric_group,
+ 'structured': structured_group,
+ }
+
+ toolbox.createMeshButton = QtWidgets.QPushButton('Create Mesh')
+ toolbox.createMeshButton.setObjectName('pagePrimaryActionButton')
+ toolbox.createMeshActionPanel = QtWidgets.QWidget()
+ toolbox.createMeshActionPanel.setLayout(
+ right_aligned_row(toolbox.createMeshButton)
+ )
+
+ layout = QtWidgets.QVBoxLayout()
+ layout.addStretch(1)
+ layout.addWidget(engine_group)
+ layout.addWidget(box_airfoil)
+ layout.addWidget(box_tunnel)
+ layout.addWidget(metric_group)
+ layout.addWidget(structured_group)
+ layout.addWidget(toolbox.createMeshActionPanel)
+ layout.addStretch(1)
+ layout.addWidget(export_group)
+ layout.addStretch(10)
+
+ toolbox.item_msh = QtWidgets.QWidget()
+ toolbox.item_msh.setLayout(layout)
+
+ toolbox.meshEngineSelector.currentIndexChanged.connect(
+ toolbox.mesh_engine_changed
+ )
+ toolbox.createMeshButton.clicked.connect(toolbox.generateMesh)
+ toolbox.exportMeshButton.clicked.connect(toolbox.exportMesh)
+ toolbox.mesh_engine_changed()
+
+
+def _build_engine_group(toolbox):
+ toolbox.mesh_engine = 'metric_based'
+ toolbox.meshEngineSelector = QtWidgets.QComboBox()
+ toolbox.meshEngineSelector.addItem(
+ 'Metric based',
+ userData='metric_based',
+ )
+ toolbox.meshEngineSelector.addItem(
+ 'Structured',
+ userData='structured',
+ )
+
+ form = QtWidgets.QFormLayout()
+ configure_form_layout(form)
+ label = make_page_label('Mesh engine')
+ label.setToolTip(
+ 'Metric-based structured wind-tunnel mesher inspired by the reference paper.'
+ )
+ form.addRow(label, toolbox.meshEngineSelector)
+
+ group = QtWidgets.QGroupBox('Engine')
+ group.setLayout(form)
+ return group
+
+
+def _build_structured_group(toolbox):
+ layout = QtWidgets.QVBoxLayout()
+ layout.setContentsMargins(0, 0, 0, 0)
+ layout.setSpacing(10)
+
+ intro = QtWidgets.QLabel(
+ 'Composable structured mesher: topology × tunnel shape × '
+ 'algorithm. Sharp vs blunt trailing edge is detected automatically '
+ 'from the prepared contour and changes the block topology (a blunt '
+ 'C-mesh adds a wake strip).'
+ )
+ intro.setWordWrap(True)
+ intro.setProperty('pageSectionHint', 'true')
+ layout.addWidget(intro)
+
+ form = QtWidgets.QFormLayout()
+ configure_form_layout(form)
+
+ toolbox.structured_topology = QtWidgets.QComboBox()
+ toolbox.structured_topology.addItem('C-mesh', userData='c')
+ toolbox.structured_topology.addItem('O-grid', userData='o')
+ form.addRow(make_page_label('Topology'), toolbox.structured_topology)
+
+ toolbox.structured_tunnel_shape = QtWidgets.QComboBox()
+ toolbox.structured_tunnel_shape.addItem(
+ 'Legacy (half-circle + rectangle)', userData='legacy')
+ toolbox.structured_tunnel_shape.addItem('Circular', userData='circular')
+ form.addRow(make_page_label('Tunnel shape'),
+ toolbox.structured_tunnel_shape)
+
+ toolbox.structured_tunnel_height = QtWidgets.QDoubleSpinBox()
+ toolbox.structured_tunnel_height.setRange(0.5, 50.0)
+ toolbox.structured_tunnel_height.setSingleStep(0.5)
+ toolbox.structured_tunnel_height.setValue(3.5)
+ form.addRow(make_page_label('Farfield radius / half-height'),
+ toolbox.structured_tunnel_height)
+
+ toolbox.structured_wake_length = QtWidgets.QDoubleSpinBox()
+ toolbox.structured_wake_length.setRange(0.5, 50.0)
+ toolbox.structured_wake_length.setSingleStep(0.5)
+ toolbox.structured_wake_length.setValue(7.0)
+ form.addRow(make_page_label('Wake length'),
+ toolbox.structured_wake_length)
+
+ toolbox.structured_algorithm = QtWidgets.QComboBox()
+ toolbox.structured_algorithm.addItem('TFI (standard)',
+ userData='tfi:standard')
+ toolbox.structured_algorithm.addItem('TFI (Hermite)',
+ userData='tfi:hermite')
+ toolbox.structured_algorithm.addItem('Elliptic (Winslow + TM)',
+ userData='elliptic:standard')
+ toolbox.structured_algorithm.addItem('Hyperbolic (marching)',
+ userData='hyperbolic:standard')
+ form.addRow(make_page_label('Algorithm'), toolbox.structured_algorithm)
+
+ toolbox.structured_elliptic_iterations = QtWidgets.QSpinBox()
+ toolbox.structured_elliptic_iterations.setRange(10, 2000)
+ toolbox.structured_elliptic_iterations.setValue(150)
+ form.addRow(make_page_label('Elliptic iterations'),
+ toolbox.structured_elliptic_iterations)
+
+ toolbox.structured_hyperbolic_fraction_cap = QtWidgets.QDoubleSpinBox()
+ toolbox.structured_hyperbolic_fraction_cap.setRange(0.05, 0.9)
+ toolbox.structured_hyperbolic_fraction_cap.setSingleStep(0.05)
+ toolbox.structured_hyperbolic_fraction_cap.setValue(0.5)
+ toolbox.structured_hyperbolic_fraction_cap.setToolTip(
+ 'Maximum wall-to-farfield height fraction the march may reach '
+ 'before the remainder is filled algebraically onto the '
+ 'prescribed outer boundary.')
+ form.addRow(make_page_label('Hyperbolic march height fraction'),
+ toolbox.structured_hyperbolic_fraction_cap)
+
+ toolbox.structured_normal_divisions = QtWidgets.QSpinBox()
+ toolbox.structured_normal_divisions.setRange(5, 500)
+ toolbox.structured_normal_divisions.setValue(60)
+ form.addRow(make_page_label('Normal divisions'),
+ toolbox.structured_normal_divisions)
+
+ toolbox.structured_first_layer = QtWidgets.QDoubleSpinBox()
+ toolbox.structured_first_layer.setDecimals(5)
+ toolbox.structured_first_layer.setRange(1.0e-5, 1.0)
+ toolbox.structured_first_layer.setSingleStep(0.001)
+ toolbox.structured_first_layer.setValue(0.002)
+ form.addRow(make_page_label('First layer thickness'),
+ toolbox.structured_first_layer)
+
+ toolbox.structured_wake_points = QtWidgets.QSpinBox()
+ toolbox.structured_wake_points.setRange(5, 500)
+ toolbox.structured_wake_points.setValue(60)
+ form.addRow(make_page_label('Wake points'),
+ toolbox.structured_wake_points)
+
+ toolbox.structured_ortho_layers = QtWidgets.QSpinBox()
+ toolbox.structured_ortho_layers.setRange(0, 100)
+ toolbox.structured_ortho_layers.setValue(0)
+ toolbox.structured_ortho_layers.setToolTip(
+ 'Exact-normal near-wall layers (0 = off).')
+ form.addRow(make_page_label('Ortho layers'),
+ toolbox.structured_ortho_layers)
+
+ toolbox.structured_ortho_growth = QtWidgets.QDoubleSpinBox()
+ toolbox.structured_ortho_growth.setRange(1.0, 2.0)
+ toolbox.structured_ortho_growth.setSingleStep(0.05)
+ toolbox.structured_ortho_growth.setValue(1.15)
+ form.addRow(make_page_label('Ortho growth'),
+ toolbox.structured_ortho_growth)
+
+ toolbox.structured_outer_distribution = QtWidgets.QComboBox()
+ toolbox.structured_outer_distribution.addItem('Uniform',
+ userData='uniform')
+ toolbox.structured_outer_distribution.addItem('Clustered to outlet',
+ userData='clustered')
+ form.addRow(make_page_label('Outer distribution'),
+ toolbox.structured_outer_distribution)
+
+ toolbox.structured_outer_ratio = QtWidgets.QDoubleSpinBox()
+ toolbox.structured_outer_ratio.setRange(1.0, 20.0)
+ toolbox.structured_outer_ratio.setSingleStep(0.5)
+ toolbox.structured_outer_ratio.setValue(2.0)
+ form.addRow(make_page_label('Outer clustering ratio'),
+ toolbox.structured_outer_ratio)
+
+ toolbox.structured_outer_angle = QtWidgets.QComboBox()
+ toolbox.structured_outer_angle.addItem('Free', userData='free')
+ toolbox.structured_outer_angle.addItem('Orthogonal',
+ userData='orthogonal')
+ form.addRow(make_page_label('Outer boundary angle'),
+ toolbox.structured_outer_angle)
+
+ toolbox.structured_smoother = QtWidgets.QComboBox()
+ toolbox.structured_smoother.addItem('None', userData='none')
+ toolbox.structured_smoother.addItem('Laplacian', userData='laplacian')
+ toolbox.structured_smoother.addItem('Elliptic', userData='elliptic')
+ toolbox.structured_smoother.addItem('Angle-based',
+ userData='angle_based')
+ toolbox.structured_smoother.setToolTip(
+ 'Optional post-smoothing pass; never runs implicitly and never '
+ 'lowers mesh quality (a degrading pass is reverted).')
+ form.addRow(make_page_label('Smoother'), toolbox.structured_smoother)
+
+ toolbox.structured_smoother_iterations = QtWidgets.QSpinBox()
+ toolbox.structured_smoother_iterations.setRange(1, 500)
+ toolbox.structured_smoother_iterations.setValue(10)
+ form.addRow(make_page_label('Smoother iterations'),
+ toolbox.structured_smoother_iterations)
+
+ layout.addLayout(form)
+
+ group = QtWidgets.QGroupBox('Structured Grid')
+ group.setLayout(layout)
+ return group
+
+
+def structured_settings_from_toolbox(toolbox):
+ import Meshing
+ algorithm, _, tfi_variant = \
+ toolbox.structured_algorithm.currentData().partition(':')
+ return Meshing.StructuredMeshSettings(
+ topology=toolbox.structured_topology.currentData(),
+ tunnel_shape=toolbox.structured_tunnel_shape.currentData(),
+ tunnel_height=toolbox.structured_tunnel_height.value(),
+ wake_length=toolbox.structured_wake_length.value(),
+ algorithm=algorithm,
+ tfi_variant=tfi_variant,
+ elliptic_iterations=toolbox.structured_elliptic_iterations.value(),
+ hyperbolic_fraction_cap=(
+ toolbox.structured_hyperbolic_fraction_cap.value()),
+ smoother=toolbox.structured_smoother.currentData(),
+ smoother_iterations=(
+ toolbox.structured_smoother_iterations.value()),
+ normal_divisions=toolbox.structured_normal_divisions.value(),
+ first_layer_thickness=toolbox.structured_first_layer.value(),
+ wake_points=toolbox.structured_wake_points.value(),
+ ortho_layers=toolbox.structured_ortho_layers.value(),
+ ortho_growth=toolbox.structured_ortho_growth.value(),
+ boundary_control=Meshing.TunnelBoundaryControl(
+ distribution=toolbox.structured_outer_distribution.currentData(),
+ clustering_ratio=toolbox.structured_outer_ratio.value(),
+ angle_mode=toolbox.structured_outer_angle.currentData(),
+ ),
+ )
+
+
+def _build_metric_group(toolbox):
+ layout = QtWidgets.QVBoxLayout()
+ layout.setContentsMargins(0, 0, 0, 0)
+ layout.setSpacing(10)
+
+ intro = QtWidgets.QLabel(
+ 'This engine currently builds a sharp-trailing-edge C-mesh in the legacy half-circle / rectangular wind-tunnel. Boundary singularities are created automatically at TE upper, TE lower, wake upper, and wake lower, with the wake cut collapsing back onto the outlet centerline for CFD-ready topology. The future singularity editor will expose full manual placement and valence editing on top of this data model.'
+ )
+ intro.setWordWrap(True)
+ intro.setProperty('pageSectionHint', 'true')
+ layout.addWidget(intro)
+
+ form = QtWidgets.QFormLayout()
+ configure_form_layout(form)
+
+ label = make_page_label('Layout family')
+ toolbox.metric_layout_strategy = QtWidgets.QComboBox()
+ toolbox.metric_layout_strategy.addItem(
+ 'C-mesh (sharp TE)',
+ userData='metric_c_grid',
+ )
+ form.addRow(label, toolbox.metric_layout_strategy)
+
+ label = make_page_label('Singularity model')
+ toolbox.metric_singularity_template = QtWidgets.QComboBox()
+ toolbox.metric_singularity_template.addItem(
+ 'Auto boundary singularities',
+ userData='auto_boundary_c',
+ )
+ form.addRow(label, toolbox.metric_singularity_template)
+
+ layout.addLayout(form)
+
+ group = QtWidgets.QGroupBox('Metric Based Layout')
+ group.setLayout(layout)
+ return group
+
+
+def _build_hybrid_group(toolbox):
+ form = QtWidgets.QFormLayout()
+ configure_form_layout(form)
+
+ label = make_page_label('Layout source')
+ label.setToolTip(
+ 'Stage 4 global layout source. The hybrid engine builds per-element boundary loops, near-wall orthogonal rings, and outer O/C-style connector blocks.'
+ )
+ toolbox.hybrid_layout_strategy = QtWidgets.QComboBox()
+ toolbox.hybrid_layout_strategy.addItem(
+ 'Multi-element O/C layout',
+ userData='multi_element_oc',
+ )
+ form.addRow(label, toolbox.hybrid_layout_strategy)
+
+ label = make_page_label('Protect wall layers')
+ label.setToolTip(
+ 'Keep the per-element near-wall orthogonal ring blocks out of Stage 2 redistribution and Stage 1 cleanup to preserve wall orthogonality.'
+ )
+ toolbox.hybrid_protect_near_wall = QtWidgets.QCheckBox(
+ 'Preserve near-wall rings'
+ )
+ toolbox.hybrid_protect_near_wall.setChecked(True)
+ form.addRow(label, toolbox.hybrid_protect_near_wall)
+
+ label = make_page_label('Stage 2 enabled')
+ label.setToolTip(
+ 'Apply local monitor/metric-based redistribution in the connector blocks while keeping block boundaries fixed.'
+ )
+ toolbox.hybrid_stage2_enabled = QtWidgets.QCheckBox('Enable redistribution')
+ toolbox.hybrid_stage2_enabled.setChecked(True)
+ form.addRow(label, toolbox.hybrid_stage2_enabled)
+
+ label = make_page_label('Stage 2 sweeps')
+ label.setToolTip(
+ 'Number of alternating U/V redistribution sweeps.'
+ )
+ toolbox.hybrid_stage2_sweeps = QtWidgets.QSpinBox()
+ toolbox.hybrid_stage2_sweeps.setRange(0, 20)
+ toolbox.hybrid_stage2_sweeps.setValue(2)
+ form.addRow(label, toolbox.hybrid_stage2_sweeps)
+
+ label = make_page_label('Redistribute U')
+ label.setToolTip(
+ 'Redistribute interior U-lines using boundary-derived size fields.'
+ )
+ toolbox.hybrid_redistribute_u = QtWidgets.QCheckBox('Use U-lines')
+ toolbox.hybrid_redistribute_u.setChecked(True)
+ form.addRow(label, toolbox.hybrid_redistribute_u)
+
+ label = make_page_label('Redistribute V')
+ label.setToolTip(
+ 'Redistribute interior V-lines using boundary-derived size fields.'
+ )
+ toolbox.hybrid_redistribute_v = QtWidgets.QCheckBox('Use V-lines')
+ toolbox.hybrid_redistribute_v.setChecked(True)
+ form.addRow(label, toolbox.hybrid_redistribute_v)
+
+ label = make_page_label('Stage 1 enabled')
+ label.setToolTip(
+ 'Run a final cleanup pass on the layout-driven mesh after redistribution.'
+ )
+ toolbox.hybrid_stage1_enabled = QtWidgets.QCheckBox('Enable cleanup')
+ toolbox.hybrid_stage1_enabled.setChecked(True)
+ form.addRow(label, toolbox.hybrid_stage1_enabled)
+
+ label = make_page_label('Cleanup method')
+ label.setToolTip(
+ 'Final practical optimizer proxy used after redistribution.'
+ )
+ toolbox.hybrid_stage1_algorithm = QtWidgets.QComboBox()
+ toolbox.hybrid_stage1_algorithm.addItem('Angle based', userData='angle_based')
+ toolbox.hybrid_stage1_algorithm.addItem('Elliptic', userData='elliptic')
+ toolbox.hybrid_stage1_algorithm.addItem('Simple', userData='simple')
+ toolbox.hybrid_stage1_algorithm.addItem('None', userData='none')
+ form.addRow(label, toolbox.hybrid_stage1_algorithm)
+
+ label = make_page_label('Cleanup iterations')
+ toolbox.hybrid_stage1_iterations = QtWidgets.QSpinBox()
+ toolbox.hybrid_stage1_iterations.setRange(0, 2000)
+ toolbox.hybrid_stage1_iterations.setValue(15)
+ form.addRow(label, toolbox.hybrid_stage1_iterations)
+
+ label = make_page_label('Cleanup tolerance')
+ toolbox.hybrid_stage1_tolerance = QtWidgets.QLineEdit()
+ hybrid_validator = QtGui.QDoubleValidator()
+ hybrid_validator.setRange(1.0e-10, 1.0)
+ hybrid_validator.setDecimals(10)
+ toolbox.hybrid_stage1_tolerance.setValidator(hybrid_validator)
+ toolbox.hybrid_stage1_tolerance.setText('1.e-4')
+ form.addRow(label, toolbox.hybrid_stage1_tolerance)
+
+ label = make_page_label('Cleanup relaxation')
+ label.setToolTip(
+ 'Under-relaxation used when the cleanup method is elliptic.'
+ )
+ toolbox.hybrid_stage1_relaxation = QtWidgets.QDoubleSpinBox()
+ toolbox.hybrid_stage1_relaxation.setSingleStep(0.05)
+ toolbox.hybrid_stage1_relaxation.setRange(0.01, 1.0)
+ toolbox.hybrid_stage1_relaxation.setDecimals(2)
+ toolbox.hybrid_stage1_relaxation.setValue(0.60)
+ form.addRow(label, toolbox.hybrid_stage1_relaxation)
+
+ group = QtWidgets.QGroupBox('Hybrid Pipeline (One Shot)')
+ group.setLayout(form)
+ group.setVisible(False)
+ return group
+
+
+def _build_hybrid_staged_group(toolbox):
+ layout = QtWidgets.QVBoxLayout()
+ layout.setContentsMargins(0, 0, 0, 0)
+ layout.setSpacing(10)
+
+ intro = QtWidgets.QLabel(
+ 'This staged engine always starts with Stage 4. Run Stage 4 first to inspect the layout-driven mesh, then apply Stage 2 redistribution and Stage 1 cleanup explicitly. The legacy trailing-edge block and legacy smoother controls are not used by this staged path.'
+ )
+ intro.setWordWrap(True)
+ intro.setProperty('pageSectionHint', 'true')
+ layout.addWidget(intro)
+
+ form = QtWidgets.QFormLayout()
+ configure_form_layout(form)
+
+ label = make_page_label('Layout source')
+ label.setToolTip(
+ 'Stage 4 global layout source. This staged path builds per-element boundary loops, near-wall orthogonal rings, and outer O/C-style connector blocks.'
+ )
+ toolbox.hybrid_staged_layout_strategy = QtWidgets.QComboBox()
+ toolbox.hybrid_staged_layout_strategy.addItem(
+ 'Multi-element O/C layout',
+ userData='multi_element_oc',
+ )
+ form.addRow(label, toolbox.hybrid_staged_layout_strategy)
+
+ label = make_page_label('Protect wall layers')
+ label.setToolTip(
+ 'Keep the per-element near-wall orthogonal ring blocks out of Stage 2 redistribution and Stage 1 cleanup to preserve wall orthogonality.'
+ )
+ toolbox.hybrid_staged_protect_near_wall = QtWidgets.QCheckBox(
+ 'Preserve near-wall rings'
+ )
+ toolbox.hybrid_staged_protect_near_wall.setChecked(True)
+ form.addRow(label, toolbox.hybrid_staged_protect_near_wall)
+
+ label = make_page_label('TE closure divisions')
+ label.setToolTip(
+ 'Number of points used to close a blunt trailing edge or an open boundary loop during Stage 4 layout preparation.'
+ )
+ toolbox.hybrid_staged_te_divisions = QtWidgets.QSpinBox()
+ toolbox.hybrid_staged_te_divisions.setRange(1, 40)
+ toolbox.hybrid_staged_te_divisions.setValue(3)
+ form.addRow(label, toolbox.hybrid_staged_te_divisions)
+
+ layout.addLayout(form)
+
+ toolbox.hybrid_staged_status_label = QtWidgets.QLabel(
+ 'Stage 4 has not been run yet.'
+ )
+ toolbox.hybrid_staged_status_label.setWordWrap(True)
+ toolbox.hybrid_staged_status_label.setProperty('pageSectionHint', 'true')
+ layout.addWidget(toolbox.hybrid_staged_status_label)
+
+ stage4_row = QtWidgets.QHBoxLayout()
+ stage4_row.setContentsMargins(0, 0, 0, 0)
+ stage4_row.setSpacing(8)
+ toolbox.hybridStagedStage4Button = QtWidgets.QPushButton('Run Stage 4')
+ toolbox.hybridStagedStage4Button.setObjectName('pagePrimaryActionButton')
+ stage4_row.addWidget(toolbox.hybridStagedStage4Button)
+ stage4_row.addStretch(1)
+ layout.addLayout(stage4_row)
+
+ separator_1 = QtWidgets.QFrame()
+ separator_1.setFrameShape(QtWidgets.QFrame.HLine)
+ separator_1.setFrameShadow(QtWidgets.QFrame.Sunken)
+ layout.addWidget(separator_1)
+
+ stage2_hint = QtWidgets.QLabel(
+ 'Stage 2 redistributes points on the Stage 4 connectivity without changing the block topology.'
+ )
+ stage2_hint.setWordWrap(True)
+ stage2_hint.setProperty('pageSectionHint', 'true')
+ layout.addWidget(stage2_hint)
+
+ stage2_form = QtWidgets.QFormLayout()
+ configure_form_layout(stage2_form)
+
+ label = make_page_label('Stage 2 sweeps')
+ toolbox.hybrid_staged_stage2_sweeps = QtWidgets.QSpinBox()
+ toolbox.hybrid_staged_stage2_sweeps.setRange(1, 20)
+ toolbox.hybrid_staged_stage2_sweeps.setValue(2)
+ stage2_form.addRow(label, toolbox.hybrid_staged_stage2_sweeps)
+
+ label = make_page_label('Redistribute U')
+ toolbox.hybrid_staged_redistribute_u = QtWidgets.QCheckBox('Use U-lines')
+ toolbox.hybrid_staged_redistribute_u.setChecked(True)
+ stage2_form.addRow(label, toolbox.hybrid_staged_redistribute_u)
+
+ label = make_page_label('Redistribute V')
+ toolbox.hybrid_staged_redistribute_v = QtWidgets.QCheckBox('Use V-lines')
+ toolbox.hybrid_staged_redistribute_v.setChecked(True)
+ stage2_form.addRow(label, toolbox.hybrid_staged_redistribute_v)
+
+ layout.addLayout(stage2_form)
+
+ stage2_row = QtWidgets.QHBoxLayout()
+ stage2_row.setContentsMargins(0, 0, 0, 0)
+ stage2_row.setSpacing(8)
+ toolbox.hybridStagedStage2Button = QtWidgets.QPushButton('Apply Stage 2')
+ toolbox.hybridStagedStage2Button.setObjectName('pageSecondaryActionButton')
+ stage2_row.addWidget(toolbox.hybridStagedStage2Button)
+ stage2_row.addStretch(1)
+ layout.addLayout(stage2_row)
+
+ separator_2 = QtWidgets.QFrame()
+ separator_2.setFrameShape(QtWidgets.QFrame.HLine)
+ separator_2.setFrameShadow(QtWidgets.QFrame.Sunken)
+ layout.addWidget(separator_2)
+
+ stage1_hint = QtWidgets.QLabel(
+ 'Stage 1 runs the final cleanup pass on the current staged mesh.'
+ )
+ stage1_hint.setWordWrap(True)
+ stage1_hint.setProperty('pageSectionHint', 'true')
+ layout.addWidget(stage1_hint)
+
+ stage1_form = QtWidgets.QFormLayout()
+ configure_form_layout(stage1_form)
+
+ label = make_page_label('Cleanup method')
+ toolbox.hybrid_staged_stage1_algorithm = QtWidgets.QComboBox()
+ toolbox.hybrid_staged_stage1_algorithm.addItem(
+ 'Angle based',
+ userData='angle_based',
+ )
+ toolbox.hybrid_staged_stage1_algorithm.addItem(
+ 'Elliptic',
+ userData='elliptic',
+ )
+ toolbox.hybrid_staged_stage1_algorithm.addItem(
+ 'Simple',
+ userData='simple',
+ )
+ toolbox.hybrid_staged_stage1_algorithm.addItem(
+ 'None',
+ userData='none',
+ )
+ stage1_form.addRow(label, toolbox.hybrid_staged_stage1_algorithm)
+
+ label = make_page_label('Cleanup iterations')
+ toolbox.hybrid_staged_stage1_iterations = QtWidgets.QSpinBox()
+ toolbox.hybrid_staged_stage1_iterations.setRange(0, 2000)
+ toolbox.hybrid_staged_stage1_iterations.setValue(15)
+ stage1_form.addRow(label, toolbox.hybrid_staged_stage1_iterations)
+
+ label = make_page_label('Cleanup tolerance')
+ toolbox.hybrid_staged_stage1_tolerance = QtWidgets.QLineEdit()
+ hybrid_validator = QtGui.QDoubleValidator()
+ hybrid_validator.setRange(1.0e-10, 1.0)
+ hybrid_validator.setDecimals(10)
+ toolbox.hybrid_staged_stage1_tolerance.setValidator(hybrid_validator)
+ toolbox.hybrid_staged_stage1_tolerance.setText('1.e-4')
+ stage1_form.addRow(label, toolbox.hybrid_staged_stage1_tolerance)
+
+ label = make_page_label('Cleanup relaxation')
+ toolbox.hybrid_staged_stage1_relaxation = QtWidgets.QDoubleSpinBox()
+ toolbox.hybrid_staged_stage1_relaxation.setSingleStep(0.05)
+ toolbox.hybrid_staged_stage1_relaxation.setRange(0.01, 1.0)
+ toolbox.hybrid_staged_stage1_relaxation.setDecimals(2)
+ toolbox.hybrid_staged_stage1_relaxation.setValue(0.60)
+ stage1_form.addRow(label, toolbox.hybrid_staged_stage1_relaxation)
+
+ layout.addLayout(stage1_form)
+
+ stage1_row = QtWidgets.QHBoxLayout()
+ stage1_row.setContentsMargins(0, 0, 0, 0)
+ stage1_row.setSpacing(8)
+ toolbox.hybridStagedStage1Button = QtWidgets.QPushButton('Apply Stage 1')
+ toolbox.hybridStagedStage1Button.setObjectName('pageSecondaryActionButton')
+ stage1_row.addWidget(toolbox.hybridStagedStage1Button)
+ stage1_row.addStretch(1)
+ layout.addLayout(stage1_row)
+
+ group = QtWidgets.QGroupBox('Hybrid Pipeline (Staged 4 -> 2 -> 1)')
+ group.setLayout(layout)
+ group.setVisible(False)
+ return group
+
+
+def _build_airfoil_mesh_form(toolbox):
+ toolbox.form_mesh_airfoil = QtWidgets.QFormLayout()
+ configure_form_layout(toolbox.form_mesh_airfoil)
+
+ label = make_page_label('Points on contour')
+ label.setToolTip('Number of points as derived from the prepared contour')
+ toolbox.points_on_airfoil = QtWidgets.QLineEdit('0')
+ toolbox.points_on_airfoil.setEnabled(False)
+ toolbox.form_mesh_airfoil.addRow(label, toolbox.points_on_airfoil)
+
+ label = make_page_label('Normal divisions')
+ label.setToolTip(
+ 'Number of points in the mesh which is constructed normal to the airfoil contour'
+ )
+ toolbox.points_n = QtWidgets.QSpinBox()
+ toolbox.points_n.setSingleStep(1)
+ toolbox.points_n.setRange(1, 500)
+ toolbox.points_n.setValue(15)
+ toolbox.form_mesh_airfoil.addRow(label, toolbox.points_n)
+
+ label = make_page_label('First layer (m)')
+ label.setToolTip('Thickness of 1st cell layer perpendicular to the airfoil')
+ toolbox.normal_thickness = QtWidgets.QDoubleSpinBox()
+ toolbox.normal_thickness.setSingleStep(0.001)
+ toolbox.normal_thickness.setRange(1.e-10, 1.e10)
+ toolbox.normal_thickness.setDecimals(8)
+ toolbox.normal_thickness.setValue(0.00400)
+ toolbox.form_mesh_airfoil.addRow(label, toolbox.normal_thickness)
+
+ label = make_page_label('Growth rate')
+ label.setToolTip('Rate at which 1st cell layer grows')
+ toolbox.ratio = QtWidgets.QDoubleSpinBox()
+ toolbox.ratio.setSingleStep(0.01)
+ toolbox.ratio.setRange(1., 100.)
+ toolbox.ratio.setValue(1.05)
+ toolbox.ratio.setDecimals(3)
+ toolbox.form_mesh_airfoil.addRow(label, toolbox.ratio)
+
+
+def _build_trailing_edge_mesh_form(toolbox):
+ toolbox.form_mesh_TE = QtWidgets.QFormLayout()
+ configure_form_layout(toolbox.form_mesh_TE)
+
+ label = make_page_label(u'TE divisions')
+ label.setToolTip('Number of subdivisions along the vertical part of the TE')
+ toolbox.te_div = QtWidgets.QSpinBox()
+ toolbox.te_div.setSingleStep(1)
+ toolbox.te_div.setRange(1, 20)
+ toolbox.te_div.setValue(3)
+ toolbox.form_mesh_TE.addRow(label, toolbox.te_div)
+
+ label = make_page_label(u'Downstream divisions')
+ label.setToolTip('Number of subdivisions downstream within the TE block')
+ toolbox.points_te = QtWidgets.QSpinBox()
+ toolbox.points_te.setSingleStep(1)
+ toolbox.points_te.setRange(1, 100)
+ toolbox.points_te.setValue(15)
+ toolbox.form_mesh_TE.addRow(label, toolbox.points_te)
+
+ label = make_page_label('First layer (m)')
+ label.setToolTip('Thickness of first cell layer in downstream direction')
+ toolbox.length_te = QtWidgets.QDoubleSpinBox()
+ toolbox.length_te.setSingleStep(0.001)
+ toolbox.length_te.setRange(1.e-10, 1.e10)
+ toolbox.length_te.setDecimals(8)
+ toolbox.length_te.setValue(0.00400)
+ toolbox.form_mesh_TE.addRow(label, toolbox.length_te)
+
+ label = make_page_label('Growth rate')
+ label.setToolTip('Rate at which 1st cell layer downstream the TE grows')
+ toolbox.ratio_te = QtWidgets.QDoubleSpinBox()
+ toolbox.ratio_te.setSingleStep(0.01)
+ toolbox.ratio_te.setRange(1., 100.)
+ toolbox.ratio_te.setValue(1.05)
+ toolbox.ratio_te.setDecimals(3)
+ toolbox.form_mesh_TE.addRow(label, toolbox.ratio_te)
+
+
+def _build_tunnel_mesh_form(toolbox):
+ toolbox.form_mesh_tunnel = QtWidgets.QFormLayout()
+ configure_form_layout(toolbox.form_mesh_tunnel)
+
+ label = make_page_label('Tunnel height (c)')
+ label.setToolTip('The height of the windtunnel in units of chord length')
+ toolbox.tunnel_height = QtWidgets.QDoubleSpinBox()
+ toolbox.tunnel_height.setSingleStep(0.1)
+ toolbox.tunnel_height.setRange(0.1, 100.)
+ toolbox.tunnel_height.setValue(3.5)
+ toolbox.tunnel_height.setDecimals(1)
+ toolbox.form_mesh_tunnel.addRow(label, toolbox.tunnel_height)
+
+ label = make_page_label('Wake divisions')
+ label.setToolTip(
+ 'Number of points traced along each sharp-TE wake branch from the trailing edge to the outlet.'
+ )
+ toolbox.divisions_height = QtWidgets.QSpinBox()
+ toolbox.divisions_height.setSingleStep(5)
+ toolbox.divisions_height.setRange(4, 1000)
+ toolbox.divisions_height.setValue(80)
+ toolbox.form_mesh_tunnel.addRow(label, toolbox.divisions_height)
+
+ label = make_page_label('Wake length (c)')
+ label.setToolTip(
+ 'The length of the wake of the windtunnel in units of chord length'
+ )
+ toolbox.tunnel_wake = QtWidgets.QDoubleSpinBox()
+ toolbox.tunnel_wake.setSingleStep(0.1)
+ toolbox.tunnel_wake.setRange(0.1, 100.)
+ toolbox.tunnel_wake.setValue(7.0)
+ toolbox.tunnel_wake.setDecimals(1)
+ toolbox.form_mesh_tunnel.addRow(label, toolbox.tunnel_wake)
+
+
+def _build_experimental_c_group(toolbox):
+ form = QtWidgets.QFormLayout()
+ configure_form_layout(form)
+
+ label = make_page_label('Surface points')
+ label.setToolTip(
+ 'Use the prepared contour point count when set to 0, or resample the contour for the experimental C-grid'
+ )
+ toolbox.experimental_surface_points = QtWidgets.QSpinBox()
+ toolbox.experimental_surface_points.setRange(0, 5000)
+ toolbox.experimental_surface_points.setValue(0)
+ toolbox.experimental_surface_points.setSpecialValueText('Prepared contour')
+ form.addRow(label, toolbox.experimental_surface_points)
+
+ label = make_page_label('Normal divisions')
+ label.setToolTip('Number of cells between the airfoil and farfield')
+ toolbox.experimental_normal_divisions = QtWidgets.QSpinBox()
+ toolbox.experimental_normal_divisions.setRange(4, 2000)
+ toolbox.experimental_normal_divisions.setValue(100)
+ form.addRow(label, toolbox.experimental_normal_divisions)
+
+ label = make_page_label('First layer (m)')
+ label.setToolTip('Target first-layer wall spacing for the experimental grid')
+ toolbox.experimental_first_layer = QtWidgets.QDoubleSpinBox()
+ toolbox.experimental_first_layer.setSingleStep(0.001)
+ toolbox.experimental_first_layer.setRange(1.0e-10, 1.0e10)
+ toolbox.experimental_first_layer.setDecimals(8)
+ toolbox.experimental_first_layer.setValue(0.00400)
+ form.addRow(label, toolbox.experimental_first_layer)
+
+ label = make_page_label('Wake points')
+ label.setToolTip(
+ 'Number of points along each wake branch, including the trailing edge and wake cut point'
+ )
+ toolbox.experimental_wake_points = QtWidgets.QSpinBox()
+ toolbox.experimental_wake_points.setRange(4, 2000)
+ toolbox.experimental_wake_points.setValue(75)
+ form.addRow(label, toolbox.experimental_wake_points)
+
+ label = make_page_label('Farfield wake len')
+ label.setToolTip(
+ 'Outer-boundary wake length ratio relative to the inner wake length'
+ )
+ toolbox.experimental_farfield_wake_length_ratio = QtWidgets.QDoubleSpinBox()
+ toolbox.experimental_farfield_wake_length_ratio.setSingleStep(0.05)
+ toolbox.experimental_farfield_wake_length_ratio.setRange(0.05, 1.00)
+ toolbox.experimental_farfield_wake_length_ratio.setDecimals(2)
+ toolbox.experimental_farfield_wake_length_ratio.setValue(1.00)
+ form.addRow(label, toolbox.experimental_farfield_wake_length_ratio)
+
+ label = make_page_label('Farfield wake start')
+ label.setToolTip(
+ 'Initial farfield wake spacing as a multiple of the inner wake spacing near the trailing edge'
+ )
+ toolbox.experimental_farfield_wake_start_ratio = QtWidgets.QDoubleSpinBox()
+ toolbox.experimental_farfield_wake_start_ratio.setSingleStep(0.5)
+ toolbox.experimental_farfield_wake_start_ratio.setRange(1.0, 100.0)
+ toolbox.experimental_farfield_wake_start_ratio.setDecimals(1)
+ toolbox.experimental_farfield_wake_start_ratio.setValue(10.0)
+ form.addRow(label, toolbox.experimental_farfield_wake_start_ratio)
+
+ label = make_page_label('Initial smooth')
+ label.setToolTip('Initial elliptic smoothing passes before wall-normal respacing')
+ toolbox.experimental_initial_smoothing = QtWidgets.QSpinBox()
+ toolbox.experimental_initial_smoothing.setRange(0, 5000)
+ toolbox.experimental_initial_smoothing.setValue(100)
+ form.addRow(label, toolbox.experimental_initial_smoothing)
+
+ label = make_page_label('Final smooth')
+ label.setToolTip('Final elliptic smoothing passes after wall-normal respacing')
+ toolbox.experimental_final_smoothing = QtWidgets.QSpinBox()
+ toolbox.experimental_final_smoothing.setRange(0, 5000)
+ toolbox.experimental_final_smoothing.setValue(20)
+ form.addRow(label, toolbox.experimental_final_smoothing)
+
+ label = make_page_label('Local TE smooth')
+ label.setToolTip(
+ 'Elliptic smoothing passes for the local finite trailing-edge bridge block'
+ )
+ toolbox.experimental_local_te_smoothing = QtWidgets.QSpinBox()
+ toolbox.experimental_local_te_smoothing.setRange(0, 5000)
+ toolbox.experimental_local_te_smoothing.setValue(10)
+ form.addRow(label, toolbox.experimental_local_te_smoothing)
+
+ label = make_page_label('Tolerance')
+ toolbox.experimental_smoothing_tolerance = QtWidgets.QLineEdit()
+ experimental_validator = QtGui.QDoubleValidator()
+ experimental_validator.setRange(1.0e-10, 1.0)
+ experimental_validator.setDecimals(10)
+ toolbox.experimental_smoothing_tolerance.setValidator(experimental_validator)
+ toolbox.experimental_smoothing_tolerance.setText('1.e-5')
+ form.addRow(label, toolbox.experimental_smoothing_tolerance)
+
+ label = make_page_label('Relaxation')
+ label.setToolTip('Under-relaxation for the experimental elliptic update')
+ toolbox.experimental_relaxation = QtWidgets.QDoubleSpinBox()
+ toolbox.experimental_relaxation.setSingleStep(0.05)
+ toolbox.experimental_relaxation.setRange(0.01, 1.0)
+ toolbox.experimental_relaxation.setDecimals(2)
+ toolbox.experimental_relaxation.setValue(0.60)
+ form.addRow(label, toolbox.experimental_relaxation)
+
+ group = QtWidgets.QGroupBox('Experimental C-grid')
+ group.setLayout(form)
+ group.setVisible(False)
+ return group
+
+
+def _build_experimental_o_group(toolbox):
+ form = QtWidgets.QFormLayout()
+ configure_form_layout(form)
+
+ label = make_page_label('Surface points')
+ label.setToolTip(
+ 'Use the prepared contour point count when set to 0, or resample the contour for the experimental O-grid'
+ )
+ toolbox.experimental_o_surface_points = QtWidgets.QSpinBox()
+ toolbox.experimental_o_surface_points.setRange(0, 5000)
+ toolbox.experimental_o_surface_points.setValue(0)
+ toolbox.experimental_o_surface_points.setSpecialValueText('Prepared contour')
+ form.addRow(label, toolbox.experimental_o_surface_points)
+
+ label = make_page_label('Normal divisions')
+ label.setToolTip('Number of cells between the airfoil and farfield')
+ toolbox.experimental_o_normal_divisions = QtWidgets.QSpinBox()
+ toolbox.experimental_o_normal_divisions.setRange(4, 2000)
+ toolbox.experimental_o_normal_divisions.setValue(100)
+ form.addRow(label, toolbox.experimental_o_normal_divisions)
+
+ label = make_page_label('First layer (m)')
+ label.setToolTip('Target first-layer wall spacing for the experimental O-grid')
+ toolbox.experimental_o_first_layer = QtWidgets.QDoubleSpinBox()
+ toolbox.experimental_o_first_layer.setSingleStep(0.001)
+ toolbox.experimental_o_first_layer.setRange(1.0e-10, 1.0e10)
+ toolbox.experimental_o_first_layer.setDecimals(8)
+ toolbox.experimental_o_first_layer.setValue(0.00400)
+ form.addRow(label, toolbox.experimental_o_first_layer)
+
+ label = make_page_label('Farfield')
+ label.setToolTip(
+ 'Choose a wind-tunnel outer boundary or a circular farfield. The circular option uses tunnel height and ignores wake length.'
+ )
+ toolbox.experimental_o_farfield_shape = QtWidgets.QComboBox()
+ toolbox.experimental_o_farfield_shape.addItem(
+ 'Wind tunnel',
+ userData='wind_tunnel',
+ )
+ toolbox.experimental_o_farfield_shape.addItem(
+ 'Circle',
+ userData='circle',
+ )
+ form.addRow(label, toolbox.experimental_o_farfield_shape)
+
+ label = make_page_label('Initial smooth')
+ label.setToolTip('Initial elliptic smoothing passes before wall-normal respacing')
+ toolbox.experimental_o_initial_smoothing = QtWidgets.QSpinBox()
+ toolbox.experimental_o_initial_smoothing.setRange(0, 5000)
+ toolbox.experimental_o_initial_smoothing.setValue(100)
+ form.addRow(label, toolbox.experimental_o_initial_smoothing)
+
+ label = make_page_label('Final smooth')
+ label.setToolTip('Final elliptic smoothing passes after wall-normal respacing')
+ toolbox.experimental_o_final_smoothing = QtWidgets.QSpinBox()
+ toolbox.experimental_o_final_smoothing.setRange(0, 5000)
+ toolbox.experimental_o_final_smoothing.setValue(20)
+ form.addRow(label, toolbox.experimental_o_final_smoothing)
+
+ label = make_page_label('Tolerance')
+ toolbox.experimental_o_smoothing_tolerance = QtWidgets.QLineEdit()
+ experimental_validator = QtGui.QDoubleValidator()
+ experimental_validator.setRange(1.0e-10, 1.0)
+ experimental_validator.setDecimals(10)
+ toolbox.experimental_o_smoothing_tolerance.setValidator(experimental_validator)
+ toolbox.experimental_o_smoothing_tolerance.setText('1.e-5')
+ form.addRow(label, toolbox.experimental_o_smoothing_tolerance)
+
+ label = make_page_label('Relaxation')
+ label.setToolTip('Under-relaxation for the experimental elliptic update')
+ toolbox.experimental_o_relaxation = QtWidgets.QDoubleSpinBox()
+ toolbox.experimental_o_relaxation.setSingleStep(0.05)
+ toolbox.experimental_o_relaxation.setRange(0.01, 1.0)
+ toolbox.experimental_o_relaxation.setDecimals(2)
+ toolbox.experimental_o_relaxation.setValue(0.60)
+ form.addRow(label, toolbox.experimental_o_relaxation)
+
+ group = QtWidgets.QGroupBox('Experimental O-grid')
+ group.setLayout(form)
+ group.setVisible(False)
+ return group
+
+
+def _build_smoothing_group(toolbox):
+ toolbox.btn_smoother_1 = make_page_radio('Simple')
+ toolbox.btn_smoother_2 = make_page_radio('Elliptic')
+ toolbox.btn_smoother_3 = make_page_radio('Angle based')
+ toolbox.btn_smoother_1.setChecked(True)
+ toolbox.smoothing_algorithm = 'simple'
+
+ toolbox.btn_smoother_1.clicked.connect(toolbox.smoother_btn_clicked)
+ toolbox.btn_smoother_2.clicked.connect(toolbox.smoother_btn_clicked)
+ toolbox.btn_smoother_3.clicked.connect(toolbox.smoother_btn_clicked)
+
+ smoother_settings = QtWidgets.QFormLayout()
+ configure_form_layout(smoother_settings)
+
+ label = make_page_label('Iterations')
+ toolbox.smoother_iterations = QtWidgets.QSpinBox()
+ toolbox.smoother_iterations.setValue(100)
+ toolbox.smoother_iterations.setSingleStep(5)
+ toolbox.smoother_iterations.setRange(0, 1000)
+ toolbox.smoother_iterations.setEnabled(False)
+ smoother_settings.addRow(label, toolbox.smoother_iterations)
+
+ label = make_page_label('Tolerance')
+ toolbox.smoother_tolerance = QtWidgets.QLineEdit()
+ toolbox.onlyFloat = QtGui.QDoubleValidator()
+ toolbox.smoother_tolerance.setValidator(toolbox.onlyFloat)
+ toolbox.smoother_tolerance.setText('1.e-5')
+ toolbox.onlyFloat.setRange(1.e-8, 1.0)
+ toolbox.onlyFloat.setDecimals(8)
+ toolbox.smoother_tolerance.setEnabled(False)
+ smoother_settings.addRow(label, toolbox.smoother_tolerance)
+
+ label = make_page_label('Outer slide')
+ label.setToolTip(
+ 'Allow the farfield boundary to slide tangentially on its exact geometry during elliptic smoothing'
+ )
+ toolbox.outer_boundary_slide = QtWidgets.QDoubleSpinBox()
+ toolbox.outer_boundary_slide.setSingleStep(0.05)
+ toolbox.outer_boundary_slide.setRange(0.0, 1.0)
+ toolbox.outer_boundary_slide.setValue(1.00)
+ toolbox.outer_boundary_slide.setDecimals(2)
+ toolbox.outer_boundary_slide.setEnabled(False)
+ smoother_settings.addRow(label, toolbox.outer_boundary_slide)
+
+ label = make_page_label('Relaxation')
+ label.setToolTip('Under-relaxation of the elliptic interior update')
+ toolbox.elliptic_relaxation = QtWidgets.QDoubleSpinBox()
+ toolbox.elliptic_relaxation.setSingleStep(0.05)
+ toolbox.elliptic_relaxation.setRange(0.01, 1.0)
+ toolbox.elliptic_relaxation.setValue(1.00)
+ toolbox.elliptic_relaxation.setDecimals(2)
+ toolbox.elliptic_relaxation.setEnabled(False)
+ smoother_settings.addRow(label, toolbox.elliptic_relaxation)
+
+ label = make_page_label('Guide relax')
+ label.setToolTip('How strongly the tunnel follows the protected airfoil/TE interface guide')
+ toolbox.protected_guide_relaxation = QtWidgets.QDoubleSpinBox()
+ toolbox.protected_guide_relaxation.setSingleStep(0.05)
+ toolbox.protected_guide_relaxation.setRange(0.0, 1.0)
+ toolbox.protected_guide_relaxation.setValue(0.25)
+ toolbox.protected_guide_relaxation.setDecimals(2)
+ toolbox.protected_guide_relaxation.setEnabled(False)
+ smoother_settings.addRow(label, toolbox.protected_guide_relaxation)
+
+ label = make_page_label('Guide layers')
+ label.setToolTip('Number of tunnel layers influenced by the protected guide')
+ toolbox.protected_guide_layers = QtWidgets.QSpinBox()
+ toolbox.protected_guide_layers.setSingleStep(1)
+ toolbox.protected_guide_layers.setRange(1, 20)
+ toolbox.protected_guide_layers.setValue(8)
+ toolbox.protected_guide_layers.setEnabled(False)
+ smoother_settings.addRow(label, toolbox.protected_guide_layers)
+
+ label = make_page_label('Guide decay')
+ label.setToolTip('Decay of guide influence away from the protected interface')
+ toolbox.protected_guide_decay = QtWidgets.QDoubleSpinBox()
+ toolbox.protected_guide_decay.setSingleStep(0.05)
+ toolbox.protected_guide_decay.setRange(0.0, 1.0)
+ toolbox.protected_guide_decay.setValue(0.20)
+ toolbox.protected_guide_decay.setDecimals(2)
+ toolbox.protected_guide_decay.setEnabled(False)
+ smoother_settings.addRow(label, toolbox.protected_guide_decay)
+
+ label = make_page_label('Guide smooth')
+ label.setToolTip('Smoothing passes for the protected guide profile along the interface')
+ toolbox.protected_guide_smoothing = QtWidgets.QSpinBox()
+ toolbox.protected_guide_smoothing.setSingleStep(1)
+ toolbox.protected_guide_smoothing.setRange(0, 20)
+ toolbox.protected_guide_smoothing.setValue(15)
+ toolbox.protected_guide_smoothing.setEnabled(False)
+ smoother_settings.addRow(label, toolbox.protected_guide_smoothing)
+
+ radio_layout = QtWidgets.QVBoxLayout()
+ radio_layout.addWidget(toolbox.btn_smoother_1)
+ radio_layout.addWidget(toolbox.btn_smoother_2)
+ radio_layout.addWidget(toolbox.btn_smoother_3)
+
+ group_layout = QtWidgets.QHBoxLayout()
+ group_layout.addLayout(radio_layout)
+ group_layout.addLayout(smoother_settings)
+
+ group = QtWidgets.QGroupBox('Smoothing')
+ group.setLayout(group_layout)
+ return group
+
+
+def _build_export_group(toolbox):
+ label = make_page_label('Boundary names')
+ label.setToolTip(
+ 'Here you can define the names of the boundaries for the mesh export'
+ )
+ boundary_header = QtWidgets.QGridLayout()
+ boundary_header.addWidget(label, 0, 0)
+
+ toolbox.form_bnd = QtWidgets.QFormLayout()
+ configure_form_layout(toolbox.form_bnd)
+ header_1 = make_page_label('Boundary')
+ header_1.setStyleSheet('font-weight: bold;')
+ header_2 = make_page_label('Name')
+ header_2.setStyleSheet('font-weight: bold;')
+ toolbox.form_bnd.addRow(header_1, header_2)
+
+ _add_boundary_name_row(
+ toolbox,
+ label='Airfoil',
+ tooltip='Name of the boundary definition for the airfoil',
+ attribute_name='lineedit_airfoil',
+ default='Airfoil',
+ )
+ _add_boundary_name_row(
+ toolbox,
+ label='Inlet (C-arc)',
+ tooltip='Name of the boundary definition for the inlet',
+ attribute_name='lineedit_inlet',
+ default='Inlet',
+ )
+ _add_boundary_name_row(
+ toolbox,
+ label='Outlet',
+ tooltip='Name of the boundary definition for the outlet',
+ attribute_name='lineedit_outlet',
+ default='Outlet',
+ )
+ _add_boundary_name_row(
+ toolbox,
+ label='Top',
+ tooltip='Name of the boundary definition for the top of the windtunnel',
+ attribute_name='lineedit_top',
+ default='Top',
+ )
+ _add_boundary_name_row(
+ toolbox,
+ label='Bottom',
+ tooltip='Name of the boundary definition for the bottom of the windtunnel',
+ attribute_name='lineedit_bottom',
+ default='Bottom',
+ )
+
+ toolbox.check_FIRE = QtWidgets.QCheckBox('AVL FIRE')
+ toolbox.check_SU2 = QtWidgets.QCheckBox('SU2')
+ toolbox.check_GMSH = QtWidgets.QCheckBox('GMSH')
+ toolbox.check_VTU = QtWidgets.QCheckBox('VTU')
+ toolbox.check_FIRE.setChecked(True)
+ toolbox.check_SU2.setChecked(True)
+ toolbox.check_GMSH.setChecked(False)
+ toolbox.check_VTU.setChecked(False)
+
+ format_label = make_page_label('Formats')
+ format_label.setToolTip('Check format to be exported')
+ format_grid = QtWidgets.QGridLayout()
+ format_grid.addWidget(format_label, 0, 0)
+ format_grid.addWidget(toolbox.check_FIRE, 1, 1)
+ format_grid.addWidget(toolbox.check_SU2, 1, 2)
+ format_grid.addWidget(toolbox.check_GMSH, 1, 3)
+ format_grid.addWidget(toolbox.check_VTU, 2, 1)
+
+ toolbox.exportMeshButton = QtWidgets.QPushButton('Export Mesh')
+ toolbox.exportMeshButton.setObjectName('pageSecondaryActionButton')
+ export_button_layout = right_aligned_row(toolbox.exportMeshButton)
+
+ group_layout = QtWidgets.QVBoxLayout()
+ group_layout.addLayout(boundary_header)
+ group_layout.addLayout(toolbox.form_bnd)
+ group_layout.addLayout(format_grid)
+ group_layout.addLayout(export_button_layout)
+
+ toolbox.box_meshexport = QtWidgets.QGroupBox('Mesh Export')
+ toolbox.box_meshexport.setLayout(group_layout)
+ toolbox.box_meshexport.setEnabled(False)
+ return toolbox.box_meshexport
+
+
+def _add_boundary_name_row(toolbox, label, tooltip, attribute_name, default):
+ widget_label = make_page_label(label, tooltip)
+ line_edit = QtWidgets.QLineEdit(default)
+ setattr(toolbox, attribute_name, line_edit)
+ toolbox.form_bnd.addRow(widget_label, line_edit)
diff --git a/src/ToolboxPagesMetricTests.py b/src/ToolboxPagesMetricTests.py
new file mode 100644
index 0000000..0a9971a
--- /dev/null
+++ b/src/ToolboxPagesMetricTests.py
@@ -0,0 +1,162 @@
+from PySide6 import QtWidgets
+
+from MetricTriangulation import MetricExampleFactory
+from ToolboxWidgets import (
+ configure_form_layout,
+ make_page_label,
+ right_aligned_row,
+)
+
+
+def build_metric_tests_panel(toolbox):
+ layout = QtWidgets.QVBoxLayout()
+ layout.setContentsMargins(0, 0, 0, 0)
+ layout.setSpacing(12)
+
+ intro = QtWidgets.QLabel(
+ 'Play with the cleanroom triangulation core on simple PSLG examples before we connect it to the full metric / Ricci-flow backend. These examples stay in the viewer and let you inspect boundary recovery and hole handling directly.'
+ )
+ intro.setWordWrap(True)
+ intro.setProperty('pageSectionHint', 'true')
+ layout.addWidget(intro)
+
+ geometry_group = QtWidgets.QGroupBox('Example Geometry')
+ geometry_form = QtWidgets.QFormLayout()
+ configure_form_layout(geometry_form)
+
+ toolbox.metric_test_example = QtWidgets.QComboBox()
+ toolbox.metric_test_example.addItem('Rectangle', userData='rectangle')
+ toolbox.metric_test_example.addItem(
+ 'Rectangle + one circle',
+ userData='rectangle_circle',
+ )
+ toolbox.metric_test_example.addItem(
+ 'Rectangle + two circles',
+ userData='rectangle_two_circles',
+ )
+ toolbox.metric_test_example.addItem(
+ 'Tunnel + default airfoil',
+ userData='default_airfoil_tunnel',
+ )
+ geometry_form.addRow(make_page_label('Example'), toolbox.metric_test_example)
+
+ toolbox.metric_test_width = QtWidgets.QDoubleSpinBox()
+ toolbox.metric_test_width.setRange(0.2, 100.0)
+ toolbox.metric_test_width.setDecimals(3)
+ toolbox.metric_test_width.setSingleStep(0.1)
+ toolbox.metric_test_width.setValue(4.0)
+ geometry_form.addRow(make_page_label('Width'), toolbox.metric_test_width)
+
+ toolbox.metric_test_height = QtWidgets.QDoubleSpinBox()
+ toolbox.metric_test_height.setRange(0.2, 100.0)
+ toolbox.metric_test_height.setDecimals(3)
+ toolbox.metric_test_height.setSingleStep(0.1)
+ toolbox.metric_test_height.setValue(2.0)
+ geometry_form.addRow(make_page_label('Height'), toolbox.metric_test_height)
+
+ toolbox.metric_test_hole_radius = QtWidgets.QDoubleSpinBox()
+ toolbox.metric_test_hole_radius.setRange(0.01, 20.0)
+ toolbox.metric_test_hole_radius.setDecimals(3)
+ toolbox.metric_test_hole_radius.setSingleStep(0.02)
+ toolbox.metric_test_hole_radius.setValue(0.35)
+ geometry_form.addRow(
+ make_page_label('Hole radius'),
+ toolbox.metric_test_hole_radius,
+ )
+
+ toolbox.metric_test_hole_spacing = QtWidgets.QDoubleSpinBox()
+ toolbox.metric_test_hole_spacing.setRange(0.05, 100.0)
+ toolbox.metric_test_hole_spacing.setDecimals(3)
+ toolbox.metric_test_hole_spacing.setSingleStep(0.05)
+ toolbox.metric_test_hole_spacing.setValue(1.40)
+ geometry_form.addRow(
+ make_page_label('Hole spacing'),
+ toolbox.metric_test_hole_spacing,
+ )
+
+ geometry_group.setLayout(geometry_form)
+ layout.addWidget(geometry_group)
+
+ discretization_group = QtWidgets.QGroupBox('Triangulation Controls')
+ discretization_form = QtWidgets.QFormLayout()
+ configure_form_layout(discretization_form)
+
+ toolbox.metric_test_outer_resolution = QtWidgets.QSpinBox()
+ toolbox.metric_test_outer_resolution.setRange(8, 1000)
+ toolbox.metric_test_outer_resolution.setValue(72)
+ discretization_form.addRow(
+ make_page_label('Outer boundary points'),
+ toolbox.metric_test_outer_resolution,
+ )
+
+ toolbox.metric_test_hole_resolution = QtWidgets.QSpinBox()
+ toolbox.metric_test_hole_resolution.setRange(12, 1000)
+ toolbox.metric_test_hole_resolution.setValue(44)
+ discretization_form.addRow(
+ make_page_label('Hole boundary points'),
+ toolbox.metric_test_hole_resolution,
+ )
+
+ toolbox.metric_test_interior_x = QtWidgets.QSpinBox()
+ toolbox.metric_test_interior_x.setRange(0, 400)
+ toolbox.metric_test_interior_x.setValue(32)
+ discretization_form.addRow(
+ make_page_label('Interior grid X'),
+ toolbox.metric_test_interior_x,
+ )
+
+ toolbox.metric_test_interior_y = QtWidgets.QSpinBox()
+ toolbox.metric_test_interior_y.setRange(0, 400)
+ toolbox.metric_test_interior_y.setValue(16)
+ discretization_form.addRow(
+ make_page_label('Interior grid Y'),
+ toolbox.metric_test_interior_y,
+ )
+
+ discretization_group.setLayout(discretization_form)
+ layout.addWidget(discretization_group)
+
+ toolbox.metric_test_status = QtWidgets.QLabel(
+ 'Choose an example and click Generate Example to draw the triangulation in the viewer.'
+ )
+ toolbox.metric_test_status.setWordWrap(True)
+ toolbox.metric_test_status.setProperty('pageSectionHint', 'true')
+ layout.addWidget(toolbox.metric_test_status)
+
+ toolbox.metric_test_generate_button = QtWidgets.QPushButton('Generate Example')
+ toolbox.metric_test_generate_button.setObjectName('pagePrimaryActionButton')
+ toolbox.metric_test_clear_button = QtWidgets.QPushButton('Clear')
+ toolbox.metric_test_clear_button.setObjectName('pageSecondaryActionButton')
+ toolbox.metric_test_actions = QtWidgets.QWidget()
+ toolbox.metric_test_actions.setLayout(
+ right_aligned_row(
+ toolbox.metric_test_clear_button,
+ toolbox.metric_test_generate_button,
+ )
+ )
+ layout.addWidget(toolbox.metric_test_actions)
+ layout.addStretch(1)
+
+ toolbox.item_metric_tests = QtWidgets.QWidget()
+ toolbox.item_metric_tests.setLayout(layout)
+
+ toolbox.metric_test_generate_button.clicked.connect(toolbox.generateMetricTest)
+ toolbox.metric_test_clear_button.clicked.connect(toolbox.clearMetricTest)
+ toolbox.metric_test_example.currentIndexChanged.connect(
+ toolbox.metricTestControlsChanged
+ )
+
+ toolbox.metricTestControlsChanged()
+
+
+def metric_test_example_labels() -> dict[str, str]:
+ return {
+ 'rectangle': 'Rectangle',
+ 'rectangle_circle': 'Rectangle + one circle',
+ 'rectangle_two_circles': 'Rectangle + two circles',
+ 'default_airfoil_tunnel': 'Tunnel + default airfoil',
+ **{
+ key: key.replace('_', ' ').title()
+ for key in MetricExampleFactory.available_examples()
+ },
+ }
diff --git a/src/ToolboxServices.py b/src/ToolboxServices.py
new file mode 100644
index 0000000..f5ba752
--- /dev/null
+++ b/src/ToolboxServices.py
@@ -0,0 +1,506 @@
+from __future__ import annotations
+
+from dataclasses import dataclass, field
+
+import Camber
+import ContourAnalysis as ca
+from CSTAirfoil import METHOD_CST_MODIFIED
+import FileOperations
+import MetricTriangulation
+import Mesh as MeshModel
+import Meshing
+import SplineRefine
+import TrailingEdge
+from Utils import get_main_window
+
+import logging
+logger = logging.getLogger(__name__)
+
+
+@dataclass(slots=True)
+class SplineRefineSettings:
+ tolerance: float
+ points: int
+ ref_te: int
+ ref_te_n: int
+ ref_te_ratio: float
+ method: str = METHOD_CST_MODIFIED
+ cst_order: int = 8
+
+
+@dataclass(slots=True)
+class TrailingEdgeSettings:
+ upper_blend: float
+ lower_blend: float
+ upper_exponent: float
+ lower_exponent: float
+ thickness: float
+
+
+@dataclass(slots=True)
+class MeshExportSettings:
+ boundary_definitions: dict[str, str] = field(default_factory=dict)
+ formats: list[str] = field(default_factory=list)
+
+ def __post_init__(self):
+ self.boundary_definitions = (
+ MeshModel.BoundaryDefinitions.from_mapping(
+ self.boundary_definitions
+ ).as_dict()
+ )
+
+ normalized_formats = []
+ seen_formats = set()
+ for mesh_format in self.formats:
+ normalized = MeshModel.MeshExportRegistry.normalize_format(
+ mesh_format
+ )
+ if normalized in seen_formats:
+ continue
+ normalized_formats.append(normalized)
+ seen_formats.add(normalized)
+ self.formats = normalized_formats
+
+
+@dataclass(slots=True)
+class MetricTestSettings:
+ example: str
+ width: float
+ height: float
+ hole_radius: float
+ hole_spacing: float
+ outer_resolution: int
+ hole_resolution: int
+ interior_x: int
+ interior_y: int
+ airfoil_path: str | None = None
+
+ def to_triangulation_settings(self):
+ return MetricTriangulation.MetricTriangulationSettings(
+ example=self.example,
+ width=self.width,
+ height=self.height,
+ hole_radius=self.hole_radius,
+ hole_spacing=self.hole_spacing,
+ outer_resolution=self.outer_resolution,
+ hole_resolution=self.hole_resolution,
+ interior_x=self.interior_x,
+ interior_y=self.interior_y,
+ airfoil_path=self.airfoil_path,
+ )
+
+
+class WorkflowService:
+ def __init__(self, mainwindow=None):
+ self.mw = mainwindow or get_main_window()
+
+ def require_airfoil(self, require_spline: bool = False):
+ airfoil = getattr(self.mw, 'airfoil', None)
+ if airfoil is None:
+ raise ValueError('No airfoil loaded.')
+
+ if require_spline and not airfoil.has_spline:
+ raise ValueError('Please prepare the contour first.')
+
+ return airfoil
+
+ def spline_and_refine(self, settings: SplineRefineSettings):
+ airfoil = self.require_airfoil()
+ airfoil.has_TE = False
+
+ refine = SplineRefine.SplineRefine()
+ refine.doSplineRefine(
+ tolerance=settings.tolerance,
+ points=settings.points,
+ ref_te=settings.ref_te,
+ ref_te_n=settings.ref_te_n,
+ ref_te_ratio=settings.ref_te_ratio,
+ method=settings.method,
+ cst_order=settings.cst_order,
+ )
+ return airfoil, refine
+
+ def add_trailing_edge(self, settings: TrailingEdgeSettings):
+ airfoil = self.require_airfoil(require_spline=True)
+ airfoil.has_TE = True
+
+ trailing = TrailingEdge.TrailingEdge()
+ trailing.trailingEdge(
+ blend=settings.upper_blend,
+ ex=settings.upper_exponent,
+ thickness=settings.thickness,
+ side='both',
+ lower_blend=settings.lower_blend,
+ lower_exponent=settings.lower_exponent,
+ )
+ refine = SplineRefine.SplineRefine()
+ rebuilt = refine.rebuildSplineData(airfoil.spline_data.coordinates)
+ if rebuilt is not None:
+ airfoil.spline_data = rebuilt
+ return airfoil, refine
+
+ def generate_mesh(self, settings):
+ airfoil = self.require_airfoil(require_spline=True)
+
+ wind_tunnel = Meshing.Windtunnel()
+ completed = wind_tunnel.makeMesh(settings=settings, airfoil=airfoil)
+ if not completed:
+ return None
+ return wind_tunnel
+
+ def generate_hybrid_stage4(self, settings):
+ airfoil = self.require_airfoil(require_spline=True)
+
+ wind_tunnel = Meshing.Windtunnel()
+ completed = wind_tunnel.makeHybridStage4Mesh(
+ settings=settings,
+ airfoil=airfoil,
+ )
+ if not completed:
+ return None
+ return wind_tunnel
+
+ def apply_hybrid_stage2(self, wind_tunnel, settings):
+ if wind_tunnel is None:
+ raise ValueError('Please run Stage 4 first.')
+
+ airfoil = self.require_airfoil(require_spline=True)
+ completed = wind_tunnel.applyHybridStage2(
+ settings=settings,
+ airfoil=airfoil,
+ )
+ if not completed:
+ return None
+ return wind_tunnel
+
+ def apply_hybrid_stage1(self, wind_tunnel, settings):
+ if wind_tunnel is None:
+ raise ValueError('Please run Stage 4 first.')
+
+ airfoil = self.require_airfoil(require_spline=True)
+ completed = wind_tunnel.applyHybridStage1(
+ settings=settings,
+ airfoil=airfoil,
+ )
+ if not completed:
+ return None
+ return wind_tunnel
+
+ def export_mesh(self, wind_tunnel, filename: str,
+ settings: MeshExportSettings):
+ if wind_tunnel is None:
+ raise ValueError('Please generate a mesh first.')
+ if not settings.formats:
+ raise ValueError('Please select at least one export format.')
+
+ wind_tunnel.setBoundaryDefinitions(settings.boundary_definitions)
+ exported_files = []
+ for mesh_format in settings.formats:
+ output_name = (
+ filename + MeshModel.MeshExportRegistry.extension_for(mesh_format)
+ )
+ try:
+ wind_tunnel.export_mesh(mesh_format, name=output_name)
+ except (OSError, ValueError) as error:
+ FileOperations.report_io_error(
+ 'export mesh',
+ output_name,
+ error,
+ mainwindow=self.mw,
+ )
+ return exported_files
+ exported_files.append(output_name)
+ return exported_files
+
+ def export_contour(self, filename: str):
+ airfoil = self.require_airfoil()
+ return FileOperations.write_contour(
+ airfoil,
+ filename,
+ prefer_spline=True,
+ mainwindow=self.mw,
+ )
+
+ def export_camber(self, filename: str):
+ airfoil = self.require_airfoil(require_spline=True)
+ return FileOperations.write_camber(
+ airfoil,
+ filename,
+ mainwindow=self.mw,
+ )
+
+ def export_cst(self, filename: str):
+ airfoil = self.require_airfoil(require_spline=True)
+ return FileOperations.write_cst_parameters(
+ airfoil,
+ filename,
+ mainwindow=self.mw,
+ )
+
+ def generate_metric_test(self, settings: MetricTestSettings):
+ return MetricTriangulation.MetricTriangulator.generate(
+ settings.to_triangulation_settings()
+ )
+
+
+class ToolboxWorkflowController:
+ def __init__(self, toolbox, mainwindow=None):
+ self.toolbox = toolbox
+ self.mw = mainwindow or get_main_window()
+ self.service = WorkflowService(self.mw)
+
+ def spline_and_refine(self, settings: SplineRefineSettings):
+ try:
+ airfoil, refine = self.service.spline_and_refine(settings)
+ except ValueError as error:
+ self._show_message(str(error))
+ return None
+
+ airfoil.makeContourSpline()
+ rc, le_id = self._update_derived_contour_geometry(airfoil, refine=refine)
+ self.invalidate_contour_analysis(airfoil)
+ self.invalidate_mesh_state(airfoil)
+
+ logger.info('Leading edge radius: {:11.8f}'.format(rc))
+ logger.info('Leading edge circle tangent at point: {}'.format(le_id))
+
+ self.toolbox.trailingButton.setEnabled(True)
+ self.toolbox.exportContourButton.setEnabled(True)
+ self.toolbox.refreshWorkflowState()
+ return airfoil
+
+ def add_trailing_edge(self, settings: TrailingEdgeSettings):
+ try:
+ airfoil, refine = self.service.add_trailing_edge(settings)
+ except ValueError as error:
+ self._show_message(str(error))
+ return None
+ self.refresh_modified_contour_scene()
+ self._update_derived_contour_geometry(airfoil, refine=refine)
+ self.invalidate_contour_analysis(airfoil)
+ self.invalidate_mesh_state(airfoil)
+ self.toolbox.refreshWorkflowState()
+ return airfoil
+
+ def refresh_modified_contour_scene(self):
+ airfoil = getattr(self.mw, 'airfoil', None)
+ if airfoil is None:
+ return None
+
+ airfoil.makeContourSpline()
+ airfoil.setSplineFillEnabled(self.toolbox.splineFillEnabled())
+ airfoil.polygonMarkersGroup.setZValue(120)
+ if airfoil.splineMarkersGroup is not None:
+ airfoil.splineMarkersGroup.setZValue(140)
+
+ if airfoil.chord is not None:
+ airfoil.chord.setZValue(30)
+ if airfoil.camberline is not None:
+ airfoil.camberline.setZValue(35)
+ if airfoil.camber_circles is not None:
+ airfoil.camber_circles.setZValue(34)
+
+ self.mw.view.adjustMarkerSize()
+ return airfoil
+
+ def invalidate_contour_analysis(self, airfoil=None):
+ airfoil = airfoil or getattr(self.mw, 'airfoil', None)
+ if airfoil is not None:
+ airfoil.curvature_data = None
+ self.toolbox.cgb.setEnabled(False)
+
+ def invalidate_mesh_state(self, airfoil=None):
+ airfoil = airfoil or getattr(self.mw, 'airfoil', None)
+ if airfoil is not None:
+ for attribute_name in ('mesh', 'mesh_blocks', 'mesh_quality'):
+ item = getattr(airfoil, attribute_name, None)
+ self._remove_scene_item(item)
+ setattr(airfoil, attribute_name, None)
+ airfoil.mesh_model = None
+ airfoil.domain_model = None
+
+ self.toolbox.wind_tunnel = None
+ self.toolbox.box_meshexport.setEnabled(False)
+ self._set_checkbox_state('mesh_checkbox', checked=False, enabled=False)
+ self._set_checkbox_state(
+ 'mesh_blocks_checkbox',
+ checked=False,
+ enabled=False,
+ )
+ self.toolbox.refreshWorkflowState()
+
+ def generate_mesh(self, settings):
+ try:
+ wind_tunnel = self.service.generate_mesh(settings)
+ except ValueError as error:
+ self._show_message(str(error))
+ return None
+ if wind_tunnel is None:
+ return None
+
+ self.toolbox.box_meshexport.setEnabled(True)
+ self.toolbox.refreshWorkflowState()
+ return wind_tunnel
+
+ def generate_hybrid_stage4(self, settings):
+ try:
+ wind_tunnel = self.service.generate_hybrid_stage4(settings)
+ except ValueError as error:
+ self._show_message(str(error))
+ return None
+ if wind_tunnel is None:
+ return None
+
+ self.toolbox.box_meshexport.setEnabled(True)
+ self.toolbox.refreshWorkflowState()
+ return wind_tunnel
+
+ def apply_hybrid_stage2(self, wind_tunnel, settings):
+ try:
+ updated = self.service.apply_hybrid_stage2(wind_tunnel, settings)
+ except ValueError as error:
+ self._show_message(str(error))
+ return None
+ if updated is None:
+ return None
+
+ self.toolbox.box_meshexport.setEnabled(True)
+ self.toolbox.refreshWorkflowState()
+ return updated
+
+ def apply_hybrid_stage1(self, wind_tunnel, settings):
+ try:
+ updated = self.service.apply_hybrid_stage1(wind_tunnel, settings)
+ except ValueError as error:
+ self._show_message(str(error))
+ return None
+ if updated is None:
+ return None
+
+ self.toolbox.box_meshexport.setEnabled(True)
+ self.toolbox.refreshWorkflowState()
+ return updated
+
+ def prepare_contour_analysis(self):
+ airfoil = self._require_airfoil(require_spline=True)
+ if airfoil is None:
+ return None
+
+ self.mw.mainArea.tabs.setCurrentIndex(1)
+ self.toolbox.setCurrentIndex(self.toolbox.tb3)
+ self.toolbox.cgb.setEnabled(True)
+ self.mw.contourview.analyze()
+ return airfoil
+
+ def refresh_camber_geometry(self, airfoil=None):
+ airfoil = airfoil or getattr(self.mw, 'airfoil', None)
+ if airfoil is None or not getattr(airfoil, 'has_spline', False):
+ return None
+ self._update_derived_contour_geometry(airfoil)
+ return airfoil
+
+ def draw_contour_analysis(self, quantity: str):
+ airfoil = self._require_airfoil(require_spline=True)
+ if airfoil is None:
+ return None
+
+ if not hasattr(airfoil, 'curvature_data'):
+ return None
+ if getattr(airfoil, 'curvature_data', None) is None:
+ return None
+
+ self.mw.contourview.drawContour(quantity)
+ return quantity
+
+ def export_mesh(self, wind_tunnel, filename: str,
+ settings: MeshExportSettings):
+ try:
+ return self.service.export_mesh(wind_tunnel, filename, settings)
+ except ValueError as error:
+ self._show_message(str(error))
+ return []
+
+ def export_contour(self, filename: str):
+ try:
+ return self.service.export_contour(filename)
+ except ValueError as error:
+ self._show_message(str(error))
+ return None
+
+ def export_camber(self, filename: str):
+ try:
+ return self.service.export_camber(filename)
+ except ValueError as error:
+ self._show_message(str(error))
+ return None
+
+ def export_cst(self, filename: str):
+ try:
+ return self.service.export_cst(filename)
+ except ValueError as error:
+ self._show_message(str(error))
+ return None
+
+ def generate_metric_test(self, settings: MetricTestSettings):
+ try:
+ return self.service.generate_metric_test(settings)
+ except ValueError as error:
+ self._show_message(str(error))
+ return None
+
+ def _require_airfoil(self, require_spline: bool = False):
+ airfoil = getattr(self.mw, 'airfoil', None)
+ if airfoil is None:
+ self._show_message('No airfoil loaded.')
+ return None
+
+ if require_spline and not airfoil.has_spline:
+ self._show_message('Please prepare the contour first.')
+ return None
+
+ return airfoil
+
+ def _show_message(self, message: str):
+ self.mw.slots.messageBox(message)
+
+ def _update_derived_contour_geometry(self, airfoil, refine=None):
+ spline_data = airfoil.spline_data
+ curvature_data = ca.ContourAnalysis.getCurvature(spline_data)
+ rc, xc, yc, xle, yle, le_id = ca.ContourAnalysis.getLeRadius(
+ spline_data,
+ curvature_data,
+ )
+
+ refine = refine or SplineRefine.SplineRefine()
+ refine.makeLeCircle(rc, xc, yc, xle, yle)
+
+ camber_builder = Camber.CamberBuilder()
+ camber_data = camber_builder.build(
+ spline_data,
+ rc,
+ xc,
+ yc,
+ xle,
+ yle,
+ )
+ airfoil.camber_data = camber_data
+ airfoil.drawCamber(camber_data)
+ airfoil.drawCamberCircles(camber_data)
+ airfoil.drawCamberMaximumMarkers(camber_data)
+ return rc, le_id
+
+ def _remove_scene_item(self, item):
+ if item is None:
+ return
+ scene = item.scene()
+ if scene is not None:
+ scene.removeItem(item)
+
+ def _set_checkbox_state(self, name, checked=None, enabled=None):
+ checkbox = getattr(self.mw.mainArea, name, None)
+ if checkbox is None:
+ return
+ if checked is not None:
+ checkbox.setChecked(checked)
+ if enabled is not None:
+ checkbox.setEnabled(enabled)
diff --git a/src/ToolboxWidgets.py b/src/ToolboxWidgets.py
new file mode 100644
index 0000000..236131c
--- /dev/null
+++ b/src/ToolboxWidgets.py
@@ -0,0 +1,129 @@
+from PySide6 import QtCore, QtWidgets
+
+from Utils import get_main_window
+
+
+PAGE_BODY_WIDTH = 352
+
+
+def configure_form_layout(form):
+ form.setContentsMargins(0, 0, 0, 0)
+ form.setHorizontalSpacing(12)
+ form.setVerticalSpacing(10)
+ form.setLabelAlignment(QtCore.Qt.AlignLeft | QtCore.Qt.AlignTop)
+ form.setFormAlignment(QtCore.Qt.AlignTop)
+ form.setFieldGrowthPolicy(QtWidgets.QFormLayout.AllNonFixedFieldsGrow)
+ form.setRowWrapPolicy(QtWidgets.QFormLayout.WrapLongRows)
+ return form
+
+
+def make_page_label(text, tooltip=None):
+ label = QtWidgets.QLabel(text)
+ label.setWordWrap(True)
+ label.setProperty('pageFieldLabel', 'true')
+ if tooltip:
+ label.setToolTip(tooltip)
+ return label
+
+
+def make_page_option(text, tooltip=None):
+ option = QtWidgets.QCheckBox(text)
+ option.setProperty('pageOption', 'true')
+ if tooltip:
+ option.setToolTip(tooltip)
+ return option
+
+
+def make_page_radio(text, tooltip=None):
+ option = QtWidgets.QRadioButton(text)
+ option.setProperty('pageChoice', 'true')
+ if tooltip:
+ option.setToolTip(tooltip)
+ return option
+
+
+def right_aligned_row(*widgets):
+ layout = QtWidgets.QHBoxLayout()
+ layout.setContentsMargins(0, 0, 0, 0)
+ layout.setSpacing(8)
+ layout.addStretch(1)
+ for widget in widgets:
+ layout.addWidget(widget)
+ return layout
+
+
+class WorkflowStepButton(QtWidgets.QPushButton):
+ """Styled navigation button for one workflow step."""
+
+ status_titles = {
+ 'ready': 'Ready',
+ 'done': 'Done',
+ 'disabled': 'Waiting',
+ 'info': 'Info',
+ }
+
+ def __init__(self, title, subtitle='', parent=None):
+ super().__init__(parent)
+ self.title = title
+ self.subtitle = subtitle
+ self.status = 'ready'
+
+ self.setCheckable(True)
+ self.setCursor(QtCore.Qt.PointingHandCursor)
+ self.setSizePolicy(
+ QtWidgets.QSizePolicy.Expanding,
+ QtWidgets.QSizePolicy.Fixed,
+ )
+ self.setMinimumHeight(62)
+ self.setProperty('navRole', 'step')
+ self._refreshText()
+
+ def set_status(self, status, subtitle=None):
+ self.status = status
+ if subtitle is not None:
+ self.subtitle = subtitle
+ self.setProperty('workflowStatus', status)
+ self._refreshText()
+ self.style().unpolish(self)
+ self.style().polish(self)
+ self.update()
+
+ def _refreshText(self):
+ status_title = self.status_titles.get(self.status, '')
+ if status_title and self.subtitle:
+ detail = f'{status_title}: {self.subtitle}'
+ else:
+ detail = self.subtitle or status_title
+
+ if detail:
+ self.setText(f'{self.title}\n{detail}')
+ else:
+ self.setText(self.title)
+
+
+class ListWidget(QtWidgets.QListWidget):
+ """List widget that exposes airfoil activation/removal shortcuts."""
+
+ def __init__(self, parent):
+ super().__init__()
+ self.mw = parent
+
+ self.itemClicked.connect(self.listItemClicked)
+ self.itemDoubleClicked.connect(self.listItemDoubleClicked)
+
+ # get MainWindow instance (overcomes handling parents)
+ self.mw = get_main_window()
+
+ def keyPressEvent(self, event):
+ super().keyPressEvent(event)
+
+ def listItemClicked(self, item):
+ """show information of airfoil in message window"""
+ pass
+
+ def listItemDoubleClicked(self, item):
+ """make double clicked name in listwidget new active airfoil"""
+ for airfoil in self.mw.airfoils:
+ if airfoil.name == item.text():
+ self.mw.slots.activateAirfoil(airfoil)
+ break
diff --git a/src/TrailingEdge.py b/src/TrailingEdge.py
index b3d7dd4..dbd9d51 100644
--- a/src/TrailingEdge.py
+++ b/src/TrailingEdge.py
@@ -1,41 +1,33 @@
-import copy
-
-import numpy as np
-
-from PySide6 import QtGui, QtCore
-from Utils import Utils
-import ContourAnalysis as ca
-
+import numpy as np
+
+from MathUtils import VectorUtils
+from Utils import get_main_window
class TrailingEdge:
def __init__(self):
# get MainWindow instance (overcomes handling parents)
- self.mainwindow = QtCore.QCoreApplication.instance().mainwindow
-
- def getUpperLower(self):
- """Split contour in upper and lower parts
-
- Returns:
- TYPE: Coordinates of upper and lower contours
- """
- # leading edge radius
- # get LE radius, etc.
- spline_data = self.mainwindow.airfoil.spline_data
- curvature_data = ca.ContourAnalysis.getCurvature(spline_data)
- rc, xc, yc, xle, yle, le_id = ca.ContourAnalysis.getLeRadius(spline_data,
- curvature_data)
-
- x, y = spline_data[0]
- upper = (x[:le_id + 1], y[:le_id + 1])
- lower = (x[le_id:], y[le_id:])
-
- return upper, lower
-
- def trailingEdge(self, blend=0.3, ex=3.0, thickness=0.6, side='both'):
- """Implement a finite trailing edge thickness into the original
- contour (i.e. a blunt trailing edge)
+ self.mw = get_main_window()
+
+ def getUpperLower(self):
+ """Split contour in upper and lower parts
+
+ Returns:
+ TYPE: Coordinates of upper and lower contours
+ """
+ spline_data = self.mw.airfoil.spline_data
+ x, y = spline_data.coordinates
+ le_id = int(np.argmin(x))
+ upper = (x[:le_id + 1], y[:le_id + 1])
+ lower = (x[le_id:], y[le_id:])
+
+ return upper, lower
+
+ def trailingEdge(self, blend=0.3, ex=3.0, thickness=0.6, side='both',
+ lower_blend=None, lower_exponent=None):
+ """Implement a finite trailing edge thickness into the original
+ contour (i.e. a blunt trailing edge)
Args:
blend (float, optional): Length to blend the TE
@@ -45,51 +37,61 @@ def trailingEdge(self, blend=0.3, ex=3.0, thickness=0.6, side='both'):
side (str, optional): Defines if blending is done on upper,
lower or both sides
- Returns:
- tuple: Updated spline coordinates
- """
- upper, lower = self.getUpperLower()
- xu = copy.copy(upper[0])
- yu = copy.copy(upper[1])
- xl = copy.copy(lower[0])
- yl = copy.copy(lower[1])
- xnu = copy.copy(xu)
- ynu = copy.copy(yu)
- xnl = copy.copy(xl)
- ynl = copy.copy(yl)
- if side == 'upper' or side == 'both':
- xnu, ynu = self.trailing(xu, yu, blend, ex, thickness,
- side='upper')
- if side == 'lower' or side == 'both':
- xnl, ynl = self.trailing(xl, yl, blend, ex, thickness,
- side='lower')
- xt = np.concatenate([xnu, xnl[1:]])
- yt = np.concatenate([ynu, ynl[1:]])
- self.mainwindow.airfoil.spline_data[0] = (xt, yt)
+ Returns:
+ tuple: Updated spline coordinates
+ """
+ upper, lower = self.getUpperLower()
+ xu = np.array(upper[0], copy=True)
+ yu = np.array(upper[1], copy=True)
+ xl = np.array(lower[0], copy=True)
+ yl = np.array(lower[1], copy=True)
+ xnu = np.array(xu, copy=True)
+ ynu = np.array(yu, copy=True)
+ xnl = np.array(xl, copy=True)
+ ynl = np.array(yl, copy=True)
+
+ lower_blend = blend if lower_blend is None else lower_blend
+ lower_exponent = ex if lower_exponent is None else lower_exponent
+
+ if side == 'upper' or side == 'both':
+ xnu, ynu = self.trailing(xu, yu, blend, ex, thickness,
+ side='upper')
+ if side == 'lower' or side == 'both':
+ xnl, ynl = self.trailing(xl, yl, lower_blend, lower_exponent, thickness,
+ side='lower')
+ xt = np.concatenate([xnu, xnl[1:]])
+ yt = np.concatenate([ynu, ynl[1:]])
+ self.mw.airfoil.spline_data.coordinates = (xt, yt)
def trailing(self, xx, yy, blend, ex, thickness, side='upper'):
- xmin = np.min(xx)
- xmax = np.max(xx)
- chord = xmax - xmin
- thickness = chord * thickness / 100.0
- blend_points = np.where(xx > (1.0 - blend) * xmax)
- x = copy.copy(xx)
- y = copy.copy(yy)
- if side == 'upper':
- signum = 1.0
- a = np.array([x[1] - x[0], y[1] - y[0]])
- elif side == 'lower':
- signum = -1.0
+ xmin = np.min(xx)
+ xmax = np.max(xx)
+ chord = xmax - xmin
+ thickness = chord * thickness / 100.0
+ x = np.array(xx, copy=True)
+ y = np.array(yy, copy=True)
+ if blend <= 0.0 or xmax <= 0.0 or thickness == 0.0:
+ return x, y
+
+ blend_mask = x > (1.0 - blend) * xmax
+ if not np.any(blend_mask):
+ return x, y
+
+ if side == 'upper':
+ signum = 1.0
+ a = np.array([x[1] - x[0], y[1] - y[0]])
+ elif side == 'lower':
+ signum = -1.0
a = np.array([x[-2] - x[-1], y[-2] - y[-1]])
- e = Utils.unit_vector(a)
- n = np.array([e[1], -e[0]])
- shift = 0.5 * thickness
- for i in blend_points:
- shift_blend = (x[i] - xmax * (1.0 - blend)) / \
- (xmax * blend)
- x[i] = x[i] + signum * n[0] * shift_blend**ex * shift
- y[i] = y[i] + signum * n[1] * shift_blend**ex * shift
- return x, y
+ e = VectorUtils.unit_vector(a)
+ n = np.array([e[1], -e[0]])
+ shift = 0.5 * thickness
+
+ shift_blend = (x[blend_mask] - xmax * (1.0 - blend)) / (xmax * blend)
+ delta = signum * np.power(shift_blend, ex) * shift
+ x[blend_mask] += n[0] * delta
+ y[blend_mask] += n[1] * delta
+ return x, y
def writeContour(self):
diff --git a/src/UiExport.py b/src/UiExport.py
new file mode 100644
index 0000000..ead583b
--- /dev/null
+++ b/src/UiExport.py
@@ -0,0 +1,178 @@
+import logging
+import re
+
+from PySide6 import QtCore, QtGui, QtWidgets
+
+import FileDialog
+
+
+logger = logging.getLogger(__name__)
+
+
+def slugify_text(text, fallback='widget'):
+ slug = re.sub(r'[^a-z0-9]+', '_', str(text).lower())
+ return slug.strip('_') or fallback
+
+
+def export_widget_as_png(
+ mainwindow,
+ widget,
+ default_name,
+ dialog_title,
+ success_label,
+ rounded_radius=None,
+):
+ parent_widget = widget.window() if widget is not None else mainwindow
+ dialog = FileDialog.Dialog(
+ mainwindow=mainwindow,
+ parent_widget=parent_widget,
+ )
+ filename, _selected_filter = dialog.save_filename(
+ filename=default_name,
+ title=dialog_title,
+ filter='PNG images (*.png)',
+ )
+ if not filename:
+ logger.info('No file selected. %s export canceled.', success_label)
+ return False
+
+ if not filename.lower().endswith('.png'):
+ filename = f'{filename}.png'
+
+ try:
+ save_widget_png(
+ widget=widget,
+ filename=filename,
+ rounded_radius=rounded_radius,
+ fallback_widget=mainwindow,
+ )
+ except OSError as error:
+ logger.error(
+ 'Failed to export %s to %s: %s',
+ success_label.lower(),
+ filename,
+ error,
+ exc_info=True,
+ )
+ QtWidgets.QMessageBox.information(
+ parent_widget,
+ 'Information',
+ f'Failed to export {success_label.lower()}:\n{filename}\n\n{error}',
+ QtWidgets.QMessageBox.Ok,
+ )
+ return False
+
+ logger.info('%s exported to %s', success_label, filename)
+ return True
+
+
+def save_widget_png(widget, filename, rounded_radius=None, fallback_widget=None):
+ if widget is None:
+ raise OSError('No widget was provided for export.')
+
+ widget.ensurePolished()
+ if widget.layout() is not None:
+ widget.layout().activate()
+ if widget.size().width() <= 0 or widget.size().height() <= 0:
+ size_hint = widget.sizeHint()
+ if size_hint.width() > 0 and size_hint.height() > 0:
+ widget.resize(size_hint)
+ if widget.layout() is not None:
+ widget.layout().activate()
+
+ QtWidgets.QApplication.processEvents()
+
+ logical_size = widget.size()
+ if logical_size.width() <= 0 or logical_size.height() <= 0:
+ raise OSError('The widget has no visible size to export.')
+
+ screen = widget.screen()
+ if screen is None and fallback_widget is not None:
+ screen = fallback_widget.screen()
+ if screen is None:
+ screen = QtWidgets.QApplication.primaryScreen()
+
+ scale = 1.0
+ if screen is not None:
+ try:
+ scale = max(1.0, float(screen.devicePixelRatio()))
+ except (TypeError, ValueError):
+ scale = 1.0
+
+ image = QtGui.QImage(
+ max(1, int(round(logical_size.width() * scale))),
+ max(1, int(round(logical_size.height() * scale))),
+ QtGui.QImage.Format_ARGB32_Premultiplied,
+ )
+ image.fill(QtCore.Qt.transparent)
+
+ painter = QtGui.QPainter(image)
+ try:
+ painter.setRenderHint(QtGui.QPainter.Antialiasing, True)
+ painter.scale(scale, scale)
+
+ if rounded_radius is not None:
+ clip_path = QtGui.QPainterPath()
+ clip_path.addRoundedRect(
+ QtCore.QRectF(
+ 0.0,
+ 0.0,
+ float(logical_size.width()),
+ float(logical_size.height()),
+ ),
+ rounded_radius,
+ rounded_radius,
+ )
+ painter.setClipPath(clip_path)
+
+ if isinstance(widget, QtWidgets.QGraphicsView):
+ widget.render(
+ painter,
+ target=QtCore.QRectF(
+ 0.0,
+ 0.0,
+ float(logical_size.width()),
+ float(logical_size.height()),
+ ),
+ source=widget.viewport().rect(),
+ aspectRatioMode=QtCore.Qt.IgnoreAspectRatio,
+ )
+ else:
+ widget.render(
+ painter,
+ QtCore.QPoint(),
+ QtGui.QRegion(),
+ QtWidgets.QWidget.DrawWindowBackground
+ | QtWidgets.QWidget.DrawChildren,
+ )
+ finally:
+ painter.end()
+
+ if not image.save(filename):
+ raise OSError('Qt could not save the PNG image.')
+
+
+def install_dialog_export_button(
+ button_box,
+ mainwindow,
+ widget,
+ default_name,
+ dialog_title,
+ success_label,
+ rounded_radius=18.0,
+):
+ button = button_box.addButton(
+ 'Export PNG...',
+ QtWidgets.QDialogButtonBox.ActionRole,
+ )
+ button.clicked.connect(
+ lambda *_: export_widget_as_png(
+ mainwindow=mainwindow,
+ widget=widget,
+ default_name=default_name,
+ dialog_title=dialog_title,
+ success_label=success_label,
+ rounded_radius=rounded_radius,
+ )
+ )
+ return button
diff --git a/src/Utils.py b/src/Utils.py
index ea34906..43b571f 100644
--- a/src/Utils.py
+++ b/src/Utils.py
@@ -1,230 +1,44 @@
-# -*- coding: utf-8 -*-
-
-import numpy as np
-
-
-class Transformations:
- """Collection of static methods for geometric transformations
-
- Dependencies: numpy (np)
-
- NOTE: Homogenous coordinates are used,
- i.e. 4-th dimension is 1: P(x/y/z/1)
- This is necessary so that also translation
- can be handled using matrix operations
-
- Several geometric operations can be combined into one transformation matrix
- by multiplying all individual transformations.
- Multiplication via numpy.dot() --> A*B: A.dot(B)
- Order is from right (1st transformation) to left (last transformation)
-
- Example: Rotate (R) then translate (T1) then scale (S) then mirror (M)
- then translate (T2)
- Setup individual transformation matrices
- >>> R = rotate3D(...)
- >>> T1 = translate3D(...)
- >>> S = scale3D(...)
- >>> M = mirror3D(...)
- >>> T2 = translate3D(...)
- Compile combined transformation matrix
- >>> MATRIX = T2.dot(M.dot(S.dot(T1.dot(R))))
- Apply combined transformation to points(s)
- >>> P = (1., 2., 6., 1.)
- >>> P_new = MATRIX.dot(P)
- """
-
- @staticmethod
- def rotate3D(axis='x', phi=0.0, degree=True):
- """Calculate 3D transformation matrix for rotation
- around one of the coordinate system axis
-
- Args:
- axis (str, optional): Rotation axis
- phi (float, optional): Rotation angle
- degree (bool, optional): Specifies if input is in degree or radians
-
- EXAMPLE: Rotate point P around x-axis by 90° to get P_rot
- >>> P = (1., 2., 6., 1.)
- >>> rotmat = rotate3D(axis='x', phi=90.0)
- >>> P_rot = rotmat.dot(P)
- """
-
- if degree:
- phi = phi / 180.0 * np.pi
-
- if axis == 'x':
- # 3D rotation x-axis
- ROT = np.array([[1.0, 0.0, 0.0, 0.0],
- [0.0, np.cos(phi), -np.sin(phi), 0.0],
- [0.0, np.sin(phi), np.cos(phi), 0.0],
- [0.0, 0.0, 0.0, 1.0]])
- elif axis == 'y':
- # 3D rotation y-axis
- ROT = np.array([[np.cos(phi), 0.0, np.sin(phi), 0.0],
- [0.0, 1.0, 0.0, 0.0],
- [-np.sin(phi), 0.0, np.cos(phi), 0.0],
- [0.0, 0.0, 0.0, 1.0]])
- elif axis == 'z':
- # 3D rotation z-axis
- ROT = np.array([[np.cos(phi), -np.sin(phi), 0.0, 0.0],
- [np.sin(phi), np.cos(phi), 0.0, 0.0],
- [0.0, 0.0, 1.0, 0.0],
- [0.0, 0.0, 0.0, 1.0]])
-
- return ROT
-
- @staticmethod
- def translate3D(vector):
- """Calculate 3D transformation matrix for translation
- along a given vector
-
- Args:
- vector (tuple): x, y, z coordinates of translation vector
-
- EXAMPLE: Translate point P along vector to get P_trans
- >>> P = (1., 2., 6., 1.)
- >>> vector = (10., 0., 0., 1.)
- >>> transmat = translate3D(vector)
- >>> P_trans = transmat.dot(P)
- """
- TRANS = np.array([[1.0, 0.0, 0.0, vector[0]],
- [0.0, 1.0, 0.0, vector[1]],
- [0.0, 0.0, 1.0, vector[2]],
- [0.0, 0.0, 0.0, 1.0]])
- return TRANS
-
- @staticmethod
- def scale3D(scale):
- """Calculate 3D transformation matrix for scaling
-
- Args:
- scale (tuple): Scaling factors for each axis
-
- EXAMPLE: Scale point P by sx, sy, sz to get P_scale
- >>> P = (1., 2., 6., 1.)
- >>> scale = (2., 2., 2.)
- >>> scalemat = scale3D(scale)
- >>> P_scale = scalemat.dot(P)
- """
- SCALE = np.array([[scale[0], 0.0, 0.0, 0.0],
- [0.0, scale[1], 0.0, 0.0],
- [0.0, 0.0, scale[2], 0.0],
- [0.0, 0.0, 0.0, 1.0]])
- return SCALE
-
- @staticmethod
- def mirror3D(plane='xy'):
- """Calculate 3D transformation matrix for mirroring wrt to xy, xz, yz planes
-
- EXAMPLE: Mirror point P wrt xy-plane to get P_mirror
- >>> P = (1., 2., 6., 1.)
- >>> mirmat = mirror3D(plane='xy')
- >>> P_rot = mirmat.dot(P)
- """
- if plane == 'xy':
- # 3D mirroring wrt xy-plane
- MIRROR = np.array([[1.0, 0.0, 0.0, 0.0],
- [0.0, 1.0, 0.0, 0.0],
- [0.0, 0.0, -1.0, 0.0],
- [0.0, 0.0, 0.0, 1.0]])
- elif plane == 'xz':
- # 3D mirroring wrt xz-plane
- MIRROR = np.array([[1.0, 0.0, 0.0, 0.0],
- [0.0, -1.0, 0.0, 0.0],
- [0.0, 0.0, 1.0, 0.0],
- [0.0, 0.0, 0.0, 1.0]])
- elif plane == 'yz':
- # 3D mirroring wrt yz-plane
- MIRROR = np.array([[-1.0, 0.0, 0.0, 0.0],
- [0.0, 1.0, 0.0, 0.0],
- [0.0, 0.0, 1.0, 0.0],
- [0.0, 0.0, 0.0, 1.0]])
- return MIRROR
-
-
-class Utils:
- """Collection of utility functions (static methods).
- """
- def __init__(self):
- pass
-
- @staticmethod
- def vector(p1, p2):
- """Returns a vector made of two points
-
- Args:
- p1 (tuple, list or np.array): Point, e.g. (1, 2) or [7., 4.3]
- p2 (tuple, list or np.array): Point, e.g. (1, 2) or [7., 4.3]
-
- Returns:
- np.array: vector in numpy format
- """
-
- p1 = np.array(p1)
- p2 = np.array(p2)
-
- return p2 - p1
-
- @staticmethod
- def vector_length(vector):
- """ Returns the length of the vector. """
- return np.linalg.norm(vector)
-
- @staticmethod
- def unit_vector(vector):
- """ Returns the unit vector of the vector. """
- return vector / np.linalg.norm(vector)
-
- @staticmethod
- def angle_between(a, b, degree=False):
- """Returns the angle between
- vectors 'a' and 'b'
- """
- a = np.array(a)
- b = np.array(b)
-
- a_u = Utils.unit_vector(a)
- b_u = Utils.unit_vector(b)
- angle = np.arccos(np.clip(np.dot(a_u, b_u), -1.0, 1.0))
- if degree:
- angle *= 180.0 / np.pi
- return angle
-
- @staticmethod
- def scalar_to_rgb(value, vmin, vmax, range='1'):
- """Convert scalar value to RGB color
-
- Args:
- value (float): scalar value
- vmin (float): minimum value
- vmax (float): maximum value
- range (str): color range (1 or 256)
-
- Returns:
- tuple: RGB color
- """
- v = np.clip(value, vmin, vmax)
- dv = vmax - vmin
- c = [1., 1., 1.]
-
- if v < (vmin + 0.25 * dv):
- c[0] = 0
- c[1] = 4 * (v - vmin) / dv
- elif v < (vmin + 0.5 * dv):
- c[0] = 0;
- c[2] = 1 + 4 * (vmin + 0.25 * dv - v) / dv;
- elif v < (vmin + 0.75 * dv):
- c[0] = 4 * (v - vmin - 0.5 * dv) / dv;
- c[2] = 0;
- else:
- c[1] = 1 + 4 * (vmin + 0.75 * dv - v) / dv;
- c[2] = 0;
-
- r, g, b = np.clip(c[0], 0., 1.), np.clip(c[1], 0., 1.), np.clip(c[2], 0., 1.)
-
- if range == '256':
- r *= 255
- g *= 255
- b *= 255
-
- return r, g, b
\ No newline at end of file
+
+from PySide6.QtCore import QCoreApplication
+
+def get_main_window():
+ """Return the application's main window instance."""
+ return QCoreApplication.instance().mainwindow
+
+def scalar_to_rgb(value, vmin, vmax, range='1'):
+ """Convert scalar value to RGB color
+
+ Args:
+ value (float): scalar value
+ vmin (float): minimum value
+ vmax (float): maximum value
+ range (str): color range (1 or 256)
+
+ Returns:
+ tuple: RGB color
+ """
+ v = np.clip(value, vmin, vmax)
+ dv = vmax - vmin
+ c = [1., 1., 1.]
+
+ if v < (vmin + 0.25 * dv):
+ c[0] = 0
+ c[1] = 4 * (v - vmin) / dv
+ elif v < (vmin + 0.5 * dv):
+ c[0] = 0;
+ c[2] = 1 + 4 * (vmin + 0.25 * dv - v) / dv;
+ elif v < (vmin + 0.75 * dv):
+ c[0] = 4 * (v - vmin - 0.5 * dv) / dv;
+ c[2] = 0;
+ else:
+ c[1] = 1 + 4 * (vmin + 0.75 * dv - v) / dv;
+ c[2] = 0;
+
+ r, g, b = np.clip(c[0], 0., 1.), np.clip(c[1], 0., 1.), np.clip(c[2], 0., 1.)
+
+ if range == '256':
+ r *= 255
+ g *= 255
+ b *= 255
+
+ return r, g, b
\ No newline at end of file
diff --git a/tests/__pycache__/test_experimental_c_grid.cpython-312.pyc b/tests/__pycache__/test_experimental_c_grid.cpython-312.pyc
new file mode 100644
index 0000000..fb6c623
Binary files /dev/null and b/tests/__pycache__/test_experimental_c_grid.cpython-312.pyc differ
diff --git a/tests/__pycache__/test_experimental_o_grid.cpython-312.pyc b/tests/__pycache__/test_experimental_o_grid.cpython-312.pyc
new file mode 100644
index 0000000..cae1ffa
Binary files /dev/null and b/tests/__pycache__/test_experimental_o_grid.cpython-312.pyc differ
diff --git a/tests/__pycache__/test_mesh_export.cpython-312.pyc b/tests/__pycache__/test_mesh_export.cpython-312.pyc
new file mode 100644
index 0000000..fadb6b2
Binary files /dev/null and b/tests/__pycache__/test_mesh_export.cpython-312.pyc differ
diff --git a/tests/__pycache__/test_print_layout.cpython-312.pyc b/tests/__pycache__/test_print_layout.cpython-312.pyc
new file mode 100644
index 0000000..7446d69
Binary files /dev/null and b/tests/__pycache__/test_print_layout.cpython-312.pyc differ
diff --git a/tests/__pycache__/test_window_settings.cpython-312.pyc b/tests/__pycache__/test_window_settings.cpython-312.pyc
new file mode 100644
index 0000000..6d157a8
Binary files /dev/null and b/tests/__pycache__/test_window_settings.cpython-312.pyc differ
diff --git a/tests/data/MW-166-39-44-43.dat b/tests/data/MW-166-39-44-43.dat
new file mode 100644
index 0000000..9344aa1
--- /dev/null
+++ b/tests/data/MW-166-39-44-43.dat
@@ -0,0 +1,282 @@
+#
+# MW-166-39-44-43
+#
+# Profil aus Grob 109B Wing Glow. Dissertation Weissmüller
+# Daten des Profils. Dissertation Reeh
+#
+1.0 -0.001195815
+0.996512207 0.000827105
+0.993652718 0.002284603
+0.989074545 0.004671828
+0.983970306 0.006886687
+0.978306126 0.009117488
+0.972083996 0.011435966
+0.965309895 0.013860056
+0.958008731 0.016393743
+0.950245267 0.019032046
+0.942109172 0.02175703
+0.933694102 0.024547772
+0.925106666 0.02737239
+0.916386718 0.030205975
+0.90755219 0.033054504
+0.89866386 0.035903034
+0.889732687 0.038740605
+0.880758672 0.041575186
+0.871772701 0.0444008
+0.862792708 0.047206487
+0.85381969 0.049988263
+0.844851653 0.052746126
+0.835896568 0.055476091
+0.826952443 0.058176167
+0.818011307 0.060846352
+0.809080135 0.063486648
+0.800164904 0.066092071
+0.791261629 0.068659634
+0.782364332 0.071190332
+0.773479987 0.073681177
+0.764602617 0.076131171
+0.75572724 0.078541313
+0.746860829 0.0809116
+0.738014345 0.083236057
+0.729188785 0.085509699
+0.720383151 0.087731533
+0.711596447 0.089897571
+0.702826681 0.092005822
+0.694076842 0.094054293
+0.685349919 0.096037005
+0.676622 0.09795097
+0.667866183 0.099806151
+0.65908745 0.101612512
+0.650315692 0.103363077
+0.641547918 0.105049877
+0.632772175 0.106676897
+0.623995435 0.108242144
+0.615217698 0.109745618
+0.606432987 0.111186322
+0.597641302 0.112565254
+0.588843639 0.113881416
+0.580039001 0.115135805
+0.571226393 0.116327425
+0.562408803 0.117457273
+0.553585235 0.118523354
+0.544755689 0.119527663
+0.535923154 0.120468206
+0.527081652 0.121344983
+0.518235168 0.122159987
+0.509388685 0.122912222
+0.500538215 0.123598699
+0.491681768 0.12422141
+0.48282034 0.124781352
+0.473953929 0.125276532
+0.46508154 0.125708943
+0.456202178 0.126078584
+0.447319826 0.126385456
+0.438437474 0.126630555
+0.429557115 0.126811889
+0.420676755 0.126929457
+0.411794403 0.126983259
+0.402912051 0.126975288
+0.394036674 0.126903552
+0.385165282 0.12676805
+0.376295882 0.126567786
+0.367437442 0.126306745
+0.358593948 0.125979947
+0.349760416 0.125587389
+0.340944819 0.12513007
+0.332146159 0.124605
+0.323365434 0.124013176
+0.314606628 0.123351607
+0.305867749 0.122621292
+0.297151785 0.121821232
+0.288468701 0.120951429
+0.279812518 0.120005905
+0.271183236 0.118987648
+0.262587829 0.117894665
+0.254022312 0.116724964
+0.245492664 0.115478546
+0.237004862 0.114155409
+0.228559902 0.112752565
+0.220161771 0.111270015
+0.211814452 0.109705764
+0.203521932 0.108058818
+0.1952872 0.10632519
+0.187108263 0.104502888
+0.178986116 0.102591912
+0.170929729 0.100593258
+0.162945077 0.098504934
+0.155037144 0.096326939
+0.147211906 0.094057282
+0.139476339 0.091694965
+0.131833431 0.089237996
+0.124290158 0.086686374
+0.116852496 0.084040101
+0.109529412 0.081299176
+0.102329875 0.078465591
+0.095264843 0.075544328
+0.088356237 0.072542362
+0.081620993 0.069465671
+0.075079039 0.066325214
+0.068759268 0.063135937
+0.062692566 0.059915773
+0.056907829 0.056683653
+0.05143096 0.0534605
+0.046289856 0.0502772
+0.041505442 0.047158663
+0.037089673 0.044132785
+0.03304753 0.041223478
+0.029375029 0.038445689
+0.017964968 0.028703497
+0.015797933 0.026592257
+0.013849096 0.0245946
+0.012096538 0.022703551
+0.010519335 0.02090815
+0.009098557 0.019200427
+0.007820256 0.017571414
+0.006669486 0.016013142
+0.005635287 0.014518635
+0.004709689 0.01308092
+0.003881732 0.01169601
+0.00314245 0.010358925
+0.002487856 0.009065678
+0.001915958 0.007812285
+0.001419781 0.006595757
+0.000996338 0.0054141
+0.000644631 0.004265322
+0.000365656 0.003146435
+0.000161407 0.002056441
+3.88572E-05 0.000995342
+0 -3.28791E-05
+4.58315E-05 -0.001054125
+0.000186315 -0.002094302
+0.000434403 -0.003138464
+0.000806037 -0.004172663
+0.001318155 -0.00517996
+0.001983709 -0.006137441
+0.002794728 -0.007035142
+0.0037273 -0.007882029
+0.004765484 -0.008696037
+0.005902305 -0.00948414
+0.00714175 -0.010254309
+0.008485809 -0.011012522
+0.009940463 -0.011764757
+0.011515673 -0.012514003
+0.0132224 -0.013267235
+0.021707213 -0.016407692
+0.024362453 -0.017241626
+0.027295672 -0.01810047
+0.030548715 -0.018989203
+0.034170403 -0.019914801
+0.038205571 -0.020884238
+0.042701047 -0.021893528
+0.047695689 -0.022938686
+0.053217393 -0.024012738
+0.059268153 -0.025102732
+0.065829038 -0.026193722
+0.072862187 -0.027270763
+0.080309812 -0.028323892
+0.088092207 -0.029341153
+0.096132654 -0.030304612
+0.104393291 -0.03120829
+0.112836258 -0.032051192
+0.121422697 -0.032838299
+0.130114749 -0.033565626
+0.138901452 -0.034235165
+0.147767863 -0.034850902
+0.156699035 -0.035413832
+0.165685006 -0.035926946
+0.174720794 -0.03639124
+0.183806399 -0.036810698
+0.192936839 -0.03718831
+0.202106136 -0.037528061
+0.211310305 -0.037830948
+0.22054835 -0.038099959
+0.229817281 -0.038338084
+0.239110124 -0.038546319
+0.248425883 -0.038725659
+0.257763561 -0.038880092
+0.267118177 -0.039008619
+0.276488735 -0.039113235
+0.28587623 -0.039195931
+0.295275681 -0.039257704
+0.304683103 -0.039298554
+0.314103477 -0.039319477
+0.323535808 -0.039322466
+0.332970131 -0.039307521
+0.342408439 -0.039273645
+0.351855715 -0.039222832
+0.361306976 -0.039155081
+0.370760229 -0.039071389
+0.380213483 -0.038971755
+0.389668729 -0.038854187
+0.399125968 -0.038720678
+0.408584203 -0.038572223
+0.418040445 -0.038406831
+0.427493699 -0.038225498
+0.436947949 -0.03802623
+0.446404191 -0.037811021
+0.455858441 -0.037579871
+0.465313687 -0.037331783
+0.474770926 -0.037066757
+0.48423215 -0.03678579
+0.493696363 -0.036489877
+0.50315958 -0.03617902
+0.512622797 -0.035851225
+0.522089003 -0.035509481
+0.531553216 -0.035152792
+0.54101743 -0.034781158
+0.550484632 -0.034393583
+0.559946852 -0.033993055
+0.569405088 -0.033576585
+0.578860334 -0.033146168
+0.588307609 -0.032699808
+0.597746914 -0.032238504
+0.607175259 -0.031760262
+0.616589655 -0.031265082
+0.625992096 -0.030751968
+0.635373613 -0.03022092
+0.644732214 -0.029668948
+0.654066904 -0.029095058
+0.663372699 -0.028498251
+0.672646612 -0.027874544
+0.681887645 -0.027222939
+0.691085836 -0.02654244
+0.700238196 -0.025828066
+0.709340738 -0.025077824
+0.718388482 -0.024288724
+0.727374453 -0.023456782
+0.736294666 -0.022578012
+0.745144139 -0.021648429
+0.75391789 -0.020665043
+0.762609941 -0.019621878
+0.771213319 -0.01851395
+0.779727026 -0.017330301
+0.788172982 -0.016052995
+0.796611963 -0.014663104
+0.805109729 -0.013160627
+0.813698161 -0.011558515
+0.822372278 -0.009876697
+0.831098205 -0.008137091
+0.839843062 -0.006389515
+0.848551055 -0.004674817
+0.857096645 -0.003021893
+0.86540411 -0.001482551
+0.873500351 -7.87107E-05
+0.881381384 0.001174682
+0.889065141 0.00225272
+0.896616386 0.003146435
+0.904104861 0.003861805
+0.911552486 0.004414773
+0.918953284 0.004810319
+0.926310242 0.00504944
+0.933607421 0.005134129
+0.940831867 0.005061396
+0.947976605 0.004836224
+0.955007762 0.00446459
+0.96189943 0.003948487
+0.968620726 0.003300867
+0.975109874 0.002539665
+0.981328018 0.001681818
+0.987208404 0.00077814
+0.992702211 -3.08865E-05
+0.996162493 -0.000466286
+1.0 -0.001195815
diff --git a/tests/data/naca0012.dat b/tests/data/naca0012.dat
new file mode 100644
index 0000000..61f4487
--- /dev/null
+++ b/tests/data/naca0012.dat
@@ -0,0 +1,161 @@
+NACA 0012
+ 1.000000 0.1260000E-02
+ 0.9916770 0.2421811E-02
+ 0.9803640 0.3982079E-02
+ 0.9672594 0.5762782E-02
+ 0.9527094 0.7707230E-02
+ 0.9371906 0.9744292E-02
+ 0.9211098 0.1181606E-01
+ 0.9047307 0.1388650E-01
+ 0.8881990 0.1593654E-01
+ 0.8715890 0.1795702E-01
+ 0.8549374 0.1994386E-01
+ 0.8382620 0.2189533E-01
+ 0.8215722 0.2381076E-01
+ 0.8048727 0.2568989E-01
+ 0.7881664 0.2753259E-01
+ 0.7714554 0.2933869E-01
+ 0.7547412 0.3110797E-01
+ 0.7380253 0.3284008E-01
+ 0.7213088 0.3453454E-01
+ 0.7045932 0.3619074E-01
+ 0.6878796 0.3780792E-01
+ 0.6711694 0.3938518E-01
+ 0.6544637 0.4092147E-01
+ 0.6377640 0.4241557E-01
+ 0.6210716 0.4386613E-01
+ 0.6043879 0.4527161E-01
+ 0.5877143 0.4663031E-01
+ 0.5710523 0.4794038E-01
+ 0.5544034 0.4919976E-01
+ 0.5377692 0.5040625E-01
+ 0.5211515 0.5155744E-01
+ 0.5045520 0.5265072E-01
+ 0.4879725 0.5368329E-01
+ 0.4714152 0.5465216E-01
+ 0.4548821 0.5555408E-01
+ 0.4383756 0.5638562E-01
+ 0.4218982 0.5714307E-01
+ 0.4054527 0.5782250E-01
+ 0.3890420 0.5841969E-01
+ 0.3726696 0.5893014E-01
+ 0.3563391 0.5934906E-01
+ 0.3400549 0.5967133E-01
+ 0.3238215 0.5989147E-01
+ 0.3076446 0.6000362E-01
+ 0.2915302 0.6000152E-01
+ 0.2754859 0.5987849E-01
+ 0.2595202 0.5962734E-01
+ 0.2436435 0.5924040E-01
+ 0.2278683 0.5870947E-01
+ 0.2122105 0.5802584E-01
+ 0.1966898 0.5718031E-01
+ 0.1813320 0.5616342E-01
+ 0.1661718 0.5496573E-01
+ 0.1512560 0.5357861E-01
+ 0.1366496 0.5199553E-01
+ 0.1224422 0.5021437E-01
+ 0.1087546 0.4824084E-01
+ 0.9573936E-01 0.4609270E-01
+ 0.8357044E-01 0.4380301E-01
+ 0.7241381E-01 0.4141953E-01
+ 0.6238752E-01 0.3899781E-01
+ 0.5353091E-01 0.3659002E-01
+ 0.4580157E-01 0.3423525E-01
+ 0.3909828E-01 0.3195587E-01
+ 0.3329271E-01 0.2975941E-01
+ 0.2825461E-01 0.2764309E-01
+ 0.2386618E-01 0.2559815E-01
+ 0.2002762E-01 0.2361286E-01
+ 0.1665766E-01 0.2167450E-01
+ 0.1369187E-01 0.1977036E-01
+ 0.1108041E-01 0.1788831E-01
+ 0.8785821E-02 0.1601718E-01
+ 0.6781149E-02 0.1414714E-01
+ 0.5048426E-02 0.1227025E-01
+ 0.3577154E-02 0.1038132E-01
+ 0.2362688E-02 0.8479151E-02
+ 0.1403607E-02 0.6567428E-02
+ 0.6990502E-03 0.4656927E-02
+ 0.2434807E-03 0.2761035E-02
+ 0.2600131E-04 0.9055955E-03
+ 0.2600131E-04 -0.9055955E-03
+ 0.2434807E-03 -0.2761035E-02
+ 0.6990502E-03 -0.4656927E-02
+ 0.1403607E-02 -0.6567428E-02
+ 0.2362688E-02 -0.8479151E-02
+ 0.3577154E-02 -0.1038132E-01
+ 0.5048426E-02 -0.1227025E-01
+ 0.6781149E-02 -0.1414714E-01
+ 0.8785821E-02 -0.1601718E-01
+ 0.1108041E-01 -0.1788831E-01
+ 0.1369187E-01 -0.1977036E-01
+ 0.1665766E-01 -0.2167450E-01
+ 0.2002762E-01 -0.2361286E-01
+ 0.2386618E-01 -0.2559815E-01
+ 0.2825461E-01 -0.2764309E-01
+ 0.3329271E-01 -0.2975941E-01
+ 0.3909828E-01 -0.3195587E-01
+ 0.4580157E-01 -0.3423525E-01
+ 0.5353091E-01 -0.3659002E-01
+ 0.6238752E-01 -0.3899781E-01
+ 0.7241381E-01 -0.4141953E-01
+ 0.8357044E-01 -0.4380301E-01
+ 0.9573936E-01 -0.4609270E-01
+ 0.1087546 -0.4824084E-01
+ 0.1224422 -0.5021437E-01
+ 0.1366496 -0.5199553E-01
+ 0.1512560 -0.5357861E-01
+ 0.1661718 -0.5496573E-01
+ 0.1813320 -0.5616342E-01
+ 0.1966898 -0.5718031E-01
+ 0.2122105 -0.5802584E-01
+ 0.2278683 -0.5870947E-01
+ 0.2436435 -0.5924040E-01
+ 0.2595202 -0.5962734E-01
+ 0.2754859 -0.5987849E-01
+ 0.2915302 -0.6000152E-01
+ 0.3076446 -0.6000362E-01
+ 0.3238215 -0.5989147E-01
+ 0.3400549 -0.5967133E-01
+ 0.3563391 -0.5934906E-01
+ 0.3726696 -0.5893014E-01
+ 0.3890420 -0.5841969E-01
+ 0.4054527 -0.5782250E-01
+ 0.4218982 -0.5714307E-01
+ 0.4383756 -0.5638562E-01
+ 0.4548821 -0.5555408E-01
+ 0.4714152 -0.5465216E-01
+ 0.4879725 -0.5368329E-01
+ 0.5045520 -0.5265072E-01
+ 0.5211515 -0.5155744E-01
+ 0.5377692 -0.5040625E-01
+ 0.5544034 -0.4919976E-01
+ 0.5710523 -0.4794038E-01
+ 0.5877143 -0.4663031E-01
+ 0.6043879 -0.4527161E-01
+ 0.6210716 -0.4386613E-01
+ 0.6377640 -0.4241557E-01
+ 0.6544637 -0.4092147E-01
+ 0.6711694 -0.3938518E-01
+ 0.6878796 -0.3780792E-01
+ 0.7045932 -0.3619074E-01
+ 0.7213088 -0.3453454E-01
+ 0.7380253 -0.3284008E-01
+ 0.7547412 -0.3110797E-01
+ 0.7714554 -0.2933869E-01
+ 0.7881664 -0.2753259E-01
+ 0.8048727 -0.2568989E-01
+ 0.8215722 -0.2381076E-01
+ 0.8382620 -0.2189533E-01
+ 0.8549374 -0.1994386E-01
+ 0.8715890 -0.1795702E-01
+ 0.8881990 -0.1593654E-01
+ 0.9047307 -0.1388650E-01
+ 0.9211098 -0.1181606E-01
+ 0.9371906 -0.9744292E-02
+ 0.9527094 -0.7707230E-02
+ 0.9672594 -0.5762782E-02
+ 0.9803640 -0.3982079E-02
+ 0.9916770 -0.2421811E-02
+ 1.000000 -0.1260000E-02
diff --git a/tests/data/naca2315.dat b/tests/data/naca2315.dat
new file mode 100644
index 0000000..9270cf3
--- /dev/null
+++ b/tests/data/naca2315.dat
@@ -0,0 +1,161 @@
+NACA 2315
+ 1.000000 0.1575000E-02
+ 0.9909632 0.3664191E-02
+ 0.9791773 0.6352792E-02
+ 0.9658993 0.9333636E-02
+ 0.9512394 0.1256654E-01
+ 0.9354256 0.1598678E-01
+ 0.9187389 0.1952174E-01
+ 0.9014435 0.2310694E-01
+ 0.8837467 0.2669386E-01
+ 0.8657938 0.3024985E-01
+ 0.8476792 0.3375446E-01
+ 0.8294621 0.3719539E-01
+ 0.8111791 0.4056532E-01
+ 0.7928528 0.4385984E-01
+ 0.7744976 0.4707611E-01
+ 0.7561231 0.5021210E-01
+ 0.7377364 0.5326616E-01
+ 0.7193425 0.5623671E-01
+ 0.7009461 0.5912215E-01
+ 0.6825511 0.6192073E-01
+ 0.6641613 0.6463053E-01
+ 0.6457802 0.6724940E-01
+ 0.6274116 0.6977497E-01
+ 0.6090591 0.7220464E-01
+ 0.5907265 0.7453554E-01
+ 0.5724177 0.7676454E-01
+ 0.5541368 0.7888825E-01
+ 0.5358880 0.8090302E-01
+ 0.5176759 0.8280491E-01
+ 0.4995054 0.8458972E-01
+ 0.4813815 0.8625294E-01
+ 0.4633103 0.8778979E-01
+ 0.4452984 0.8919518E-01
+ 0.4273534 0.9046374E-01
+ 0.4094849 0.9158977E-01
+ 0.3917050 0.9256732E-01
+ 0.3740297 0.9339020E-01
+ 0.3564810 0.9405208E-01
+ 0.3390905 0.9454674E-01
+ 0.3219038 0.9486854E-01
+ 0.3049860 0.9501387E-01
+ 0.2884286 0.9495474E-01
+ 0.2721981 0.9462518E-01
+ 0.2562286 0.9402262E-01
+ 0.2404966 0.9314634E-01
+ 0.2250014 0.9199550E-01
+ 0.2097584 0.9057002E-01
+ 0.1947962 0.8887170E-01
+ 0.1801557 0.8690534E-01
+ 0.1658894 0.8468003E-01
+ 0.1520615 0.8221049E-01
+ 0.1387459 0.7951840E-01
+ 0.1260229 0.7663312E-01
+ 0.1139726 0.7359145E-01
+ 0.1026664 0.7043599E-01
+ 0.9215841E-01 0.6721200E-01
+ 0.8247775E-01 0.6396337E-01
+ 0.7362613E-01 0.6072886E-01
+ 0.6557969E-01 0.5753940E-01
+ 0.5829452E-01 0.5441708E-01
+ 0.5171392E-01 0.5137550E-01
+ 0.4577519E-01 0.4842116E-01
+ 0.4041503E-01 0.4555508E-01
+ 0.3557323E-01 0.4277443E-01
+ 0.3119474E-01 0.4007388E-01
+ 0.2723063E-01 0.3744659E-01
+ 0.2363831E-01 0.3488498E-01
+ 0.2038131E-01 0.3238121E-01
+ 0.1742885E-01 0.2992754E-01
+ 0.1475529E-01 0.2751653E-01
+ 0.1233967E-01 0.2514125E-01
+ 0.1016517E-01 0.2279541E-01
+ 0.8218722E-02 0.2047354E-01
+ 0.6490521E-02 0.1817118E-01
+ 0.4973651E-02 0.1588511E-01
+ 0.3663542E-02 0.1361366E-01
+ 0.2557429E-02 0.1135704E-01
+ 0.1653466E-02 0.9117456E-02
+ 0.9499606E-03 0.6899565E-02
+ 0.4442774E-03 0.4710628E-02
+ 0.1312303E-03 0.2555958E-02
+ 0.4238310E-05 0.4579697E-03
+ 0.5346421E-04 -0.1615721E-02
+ 0.2907888E-03 -0.3730689E-02
+ 0.7349078E-03 -0.5868965E-02
+ 0.1399508E-02 -0.8011289E-02
+ 0.2293800E-02 -0.1014188E-01
+ 0.3421248E-02 -0.1224463E-01
+ 0.4781597E-02 -0.1430744E-01
+ 0.6372318E-02 -0.1632198E-01
+ 0.8190768E-02 -0.1828393E-01
+ 0.1023582E-01 -0.2019235E-01
+ 0.1250912E-01 -0.2204883E-01
+ 0.1501592E-01 -0.2385672E-01
+ 0.1776569E-01 -0.2562039E-01
+ 0.2077254E-01 -0.2734477E-01
+ 0.2405574E-01 -0.2903495E-01
+ 0.2764024E-01 -0.3069592E-01
+ 0.3155738E-01 -0.3233237E-01
+ 0.3584576E-01 -0.3394855E-01
+ 0.4055216E-01 -0.3554800E-01
+ 0.4573268E-01 -0.3713338E-01
+ 0.5145365E-01 -0.3870610E-01
+ 0.5779219E-01 -0.4026588E-01
+ 0.6483579E-01 -0.4181011E-01
+ 0.7268013E-01 -0.4333320E-01
+ 0.8142392E-01 -0.4482572E-01
+ 0.9116000E-01 -0.4627393E-01
+ 0.1019624 -0.4765973E-01
+ 0.1138716 -0.4896184E-01
+ 0.1268822 -0.5015809E-01
+ 0.1409395 -0.5122873E-01
+ 0.1559465 -0.5215961E-01
+ 0.1717802 -0.5294406E-01
+ 0.1883104 -0.5358315E-01
+ 0.2054129 -0.5408439E-01
+ 0.2229763 -0.5445995E-01
+ 0.2409016 -0.5472480E-01
+ 0.2590948 -0.5489534E-01
+ 0.2774558 -0.5498850E-01
+ 0.2958612 -0.5502199E-01
+ 0.3141528 -0.5497316E-01
+ 0.3323696 -0.5477844E-01
+ 0.3505920 -0.5444697E-01
+ 0.3688598 -0.5398871E-01
+ 0.3871924 -0.5341245E-01
+ 0.4055981 -0.5272619E-01
+ 0.4240787 -0.5193732E-01
+ 0.4426328 -0.5105282E-01
+ 0.4612571 -0.5007924E-01
+ 0.4799471 -0.4902278E-01
+ 0.4986983 -0.4788924E-01
+ 0.5175059 -0.4668408E-01
+ 0.5363652 -0.4541240E-01
+ 0.5552716 -0.4407892E-01
+ 0.5742206 -0.4268804E-01
+ 0.5932078 -0.4124380E-01
+ 0.6122291 -0.3974989E-01
+ 0.6312804 -0.3820966E-01
+ 0.6503577 -0.3662615E-01
+ 0.6694570 -0.3500204E-01
+ 0.6885743 -0.3333972E-01
+ 0.7077056 -0.3164124E-01
+ 0.7268467 -0.2990840E-01
+ 0.7459927 -0.2814271E-01
+ 0.7651382 -0.2634547E-01
+ 0.7842760 -0.2451784E-01
+ 0.8033960 -0.2266100E-01
+ 0.8224832 -0.2077636E-01
+ 0.8415140 -0.1886605E-01
+ 0.8604494 -0.1693359E-01
+ 0.8792257 -0.1498511E-01
+ 0.8977393 -0.1303122E-01
+ 0.9158290 -0.1108932E-01
+ 0.9332620 -0.9185762E-02
+ 0.9497422 -0.7355755E-02
+ 0.9649583 -0.5638453E-02
+ 0.9786675 -0.4067341E-02
+ 0.9907672 -0.2661014E-02
+ 1.000000 -0.1575000E-02
diff --git a/tests/test_camber_medial_axis.py b/tests/test_camber_medial_axis.py
new file mode 100644
index 0000000..61bbd21
--- /dev/null
+++ b/tests/test_camber_medial_axis.py
@@ -0,0 +1,310 @@
+import os
+import sys
+
+import numpy as np
+import pytest
+from scipy import interpolate
+
+sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'src'))
+
+import CamberMedialAxis as cma
+from ContourData import CamberData, SplineData
+
+
+def _build_spline_data(x, y, points=200, degree=3):
+ tck, u = interpolate.splprep([x, y], s=0.0, k=degree)
+ t = np.linspace(0.0, 1.0, points)
+ coo = interpolate.splev(t, tck, der=0)
+ der1 = interpolate.splev(t, tck, der=1)
+ der2 = interpolate.splev(t, tck, der=2)
+ return SplineData(
+ coordinates=coo, fit_parameters=u, sample_parameters=t,
+ first_derivative=der1, second_derivative=der2, spline=tck,
+ method='bspline', metadata={'degree': degree},
+ leading_edge_parameter=float(t[int(np.argmin(coo[0]))]),
+ )
+
+
+def _load_dat(path):
+ xs, ys = [], []
+ with open(path) as handle:
+ lines = handle.readlines()
+ for line in lines:
+ if line.strip().startswith('#'):
+ continue
+ parts = line.split()
+ if len(parts) != 2:
+ continue
+ try:
+ xs.append(float(parts[0]))
+ ys.append(float(parts[1]))
+ except ValueError:
+ continue
+ return np.array(xs), np.array(ys)
+
+
+_DATA_DIR = os.path.join(os.path.dirname(__file__), 'data')
+NACA2315 = os.path.join(_DATA_DIR, 'naca2315.dat')
+NACA0012 = os.path.join(_DATA_DIR, 'naca0012.dat')
+MW166 = os.path.join(_DATA_DIR, 'MW-166-39-44-43.dat')
+
+
+def _le_radius_inputs(spline_data):
+ t_le = spline_data.leading_edge_parameter_value()
+ sample_t = spline_data.sample_parameters
+ dx, dy = spline_data.evaluate(sample_t, der=1)
+ x2, y2 = spline_data.evaluate(sample_t, der=2)
+ curvature_radius = ((dx ** 2 + dy ** 2) ** 1.5) / np.abs(dx * y2 - dy * x2)
+ le_id = int(np.argmin(curvature_radius))
+ rc = float(curvature_radius[le_id])
+ point = np.array((spline_data.coordinates[0][le_id], spline_data.coordinates[1][le_id]))
+ # Reference point well inside the airfoil body (near mid-chord), not the
+ # coordinate origin -- the origin sits right at/near the LE point itself
+ # for chord-normalized data, which is too close to reliably disambiguate
+ # "inward" from "outward" for a point that's also right at the LE.
+ normal = cma.inward_normal(np.array((dx[le_id], dy[le_id])), point, np.array((0.3, 0.0)))
+ xc, yc = point + rc * normal
+ xle, yle = point
+ return t_le, rc, float(xc), float(yc), float(xle), float(yle), le_id
+
+
+def _build_legacy_camber_data(spline_data, point_count=240):
+ stations = np.linspace(0.0, 1.0, point_count)
+ t_le = spline_data.leading_edge_parameter_value()
+ upper_params = t_le * (1.0 - stations)
+ lower_params = t_le + stations * (1.0 - t_le)
+ upper = np.array(spline_data.evaluate(upper_params, der=0), dtype=float).T
+ lower = np.array(spline_data.evaluate(lower_params, der=0), dtype=float).T
+ centers = 0.5 * (upper + lower)
+ radius = 0.5 * np.linalg.norm(upper - lower, axis=1)
+ return CamberData(
+ method='legacy_midpoint',
+ coordinates=(centers[:, 0], centers[:, 1]), radius=radius,
+ upper_contact=(upper[:, 0], upper[:, 1]), lower_contact=(lower[:, 0], lower[:, 1]),
+ upper_parameters=upper_params, lower_parameters=lower_params,
+ display_indices=np.arange(point_count), valid=np.ones(point_count, dtype=bool),
+ fallback_used=np.zeros(point_count, dtype=bool),
+ )
+
+
+# --- Task 1: geometry primitives and nose refinement ---------------------
+
+def test_inward_normal_points_toward_reference():
+ point = np.array((0.0, 0.0))
+ derivative = np.array((1.0, 0.0))
+ reference = np.array((0.0, 1.0))
+ normal = cma.inward_normal(derivative, point, reference)
+ assert np.dot(normal, reference - point) > 0
+ np.testing.assert_allclose(np.linalg.norm(normal), 1.0)
+
+
+def test_intersect_normals_finds_equidistant_center():
+ upper_point = np.array((1.0, 0.0))
+ lower_point = np.array((0.0, 1.0))
+ upper_normal = np.array((-1.0, 0.0))
+ lower_normal = np.array((0.0, -1.0))
+ center, radius_upper, radius_lower = cma.intersect_normals(
+ upper_point, upper_normal, lower_point, lower_normal,
+ )
+ np.testing.assert_allclose(center, (0.0, 0.0), atol=1e-12)
+ assert radius_upper == pytest.approx(1.0)
+ assert radius_lower == pytest.approx(1.0)
+
+
+def test_intersect_normals_rejects_parallel_normals():
+ upper_point = np.array((0.0, 0.0))
+ lower_point = np.array((1.0, 0.0))
+ normal = np.array((0.0, 1.0))
+ assert cma.intersect_normals(upper_point, normal, lower_point, normal) is None
+
+
+def test_refine_nose_start_matches_true_curvature_extremum():
+ x, y = _load_dat(NACA2315)
+ spline_data = _build_spline_data(x, y)
+ t_le, rc, xc, yc, xle, yle, le_id = _le_radius_inputs(spline_data)
+
+ t_star, center, radius = cma.refine_nose_start(spline_data, t_le, xc, yc, rc)
+
+ assert np.linalg.norm(center - np.array((xc, yc))) < 5e-4
+ assert abs(radius - rc) < 5e-4
+ assert 0.0 < t_star < 1.0
+
+
+def test_refine_nose_start_falls_back_when_window_collapses():
+ x, y = _load_dat(NACA2315)
+ spline_data = _build_spline_data(x, y)
+ # margin=0.0 collapses the search window to a single point (upper <=
+ # lower), which must trigger the fallback rather than raise.
+ t_star, center, radius = cma.refine_nose_start(
+ spline_data, t_le=0.5, xc=0.01, yc=0.0, rc=0.01, margin=0.0,
+ )
+ assert t_star == 0.5
+ np.testing.assert_allclose(center, (0.01, 0.0))
+ assert radius == 0.01
+
+
+# --- Task 2: continuation step ---------------------------------------------
+
+def test_continuation_step_advances_from_the_nose_with_correct_radius():
+ x, y = _load_dat(NACA2315)
+ spline_data = _build_spline_data(x, y)
+ t_le, rc, xc, yc, xle, yle, le_id = _le_radius_inputs(spline_data)
+ t_star, center0, radius0 = cma.refine_nose_start(spline_data, t_le, xc, yc, rc)
+
+ step = 0.003
+ guess_upper = t_star - step / np.sqrt(2.0)
+ guess_lower = t_star + step / np.sqrt(2.0)
+ result = cma._continuation_step(
+ spline_data, t_star, t_star, center0, guess_upper, guess_lower, step,
+ )
+ assert result is not None
+ t_upper, t_lower, solution, used_step = result
+
+ assert t_upper < t_star
+ assert t_lower > t_star
+ assert abs(solution['radius_upper'] - solution['radius_lower']) < 1e-8 * radius0
+ assert abs(solution['radius_upper'] - radius0) < 0.05 * radius0
+
+
+def test_continuation_step_returns_none_when_guess_carries_no_direction():
+ x, y = _load_dat(NACA2315)
+ spline_data = _build_spline_data(x, y)
+ result = cma._continuation_step(
+ spline_data, 0.5, 0.5, np.array((0.0, 0.0)), 0.5, 0.5, step=1e-9, min_step=1e-9,
+ )
+ assert result is None
+
+
+# --- Task 3: full march ------------------------------------------------
+
+def test_march_on_symmetric_naca0012_stays_on_the_chord_line():
+ x, y = _load_dat(NACA0012)
+ spline_data = _build_spline_data(x, y)
+ t_le, rc, xc, yc, xle, yle, le_id = _le_radius_inputs(spline_data)
+ t_star, center0, radius0 = cma.refine_nose_start(spline_data, t_le, xc, yc, rc)
+ states = cma._march(spline_data, t_star, center0, radius0)
+
+ assert len(states) > 20
+ centers = np.array([s['center'] for _, _, s in states])
+ assert np.max(np.abs(centers[:, 1])) < 1e-4
+ assert np.all(np.diff(centers[:, 0]) > 0) or np.all(np.diff(centers[:, 0]) < 0)
+
+
+def test_march_on_naca2315_reaches_both_surface_ends_smoothly():
+ x, y = _load_dat(NACA2315)
+ spline_data = _build_spline_data(x, y)
+ t_le, rc, xc, yc, xle, yle, le_id = _le_radius_inputs(spline_data)
+ t_star, center0, radius0 = cma.refine_nose_start(spline_data, t_le, xc, yc, rc)
+ states = cma._march(spline_data, t_star, center0, radius0)
+
+ assert len(states) > 20
+ assert states[-1][0] < 1e-3
+ assert states[-1][1] > 1.0 - 1e-3
+
+ centers = np.array([s['center'] for _, _, s in states])
+ jumps = np.linalg.norm(np.diff(centers, axis=0), axis=1)
+ # The raw march's adaptive step can grow large in low-curvature regions
+ # (by design -- it's resampled to a uniform grid afterward, checked
+ # separately in test_trace_camberline_has_no_large_jumps); this just
+ # guards against a genuine discontinuity, not the largest normal step.
+ assert np.max(jumps) < 0.035
+
+
+# --- Task 4: legacy blend, resampling, public trace() -----------------
+
+@pytest.mark.parametrize('dat_path', [NACA2315, MW166, NACA0012])
+def test_trace_tangency_matches_true_nearest_surface_point(dat_path):
+ x, y = _load_dat(dat_path)
+ spline_data = _build_spline_data(x, y)
+ t_le, rc, xc, yc, xle, yle, le_id = _le_radius_inputs(spline_data)
+ legacy = _build_legacy_camber_data(spline_data)
+
+ result = cma.trace(
+ spline_data, t_le, legacy, rc, xc, yc, xle, yle,
+ point_count=240, display_count=17,
+ )
+
+ assert result.point_count == 240
+ assert np.all(result.valid)
+
+ t_full = np.linspace(0.0, 1.0, 40000)
+ full_points = np.column_stack(spline_data.evaluate(t_full, der=0))
+ centers = np.column_stack(result.coordinates)
+ radii = np.asarray(result.radius)
+
+ worst_gap = 0.0
+ for center, radius in zip(centers, radii):
+ distance = np.min(np.linalg.norm(full_points - center, axis=1))
+ worst_gap = max(worst_gap, abs(distance - radius))
+ assert worst_gap < 5.0e-4
+
+
+def test_trace_camberline_has_no_large_jumps():
+ x, y = _load_dat(NACA2315)
+ spline_data = _build_spline_data(x, y)
+ t_le, rc, xc, yc, xle, yle, le_id = _le_radius_inputs(spline_data)
+ legacy = _build_legacy_camber_data(spline_data)
+ result = cma.trace(spline_data, t_le, legacy, rc, xc, yc, xle, yle, 240, 17)
+
+ centers = np.column_stack(result.coordinates)
+ steps = np.linalg.norm(np.diff(centers, axis=0), axis=1)
+ assert np.max(steps) < 0.02
+ radii = np.asarray(result.radius)
+ radius_steps = np.abs(np.diff(radii))
+ assert np.max(radius_steps) < 0.01
+
+
+def test_trace_leaves_get_le_radius_marker_point_untouched():
+ x, y = _load_dat(NACA2315)
+ spline_data = _build_spline_data(x, y)
+ t_le, rc, xc, yc, xle, yle, le_id = _le_radius_inputs(spline_data)
+ legacy = _build_legacy_camber_data(spline_data)
+
+ before = (rc, xc, yc, xle, yle)
+ result = cma.trace(spline_data, t_le, legacy, rc, xc, yc, xle, yle, 240, 17)
+ after = (rc, xc, yc, xle, yle)
+ assert before == after
+
+ first_center = np.array((result.coordinates[0][0], result.coordinates[1][0]))
+ assert np.linalg.norm(first_center - np.array((xc, yc))) < 5e-4
+
+
+def test_trace_degrades_gracefully_on_pathological_input():
+ theta = np.linspace(0.0, 2 * np.pi, 60)
+ x = 0.5 + 0.5 * np.cos(theta)
+ y = 0.01 * np.sin(theta)
+ spline_data = _build_spline_data(x, y, points=60)
+ t_le, rc, xc, yc, xle, yle, le_id = _le_radius_inputs(spline_data)
+ legacy = _build_legacy_camber_data(spline_data, point_count=60)
+
+ result = cma.trace(spline_data, t_le, legacy, rc, xc, yc, xle, yle, 60, 12)
+ assert result.point_count == 60
+ assert np.all(result.valid)
+ assert np.all(np.isfinite(result.radius))
+
+
+# --- Task 5: integration through CamberBuilder --------------------------
+
+def test_camber_builder_inscribed_method_uses_medial_axis_tracer():
+ sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'src'))
+ import Camber
+
+ x, y = _load_dat(NACA2315)
+ spline_data = _build_spline_data(x, y)
+ t_le, rc, xc, yc, xle, yle, le_id = _le_radius_inputs(spline_data)
+
+ builder = Camber.CamberBuilder()
+ result = builder.build(spline_data, rc, xc, yc, xle, yle)
+ assert result.method == Camber.CAMBER_METHOD_INSCRIBED_CIRCLES
+ assert result.point_count == builder.DEFAULT_CALCULATION_POINTS
+ assert np.all(result.valid)
+
+ t_full = np.linspace(0.0, 1.0, 40000)
+ full_points = np.column_stack(spline_data.evaluate(t_full, der=0))
+ centers = np.column_stack(result.coordinates)
+ radii = np.asarray(result.radius)
+ worst_gap = max(
+ abs(np.min(np.linalg.norm(full_points - center, axis=1)) - radius)
+ for center, radius in zip(centers, radii)
+ )
+ assert worst_gap < 5.0e-4
diff --git a/tests/test_experimental_c_grid.py b/tests/test_experimental_c_grid.py
new file mode 100644
index 0000000..ebe84ea
--- /dev/null
+++ b/tests/test_experimental_c_grid.py
@@ -0,0 +1,128 @@
+import sys
+import unittest
+from pathlib import Path
+
+import numpy as np
+
+
+PROJECT_ROOT = Path(__file__).resolve().parents[1]
+SRC_ROOT = PROJECT_ROOT / 'src'
+if str(SRC_ROOT) not in sys.path:
+ sys.path.insert(0, str(SRC_ROOT))
+
+import Connect
+from ExperimentalCGrid import (
+ ExperimentalCGridGenerator,
+ ExperimentalCGridSettings,
+)
+import Mesh
+import MeshBuilders
+
+
+class ExperimentalCGridTests(unittest.TestCase):
+ def setUp(self):
+ self.generator = ExperimentalCGridGenerator()
+ self.settings = ExperimentalCGridSettings(
+ normal_divisions=24,
+ wake_points=18,
+ initial_smoothing_iterations=8,
+ final_smoothing_iterations=3,
+ )
+ self.sharp_contour = (
+ np.array([1.0, 0.75, 0.35, 0.0, 0.35, 0.75, 1.0], dtype=float),
+ np.array([0.0, 0.05, 0.08, 0.0, -0.08, -0.05, 0.0], dtype=float),
+ )
+ self.blunt_contour = (
+ np.array([1.0, 0.7, 0.2, 0.0, 0.2, 0.7, 1.0], dtype=float),
+ np.array([0.03, 0.08, 0.10, 0.0, -0.10, -0.08, -0.03], dtype=float),
+ )
+
+ @staticmethod
+ def _minimum_cell_area(vertices, connectivity):
+ points = np.asarray(vertices, dtype=float)
+ cells = np.asarray(connectivity, dtype=int)
+ areas = []
+ for cell in cells:
+ polygon = points[cell]
+ x_values = polygon[:, 0]
+ y_values = polygon[:, 1]
+ areas.append(
+ 0.5 * abs(
+ np.dot(x_values, np.roll(y_values, -1)) -
+ np.dot(y_values, np.roll(x_values, -1))
+ )
+ )
+ return min(areas) if areas else 0.0
+
+ def test_build_block_creates_structured_experimental_c_grid(self):
+ block = self.generator.build_block(
+ self.sharp_contour,
+ radius=3.5,
+ wake_length=7.0,
+ settings=self.settings,
+ )
+
+ u_divisions, v_divisions = block.getDivUV()
+ self.assertGreater(u_divisions, 0)
+ self.assertEqual(v_divisions, self.settings.normal_divisions)
+
+ inner_boundary = np.asarray(block.getULines()[0], dtype=float)
+ self.assertTrue(np.allclose(inner_boundary[0], inner_boundary[-1]))
+
+ connector = Connect.Connect()
+ vertices, connectivity = connector.connectAllBlocks([block])
+ self.assertGreater(len(vertices), 0)
+ self.assertGreater(len(connectivity), 0)
+ self.assertGreater(self._minimum_cell_area(vertices, connectivity), 1.0e-10)
+
+ topology = Mesh.MeshTopology.from_mesh(vertices, connectivity)
+ self.assertGreater(len(topology.boundary_tags['airfoil']), 0)
+ self.assertGreater(len(topology.boundary_tags['inlet']), 0)
+ self.assertGreater(len(topology.boundary_tags['outlet']), 0)
+
+ def test_build_block_rejects_blunt_trailing_edge(self):
+ with self.assertRaisesRegex(ValueError, 'sharp trailing edge'):
+ self.generator.build_block(
+ self.blunt_contour,
+ radius=3.5,
+ wake_length=7.0,
+ settings=self.settings,
+ )
+
+ def test_local_te_smoothing_iterations_are_not_capped(self):
+ settings = ExperimentalCGridSettings(local_te_smoothing_iterations=25)
+ self.assertEqual(settings.local_te_smoothing_iterations, 25)
+
+ def test_build_blunt_blocks_creates_connected_mesh(self):
+ trailing_edge_settings = MeshBuilders.TrailingEdgeBlockSettings(
+ name='block_experimental_te',
+ trailing_edge_divisions=3,
+ thickness=0.03,
+ divisions=8,
+ growth=1.05,
+ )
+
+ blocks = self.generator.build_blunt_blocks(
+ self.blunt_contour,
+ radius=3.5,
+ wake_length=7.0,
+ settings=self.settings,
+ trailing_edge_settings=trailing_edge_settings,
+ )
+
+ self.assertEqual(len(blocks), 3)
+
+ connector = Connect.Connect()
+ vertices, connectivity = connector.connectAllBlocks(blocks)
+ self.assertGreater(len(vertices), 0)
+ self.assertGreater(len(connectivity), 0)
+ self.assertGreater(self._minimum_cell_area(vertices, connectivity), 1.0e-10)
+
+ topology = Mesh.MeshTopology.from_mesh(vertices, connectivity)
+ self.assertGreater(len(topology.boundary_tags['airfoil']), 0)
+ self.assertGreater(len(topology.boundary_tags['inlet']), 0)
+ self.assertGreater(len(topology.boundary_tags['outlet']), 0)
+
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/tests/test_experimental_o_grid.py b/tests/test_experimental_o_grid.py
new file mode 100644
index 0000000..f761b11
--- /dev/null
+++ b/tests/test_experimental_o_grid.py
@@ -0,0 +1,188 @@
+import sys
+import unittest
+from pathlib import Path
+
+import numpy as np
+
+
+PROJECT_ROOT = Path(__file__).resolve().parents[1]
+SRC_ROOT = PROJECT_ROOT / 'src'
+if str(SRC_ROOT) not in sys.path:
+ sys.path.insert(0, str(SRC_ROOT))
+
+import Connect
+from ExperimentalOGrid import (
+ ExperimentalOGridGenerator,
+ ExperimentalOGridSettings,
+)
+import Mesh
+import MeshBuilders
+
+
+class ExperimentalOGridTests(unittest.TestCase):
+ def setUp(self):
+ self.generator = ExperimentalOGridGenerator()
+ self.sharp_contour = (
+ np.array([1.0, 0.75, 0.35, 0.0, 0.35, 0.75, 1.0], dtype=float),
+ np.array([0.0, 0.05, 0.08, 0.0, -0.08, -0.05, 0.0], dtype=float),
+ )
+ self.blunt_contour = (
+ np.array([1.0, 0.7, 0.2, 0.0, 0.2, 0.7, 1.0], dtype=float),
+ np.array([0.03, 0.08, 0.10, 0.0, -0.10, -0.08, -0.03], dtype=float),
+ )
+ self.trailing_edge_settings = MeshBuilders.TrailingEdgeBlockSettings(
+ name='block_te',
+ trailing_edge_divisions=3,
+ thickness=0.03,
+ divisions=8,
+ growth=1.05,
+ )
+
+ @staticmethod
+ def _minimum_cell_area(vertices, connectivity):
+ points = np.asarray(vertices, dtype=float)
+ cells = np.asarray(connectivity, dtype=int)
+ areas = []
+ for cell in cells:
+ polygon = points[cell]
+ x_values = polygon[:, 0]
+ y_values = polygon[:, 1]
+ areas.append(
+ 0.5 * abs(
+ np.dot(x_values, np.roll(y_values, -1)) -
+ np.dot(y_values, np.roll(x_values, -1))
+ )
+ )
+ return min(areas) if areas else 0.0
+
+ def _build_topology(self, contour, *, farfield_shape='wind_tunnel',
+ radius=3.5, wake_length=7.0):
+ settings = ExperimentalOGridSettings(
+ normal_divisions=20,
+ farfield_shape=farfield_shape,
+ initial_smoothing_iterations=6,
+ final_smoothing_iterations=3,
+ )
+ blocks = self.generator.build_blocks(
+ contour,
+ radius=radius,
+ wake_length=wake_length,
+ settings=settings,
+ trailing_edge_settings=self.trailing_edge_settings,
+ )
+ vertices, connectivity = Connect.Connect().connectAllBlocks(blocks)
+ topology = Mesh.MeshTopology.from_mesh(vertices, connectivity)
+ airfoil_edges = set(topology.boundary_tags['airfoil'])
+ outer_edges = [
+ edge for edge in topology.boundary_edges if edge not in airfoil_edges
+ ]
+ return (
+ blocks,
+ np.asarray(vertices, dtype=float),
+ connectivity,
+ topology,
+ outer_edges,
+ )
+
+ def _assert_connected_mesh(self, blocks, vertices, connectivity, topology,
+ outer_edges):
+ self.assertEqual(len(blocks), 4)
+ self.assertGreater(len(vertices), 0)
+ self.assertGreater(len(connectivity), 0)
+ self.assertGreater(
+ self._minimum_cell_area(vertices, connectivity),
+ 1.0e-10,
+ )
+ self.assertGreater(len(topology.boundary_tags['airfoil']), 0)
+
+ classified_outer = sum(
+ len(topology.boundary_tags[tag])
+ for tag in ('inlet', 'outlet', 'top', 'bottom')
+ )
+ self.assertEqual(classified_outer, len(outer_edges))
+
+ @staticmethod
+ def _outer_boundary_points(vertices, outer_edges):
+ vertex_ids = sorted({vertex_id for edge in outer_edges for vertex_id in edge})
+ return vertices[vertex_ids]
+
+ def test_build_blocks_creates_connected_sharp_wind_tunnel_o_grid(self):
+ blocks, vertices, connectivity, topology, outer_edges = (
+ self._build_topology(self.sharp_contour)
+ )
+
+ self._assert_connected_mesh(
+ blocks,
+ vertices,
+ connectivity,
+ topology,
+ outer_edges,
+ )
+ self.assertGreater(len(topology.boundary_tags['inlet']), 0)
+ self.assertGreater(len(topology.boundary_tags['outlet']), 0)
+ self.assertGreater(len(topology.boundary_tags['top']), 0)
+ self.assertGreater(len(topology.boundary_tags['bottom']), 0)
+
+ def test_build_blocks_creates_connected_blunt_wind_tunnel_o_grid(self):
+ blocks, vertices, connectivity, topology, outer_edges = (
+ self._build_topology(self.blunt_contour)
+ )
+
+ self._assert_connected_mesh(
+ blocks,
+ vertices,
+ connectivity,
+ topology,
+ outer_edges,
+ )
+ self.assertGreater(len(topology.boundary_tags['inlet']), 0)
+ self.assertGreater(len(topology.boundary_tags['outlet']), 0)
+ self.assertGreater(len(topology.boundary_tags['top']), 0)
+ self.assertGreater(len(topology.boundary_tags['bottom']), 0)
+
+ def test_build_blocks_creates_connected_circle_o_grid(self):
+ blocks, vertices, connectivity, topology, outer_edges = (
+ self._build_topology(
+ self.sharp_contour,
+ farfield_shape='circle',
+ )
+ )
+
+ self._assert_connected_mesh(
+ blocks,
+ vertices,
+ connectivity,
+ topology,
+ outer_edges,
+ )
+ self.assertGreater(len(outer_edges), 0)
+
+ def test_wind_tunnel_outer_boundary_matches_standard_extent(self):
+ _, vertices, _, _, outer_edges = self._build_topology(
+ self.sharp_contour,
+ farfield_shape='wind_tunnel',
+ radius=3.5,
+ wake_length=7.0,
+ )
+
+ outer_points = self._outer_boundary_points(vertices, outer_edges)
+ self.assertAlmostEqual(np.max(outer_points[:, 0]), 8.0, places=6)
+ self.assertAlmostEqual(np.max(outer_points[:, 1]), 3.5, places=6)
+ self.assertAlmostEqual(np.min(outer_points[:, 1]), -3.5, places=6)
+ self.assertLess(np.min(outer_points[:, 0]), -3.0)
+
+ def test_circle_outer_boundary_uses_requested_radius(self):
+ _, vertices, _, _, outer_edges = self._build_topology(
+ self.sharp_contour,
+ farfield_shape='circle',
+ radius=3.5,
+ wake_length=7.0,
+ )
+
+ outer_points = self._outer_boundary_points(vertices, outer_edges)
+ radii = np.sqrt((outer_points[:, 0] - 0.5) ** 2 + outer_points[:, 1] ** 2)
+ self.assertTrue(np.allclose(radii, 3.5, atol=1.0e-6))
+
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/tests/test_grid_elliptic.py b/tests/test_grid_elliptic.py
new file mode 100644
index 0000000..301071b
--- /dev/null
+++ b/tests/test_grid_elliptic.py
@@ -0,0 +1,118 @@
+import sys
+from pathlib import Path
+
+import numpy as np
+
+PROJECT_ROOT = Path(__file__).resolve().parents[1]
+SRC_ROOT = PROJECT_ROOT / 'src'
+if str(SRC_ROOT) not in sys.path:
+ sys.path.insert(0, str(SRC_ROOT))
+
+import GridElliptic
+import GridTFI
+import StructuredCore as core
+import StructuredTopologies as topo
+from test_ortho_layers import _build_spline_data
+from test_structured_topologies import (
+ NACA0012, _blunt_contour, _load_dat, _sharp_contour,
+)
+
+MW166 = Path(__file__).resolve().parent / 'data' / 'MW-166-39-44-43.dat'
+
+
+def _clustered_rect_rows(nx=15, ny=10):
+ x = np.linspace(0.0, 2.0, nx)
+ d = core.geometric_distances(1.0, 0.02, ny)
+ xs, ys = np.meshgrid(x, d)
+ return np.stack((xs, ys), axis=-1)
+
+
+def _tfi_rows(contour, topology, tunnel_shape='legacy', tunnel_height=3.5):
+ settings = core.StructuredMeshSettings(
+ topology=topology, tunnel_shape=tunnel_shape,
+ tunnel_height=tunnel_height,
+ normal_divisions=30, wake_points=30)
+ frame = topo.build_frames(np.asarray(contour, float), settings)[0]
+ return frame, GridTFI.fill(frame)
+
+
+def _single_signed(rows):
+ jacobians = core.cell_jacobians(rows)
+ return bool(np.all(jacobians > 0) or np.all(jacobians < 0))
+
+
+def test_clustered_rectangle_is_fixed_point():
+ rows = _clustered_rect_rows()
+ out, _info = GridElliptic.solve(rows, iterations=50)
+ assert np.max(np.abs(out - rows)) < 1e-6
+
+
+def test_pure_winslow_would_move_it():
+ rows = _clustered_rect_rows()
+ out, _info = GridElliptic.solve(rows, iterations=50, control='none')
+ assert np.max(np.abs(out - rows)) > 1e-3
+
+
+def test_boundaries_dirichlet_and_wall_bitidentical():
+ frame, rows = _tfi_rows(_sharp_contour(), 'c')
+ out, _info = GridElliptic.solve(rows, iterations=30)
+ assert np.array_equal(out[0], rows[0])
+ assert np.array_equal(out[-1], rows[-1])
+ assert np.array_equal(out[:, 0], rows[:, 0])
+ assert np.array_equal(out[:, -1], rows[:, -1])
+ assert np.max(np.abs(out[1:-1, 1:-1] - rows[1:-1, 1:-1])) > 0.0
+
+
+def test_o_grid_periodic_seam_relaxes():
+ frame, rows = _tfi_rows(_sharp_contour(), 'o')
+ out, _info = GridElliptic.solve(rows, periodic=True, iterations=50)
+ assert np.array_equal(out[:, 0], out[:, -1]) # seam stays welded
+ assert np.array_equal(out[0], rows[0]) # wall fixed
+ assert np.array_equal(out[-1], rows[-1]) # outer fixed
+ seam_delta = np.max(np.abs(out[1:-1, 0] - rows[1:-1, 0]))
+ assert seam_delta > 1e-6 # seam actually moved
+
+
+def test_no_inverted_cells_after_elliptic_all_airfoils():
+ mw166 = _load_dat(MW166)
+ cases = (
+ ('naca0012-sharp', _sharp_contour()),
+ ('naca0012-blunt', _blunt_contour()),
+ ('mw166', mw166),
+ )
+ for name, contour in cases:
+ for topology in ('c', 'o'):
+ frame, rows = _tfi_rows(contour, topology)
+ out, _info = GridElliptic.solve(
+ rows, periodic=frame.periodic, iterations=60)
+ assert _single_signed(out), f'inverted cells: {name}/{topology}'
+
+
+def test_outer_orthogonality_improves_angle():
+ frame, rows = _tfi_rows(_sharp_contour(), 'o', tunnel_shape='circular',
+ tunnel_height=10.0)
+
+ def median_angle_deviation(grid):
+ edge = grid[-1] - grid[-2]
+ tangent = np.gradient(frame.outer, axis=0)
+ cos = np.abs(np.sum(edge * tangent, axis=1)) / (
+ np.linalg.norm(edge, axis=1) *
+ np.linalg.norm(tangent, axis=1))
+ angles = np.degrees(np.arccos(np.clip(cos, 0.0, 1.0)))
+ return float(np.median(np.abs(angles - 90.0)))
+
+ baseline = median_angle_deviation(rows)
+ out, _info = GridElliptic.solve(rows, periodic=True, iterations=100,
+ outer_orthogonal=True)
+ improved = median_angle_deviation(out)
+ assert improved < baseline
+ assert improved < 10.0
+ assert _single_signed(out)
+
+
+def test_info_reports_convergence():
+ rows = _clustered_rect_rows()
+ _out, info = GridElliptic.solve(rows, iterations=40)
+ assert set(info) >= {'iterations', 'residual', 'converged'}
+ assert info['iterations'] <= 40
+ assert info['residual'] >= 0.0
diff --git a/tests/test_grid_hyperbolic.py b/tests/test_grid_hyperbolic.py
new file mode 100644
index 0000000..f394104
--- /dev/null
+++ b/tests/test_grid_hyperbolic.py
@@ -0,0 +1,91 @@
+import sys
+from pathlib import Path
+
+import numpy as np
+
+PROJECT_ROOT = Path(__file__).resolve().parents[1]
+SRC_ROOT = PROJECT_ROOT / 'src'
+if str(SRC_ROOT) not in sys.path:
+ sys.path.insert(0, str(SRC_ROOT))
+
+import GridHyperbolic
+import OrthoLayers
+import StructuredCore as core
+import StructuredTopologies as topo
+from test_structured_topologies import (
+ _blunt_contour, _load_dat, _sharp_contour,
+)
+
+MW166 = Path(__file__).resolve().parent / 'data' / 'MW-166-39-44-43.dat'
+FIRST_SPACING = 0.002
+
+
+def _main_frame(contour, topology):
+ settings = core.StructuredMeshSettings(
+ topology=topology, normal_divisions=40, wake_points=40,
+ first_layer_thickness=FIRST_SPACING)
+ return topo.build_frames(np.asarray(contour, float), settings)[0]
+
+
+def _frame_normals(frame):
+ if frame.kind == 'o':
+ corners = [0, len(frame.wall) - 1]
+ else:
+ start, stop = frame.metadata['contour_slice']
+ corners = [start, stop - 1]
+ return OrthoLayers.wall_normals(frame.wall, corners,
+ closed=frame.periodic)
+
+
+def _march(frame):
+ return GridHyperbolic.march(frame, normals=_frame_normals(frame),
+ first_spacing=FIRST_SPACING)
+
+
+def _single_signed(rows):
+ jacobians = core.cell_jacobians(rows)
+ return bool(np.all(jacobians > 0) or np.all(jacobians < 0))
+
+
+def test_wall_and_outer_verbatim():
+ frame = _main_frame(_sharp_contour(), 'c')
+ rows = _march(frame)
+ assert rows.shape == (len(frame.side_start), len(frame.wall), 2)
+ assert np.array_equal(rows[0], frame.wall)
+ assert np.array_equal(rows[-1], frame.outer)
+
+
+def test_no_inverted_cells_matrix():
+ cases = (
+ ('naca0012-sharp', _sharp_contour()),
+ ('naca0012-blunt', _blunt_contour()),
+ ('mw166', _load_dat(MW166)),
+ )
+ for name, contour in cases:
+ for topology in ('c', 'o'):
+ frame = _main_frame(contour, topology)
+ rows = _march(frame)
+ assert _single_signed(rows), f'inverted: {name}/{topology}'
+
+
+def test_o_seam_welded():
+ frame = _main_frame(_sharp_contour(), 'o')
+ rows = _march(frame)
+ assert np.array_equal(rows[:, 0], rows[:, -1])
+
+
+def test_c_front_ends_stay_on_outlet_plane():
+ frame = _main_frame(_sharp_contour(), 'c')
+ x_outlet = frame.metadata['x_outlet']
+ rows = _march(frame)
+ assert np.allclose(rows[:, 0, 0], x_outlet, atol=1e-9)
+ assert np.allclose(rows[:, -1, 0], x_outlet, atol=1e-9)
+
+
+def test_first_layer_spacing_respected():
+ frame = _main_frame(_sharp_contour(), 'o')
+ rows = _march(frame)
+ start, stop = frame.metadata['contour_slice']
+ spacing = np.linalg.norm(rows[1] - rows[0], axis=1)[start + 5:stop - 5]
+ assert np.all(spacing > 0.2 * FIRST_SPACING)
+ assert np.median(spacing) < 5.0 * FIRST_SPACING
diff --git a/tests/test_grid_smoothers.py b/tests/test_grid_smoothers.py
new file mode 100644
index 0000000..fe788d9
--- /dev/null
+++ b/tests/test_grid_smoothers.py
@@ -0,0 +1,133 @@
+import sys
+from pathlib import Path
+from unittest import mock
+
+import numpy as np
+
+PROJECT_ROOT = Path(__file__).resolve().parents[1]
+SRC_ROOT = PROJECT_ROOT / 'src'
+if str(SRC_ROOT) not in sys.path:
+ sys.path.insert(0, str(SRC_ROOT))
+
+import GridElliptic
+import GridSmoothers
+import GridTFI
+import StructuredCore as core
+import StructuredTopologies as topo
+from test_structured_topologies import _blunt_contour, _load_dat, _sharp_contour
+
+MW166 = Path(__file__).resolve().parent / 'data' / 'MW-166-39-44-43.dat'
+METHODS = ('laplacian', 'elliptic', 'angle_based')
+
+
+def _tfi_rows(contour, topology):
+ settings = core.StructuredMeshSettings(
+ topology=topology, normal_divisions=40, wake_points=40)
+ frame = topo.build_frames(np.asarray(contour, float), settings)[0]
+ return frame, GridTFI.fill(frame)
+
+
+def test_wall_and_outer_bitidentical_all_methods():
+ frame, rows = _tfi_rows(_sharp_contour(), 'c')
+ for method in METHODS:
+ out, _info = GridSmoothers.smooth(rows, method=method, iterations=5,
+ periodic=frame.periodic,
+ frozen_rows=1)
+ assert np.array_equal(out[0], rows[0]), method
+ assert np.array_equal(out[-1], rows[-1]), method
+
+
+def test_frozen_rows_beyond_wall_bitidentical():
+ frame, rows = _tfi_rows(_sharp_contour(), 'c')
+ for method in METHODS:
+ out, _info = GridSmoothers.smooth(rows, method=method, iterations=5,
+ periodic=frame.periodic,
+ frozen_rows=4)
+ assert np.array_equal(out[:4], rows[:4]), method
+ assert np.array_equal(out[-1], rows[-1]), method
+
+
+def test_side_columns_fixed_when_not_periodic():
+ frame, rows = _tfi_rows(_sharp_contour(), 'c')
+ for method in METHODS:
+ out, _info = GridSmoothers.smooth(rows, method=method, iterations=5,
+ periodic=False, frozen_rows=1)
+ assert np.array_equal(out[:, 0], rows[:, 0]), method
+ assert np.array_equal(out[:, -1], rows[:, -1]), method
+
+
+def test_periodic_seam_stays_welded():
+ frame, rows = _tfi_rows(_sharp_contour(), 'o')
+ for method in METHODS:
+ out, _info = GridSmoothers.smooth(rows, method=method, iterations=5,
+ periodic=True, frozen_rows=1)
+ assert np.array_equal(out[:, 0], out[:, -1]), method
+
+
+def test_laplacian_moves_interior():
+ frame, rows = _tfi_rows(_sharp_contour(), 'c')
+ out, info = GridSmoothers.smooth(rows, method='laplacian',
+ iterations=10, periodic=False,
+ frozen_rows=1)
+ assert not np.array_equal(out, rows)
+ assert info['quality_after'] >= info['quality_before']
+
+
+def test_elliptic_delegates_to_grid_elliptic_on_sliced_rows():
+ frame, rows = _tfi_rows(_sharp_contour(), 'c')
+ solved = rows.copy()
+ with mock.patch.object(GridElliptic, 'solve',
+ wraps=GridElliptic.solve) as spy:
+ GridSmoothers.smooth(rows, method='elliptic', iterations=5,
+ periodic=False, frozen_rows=3)
+ spy.assert_called_once()
+ passed_rows = spy.call_args[0][0]
+ assert passed_rows.shape[0] == rows.shape[0] - 2 # frozen_rows-1 onward
+ assert np.array_equal(passed_rows[0], rows[2])
+
+
+def test_quality_never_degrades_shipped_airfoils():
+ cases = (_sharp_contour(), _blunt_contour(), _load_dat(MW166))
+ for contour in cases:
+ for topology in ('c', 'o'):
+ frame, rows = _tfi_rows(contour, topology)
+ for method in METHODS:
+ out, info = GridSmoothers.smooth(
+ rows, method=method, iterations=10,
+ periodic=frame.periodic, frozen_rows=1)
+ assert info['quality_after'] >= info['quality_before']
+ if info['quality_after'] == info['quality_before']:
+ # guard may have reverted; either way it's a no-op
+ # in the degrading direction
+ pass
+
+
+def test_guard_reverts_when_smoothing_would_degrade():
+ # Empirically: a C-mesh TFI grid's near-wall quality is already so
+ # tight that a generic elliptic relaxation pass makes it worse there.
+ frame, rows = _tfi_rows(_sharp_contour(), 'c')
+ out, info = GridSmoothers.smooth(rows, method='elliptic', iterations=10,
+ periodic=False, frozen_rows=1)
+ assert np.array_equal(out, rows)
+ assert info['quality_after'] == info['quality_before']
+
+
+def test_unknown_method_raises():
+ frame, rows = _tfi_rows(_sharp_contour(), 'c')
+ try:
+ GridSmoothers.smooth(rows, method='spectral', periodic=False)
+ except ValueError:
+ pass
+ else:
+ raise AssertionError('Expected ValueError for unknown method.')
+
+
+def test_frozen_rows_must_include_wall():
+ frame, rows = _tfi_rows(_sharp_contour(), 'c')
+ try:
+ GridSmoothers.smooth(rows, method='laplacian', frozen_rows=0,
+ periodic=False)
+ except ValueError:
+ pass
+ else:
+ raise AssertionError('Expected ValueError for frozen_rows < 1.')
diff --git a/tests/test_grid_tfi.py b/tests/test_grid_tfi.py
new file mode 100644
index 0000000..7707f5b
--- /dev/null
+++ b/tests/test_grid_tfi.py
@@ -0,0 +1,77 @@
+import sys
+from pathlib import Path
+
+import numpy as np
+
+PROJECT_ROOT = Path(__file__).resolve().parents[1]
+SRC_ROOT = PROJECT_ROOT / 'src'
+if str(SRC_ROOT) not in sys.path:
+ sys.path.insert(0, str(SRC_ROOT))
+
+import GridTFI
+import StructuredCore as core
+import StructuredTopologies as topo
+from test_structured_topologies import _blunt_contour, _sharp_contour
+
+
+def _rect_frame(nx=11, ny=6, width=2.0, height=1.0):
+ x = np.linspace(0.0, width, nx)
+ y = np.linspace(0.0, height, ny)
+ return core.GridFrame(
+ wall=np.column_stack((x, np.zeros(nx))),
+ outer=np.column_stack((x, np.full(nx, height))),
+ side_start=np.column_stack((np.zeros(ny), y)),
+ side_end=np.column_stack((np.full(ny, width), y)),
+ kind='c', te_type='sharp',
+ )
+
+
+def test_standard_tfi_reproduces_rectangle():
+ frame = _rect_frame()
+ rows = GridTFI.fill(frame)
+ assert rows.shape == (6, 11, 2)
+ xs, ys = np.meshgrid(np.linspace(0, 2, 11), np.linspace(0, 1, 6))
+ assert np.allclose(rows[..., 0], xs, atol=1e-12)
+ assert np.allclose(rows[..., 1], ys, atol=1e-12)
+
+
+def test_boundaries_verbatim_on_real_frame():
+ settings = core.StructuredMeshSettings(topology='c')
+ frame = topo.build_frames(_sharp_contour(), settings)[0]
+ rows = GridTFI.fill(frame)
+ assert np.array_equal(rows[0], frame.wall)
+ assert np.array_equal(rows[-1], frame.outer)
+ assert np.array_equal(rows[:, 0], frame.side_start)
+ assert np.array_equal(rows[:, -1], frame.side_end)
+
+
+def test_no_inverted_cells_o_and_c_both_te_types():
+ for topology in ('c', 'o'):
+ for shape in ('legacy', 'circular'):
+ for contour in (_sharp_contour(), _blunt_contour()):
+ settings = core.StructuredMeshSettings(
+ topology=topology, tunnel_shape=shape,
+ tunnel_height=3.5 if shape == 'legacy' else 10.0)
+ for frame in topo.build_frames(contour, settings):
+ rows = GridTFI.fill(frame)
+ jacobians = core.cell_jacobians(rows)
+ assert np.all(np.abs(jacobians) > 1e-14), \
+ f'degenerate cell: {topology}/{shape}'
+ assert np.all(jacobians > 0) or np.all(jacobians < 0), \
+ f'inverted cells: {topology}/{shape}'
+
+
+def test_hermite_orthogonal_angle_at_outer_boundary():
+ control = core.TunnelBoundaryControl(angle_mode='orthogonal')
+ settings = core.StructuredMeshSettings(
+ topology='o', tunnel_shape='circular',
+ boundary_control=control, tfi_variant='hermite')
+ frame = topo.build_frames(_sharp_contour(), settings)[0]
+ rows = GridTFI.fill(frame, variant='hermite', boundary_control=control)
+ # angle between last mesh edge and outer-boundary tangent, all nodes
+ edge = rows[-1] - rows[-2]
+ tangent = np.gradient(frame.outer, axis=0)
+ cos = np.abs(np.sum(edge * tangent, axis=1)) / (
+ np.linalg.norm(edge, axis=1) * np.linalg.norm(tangent, axis=1))
+ angles = np.degrees(np.arccos(np.clip(cos, 0.0, 1.0)))
+ assert np.median(np.abs(angles - 90.0)) < 10.0
diff --git a/tests/test_mesh_export.py b/tests/test_mesh_export.py
new file mode 100644
index 0000000..391c3a7
--- /dev/null
+++ b/tests/test_mesh_export.py
@@ -0,0 +1,107 @@
+import os
+import sys
+import tempfile
+import unittest
+from pathlib import Path
+
+
+PROJECT_ROOT = Path(__file__).resolve().parents[1]
+SRC_ROOT = PROJECT_ROOT / 'src'
+if str(SRC_ROOT) not in sys.path:
+ sys.path.insert(0, str(SRC_ROOT))
+
+import Mesh
+
+
+class MeshExportCleanupTests(unittest.TestCase):
+ def setUp(self):
+ mesh_data = Mesh.MeshData(
+ vertices=[
+ (0.0, 0.0),
+ (1.0, 0.0),
+ (1.0, 1.0),
+ (0.0, 1.0),
+ ],
+ connectivity=[(0, 1, 2, 3)],
+ boundary_tags={
+ 'airfoil': [(0, 1)],
+ 'outlet': [(1, 2)],
+ 'top': [(2, 3)],
+ 'inlet': [(3, 0)],
+ 'bottom': [],
+ },
+ )
+ self.mesh_model = Mesh.BlockStructuredMesh(
+ name='demo',
+ blocks=[],
+ data=mesh_data,
+ boundary_conditions={
+ 'airfoil': 'wall',
+ 'inlet': 'inlet',
+ 'outlet': 'outlet',
+ 'top': 'top',
+ 'bottom': 'bottom',
+ },
+ )
+
+ def test_export_format_normalization_uses_canonical_names(self):
+ self.assertEqual(
+ Mesh.MeshExportRegistry.normalize_format('VTK'),
+ 'vtu',
+ )
+ self.assertEqual(
+ Mesh.MeshExportRegistry.normalize_format('.msh'),
+ 'gmsh',
+ )
+ self.assertEqual(
+ Mesh.MeshExportRegistry.normalize_format('AVL FIRE'),
+ 'flma',
+ )
+
+ def test_extension_lookup_uses_canonical_export_extension(self):
+ self.assertEqual(
+ Mesh.MeshExportRegistry.extension_for('vtk'),
+ '.vtu',
+ )
+ self.assertEqual(
+ Mesh.MeshExportRegistry.extension_for('gmsh'),
+ '.msh',
+ )
+
+ def test_boundary_definitions_reject_empty_names(self):
+ with self.assertRaisesRegex(ValueError, 'cannot be empty'):
+ Mesh.BoundaryDefinitions.from_mapping({'airfoil': ' '})
+
+ def test_boundary_definitions_reject_duplicate_names(self):
+ with self.assertRaisesRegex(ValueError, 'must be unique'):
+ Mesh.BoundaryDefinitions.from_mapping(
+ {'airfoil': 'wall', 'inlet': 'wall'}
+ )
+
+ def test_export_registry_writes_all_supported_mesh_formats(self):
+ expected_headers = {
+ 'flma': '8',
+ 'su2': '%',
+ 'gmsh': '$MeshFormat',
+ 'vtu': '',
+ }
+
+ with tempfile.TemporaryDirectory() as temp_dir:
+ for mesh_format, expected_header in expected_headers.items():
+ output_name = os.path.join(
+ temp_dir,
+ 'demo' + Mesh.MeshExportRegistry.extension_for(mesh_format),
+ )
+ Mesh.MeshExportRegistry.export(
+ self.mesh_model,
+ mesh_format,
+ output_name,
+ )
+
+ self.assertTrue(os.path.exists(output_name))
+ with open(output_name, 'r', encoding='utf-8') as handle:
+ self.assertEqual(handle.readline().rstrip(), expected_header)
+
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/tests/test_metric_triangulation.py b/tests/test_metric_triangulation.py
new file mode 100644
index 0000000..ed85d94
--- /dev/null
+++ b/tests/test_metric_triangulation.py
@@ -0,0 +1,138 @@
+import sys
+import unittest
+from pathlib import Path
+
+import numpy as np
+
+
+PROJECT_ROOT = Path(__file__).resolve().parents[1]
+SRC_ROOT = PROJECT_ROOT / 'src'
+if str(SRC_ROOT) not in sys.path:
+ sys.path.insert(0, str(SRC_ROOT))
+
+import MetricTriangulation
+import Settings
+
+
+class MetricTriangulationTests(unittest.TestCase):
+ def _generate(self, example):
+ settings = MetricTriangulation.MetricTriangulationSettings(
+ example=example,
+ width=4.0,
+ height=2.0,
+ hole_radius=0.35,
+ hole_spacing=1.40,
+ outer_resolution=72,
+ hole_resolution=44,
+ interior_x=22,
+ interior_y=12,
+ )
+ return MetricTriangulation.MetricTriangulator.generate(settings)
+
+ def _triangle_centroids(self, result):
+ mesh_data = result.mesh_data
+ vertices = mesh_data.vertices
+ return np.asarray([
+ np.mean(vertices[cell], axis=0)
+ for cell in mesh_data.connectivity
+ ], dtype=float)
+
+ def test_rectangle_example_builds_triangular_mesh(self):
+ result = self._generate('rectangle')
+
+ self.assertEqual(len(result.loops), 1)
+ self.assertGreater(result.mesh_data.vertex_count, 0)
+ self.assertGreater(result.mesh_data.cell_count, 0)
+ self.assertIn('outer', result.mesh_data.boundary_tags)
+ self.assertFalse(result.warnings)
+ outer_points = {
+ (round(float(point[0]), 12), round(float(point[1]), 12))
+ for point in result.loops[0].points[:-1]
+ }
+ self.assertTrue({
+ (-2.0, -1.0),
+ (2.0, -1.0),
+ (2.0, 1.0),
+ (-2.0, 1.0),
+ }.issubset(outer_points))
+
+ outer = result.loops[0].points
+ for centroid in self._triangle_centroids(result):
+ self.assertTrue(
+ MetricTriangulation.MetricTriangulator._point_in_polygon(
+ (float(centroid[0]), float(centroid[1])),
+ outer,
+ )
+ )
+
+ def test_rectangle_circle_excludes_hole(self):
+ result = self._generate('rectangle_circle')
+
+ self.assertEqual(len(result.loops), 2)
+ self.assertIn('hole_1', result.mesh_data.boundary_tags)
+ self.assertGreater(len(result.mesh_data.boundary_tags['hole_1']), 0)
+ self.assertFalse(result.warnings)
+
+ hole = result.loops[1].points
+ for centroid in self._triangle_centroids(result):
+ self.assertFalse(
+ MetricTriangulation.MetricTriangulator._point_in_polygon(
+ (float(centroid[0]), float(centroid[1])),
+ hole,
+ )
+ )
+
+ def test_rectangle_two_circles_tracks_both_holes(self):
+ result = self._generate('rectangle_two_circles')
+
+ self.assertEqual(len(result.loops), 3)
+ self.assertIn('hole_1', result.mesh_data.boundary_tags)
+ self.assertIn('hole_2', result.mesh_data.boundary_tags)
+ self.assertGreater(len(result.mesh_data.boundary_tags['hole_1']), 0)
+ self.assertGreater(len(result.mesh_data.boundary_tags['hole_2']), 0)
+ self.assertFalse(result.warnings)
+
+ unique_edges = set(result.mesh_data.unique_edges())
+ for tag in ('outer', 'hole_1', 'hole_2'):
+ for edge in result.mesh_data.boundary_tags[tag]:
+ sorted_edge = tuple(sorted((int(edge[0]), int(edge[1]))))
+ self.assertIn(sorted_edge, unique_edges)
+
+ def test_default_airfoil_tunnel_uses_configured_airfoil_hole(self):
+ airfoil_path = Settings.ROOT / 'data' / 'Airfoils' / 'F1K' / 'hn1033a.dat'
+ settings = MetricTriangulation.MetricTriangulationSettings(
+ example='default_airfoil_tunnel',
+ width=4.0,
+ height=2.0,
+ outer_resolution=72,
+ hole_resolution=44,
+ interior_x=32,
+ interior_y=16,
+ airfoil_path=str(airfoil_path),
+ )
+
+ result = MetricTriangulation.MetricTriangulator.generate(settings)
+
+ self.assertEqual(len(result.loops), 2)
+ self.assertIn('outer', result.mesh_data.boundary_tags)
+ self.assertIn('hole_1', result.mesh_data.boundary_tags)
+ source_count = len(
+ MetricTriangulation.MetricExampleFactory._read_airfoil_coordinates(
+ airfoil_path
+ )
+ )
+ self.assertEqual(len(result.mesh_data.boundary_tags['hole_1']), source_count)
+ self.assertFalse(result.warnings)
+
+ outer = result.loops[0].points
+ hole = result.loops[1].points
+ self.assertLess(np.min(outer[:, 0]), -0.49)
+ self.assertAlmostEqual(np.max(outer[:, 0]), 3.5, places=6)
+ self.assertGreater(np.count_nonzero(outer[:-1, 0] < 0.49), 10)
+ self.assertGreater(np.max(hole[:, 0]), 0.9)
+ self.assertLess(np.min(hole[:, 0]), 0.1)
+ self.assertTrue(np.max(hole[:, 0]) < np.max(outer[:, 0]))
+
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/tests/test_ortho_layers.py b/tests/test_ortho_layers.py
new file mode 100644
index 0000000..c6685b6
--- /dev/null
+++ b/tests/test_ortho_layers.py
@@ -0,0 +1,103 @@
+import sys
+from pathlib import Path
+
+import numpy as np
+from scipy import interpolate
+
+PROJECT_ROOT = Path(__file__).resolve().parents[1]
+SRC_ROOT = PROJECT_ROOT / 'src'
+if str(SRC_ROOT) not in sys.path:
+ sys.path.insert(0, str(SRC_ROOT))
+
+import OrthoLayers
+import StructuredCore as core
+from ContourData import SplineData
+from test_structured_topologies import _load_dat, NACA0012
+
+
+def _build_spline_data(contour, points=200, degree=3):
+ x, y = contour[:, 0], contour[:, 1]
+ tck, u = interpolate.splprep([x, y], s=0.0, k=degree)
+ t = np.linspace(0.0, 1.0, points)
+ coo = interpolate.splev(t, tck, der=0)
+ der1 = interpolate.splev(t, tck, der=1)
+ der2 = interpolate.splev(t, tck, der=2)
+ return SplineData(
+ coordinates=coo, fit_parameters=u, sample_parameters=t,
+ first_derivative=der1, second_derivative=der2, spline=tck,
+ method='bspline', metadata={'degree': degree},
+ leading_edge_parameter=float(t[int(np.argmin(coo[0]))]),
+ )
+
+
+def _spline_wall():
+ contour_raw = _load_dat(NACA0012)
+ te = 0.5 * (contour_raw[0] + contour_raw[-1])
+ contour_raw[0] = te
+ contour_raw[-1] = te
+ spline_data = _build_spline_data(contour_raw)
+ return spline_data, core.contour_array(spline_data)
+
+
+def test_exact_perpendicularity_outside_corner_blend():
+ spline_data, wall = _spline_wall()
+ n = len(wall)
+ corner = [0, n - 1]
+ normals = OrthoLayers.wall_normals(
+ wall, corner, spline_data=spline_data,
+ contour_slice=(0, n), closed=True, blend_width=4)
+ tangents = np.column_stack(spline_data.evaluate(
+ spline_data.sample_parameters, der=1))
+ dots = np.abs(np.sum(normals * tangents, axis=1)) / \
+ np.linalg.norm(tangents, axis=1)
+ interior = np.ones(n, dtype=bool)
+ interior[:5] = False
+ interior[-5:] = False
+ assert np.max(dots[interior]) < 1e-9 # mathematically perpendicular
+ lengths = np.linalg.norm(normals, axis=1)
+ assert np.allclose(lengths, 1.0, atol=1e-12)
+
+
+def test_normals_point_away_from_body():
+ spline_data, wall = _spline_wall()
+ normals = OrthoLayers.wall_normals(
+ wall, [0, len(wall) - 1], spline_data=spline_data,
+ contour_slice=(0, len(wall)), closed=True)
+ centroid = wall.mean(axis=0)
+ outward = np.sum((wall - centroid) * normals, axis=1)
+ assert np.mean(outward > 0.0) > 0.95
+
+
+def test_build_layers_wall_verbatim_and_no_fold():
+ spline_data, wall = _spline_wall()
+ normals = OrthoLayers.wall_normals(
+ wall, [0, len(wall) - 1], spline_data=spline_data,
+ contour_slice=(0, len(wall)), closed=True)
+ rows = OrthoLayers.build_layers(wall, normals, layer_count=8,
+ first_height=0.001, growth=1.2)
+ assert rows.shape == (9, len(wall), 2)
+ assert np.array_equal(rows[0], wall)
+ jacobians = core.cell_jacobians(rows)
+ assert np.all(jacobians != 0.0)
+ assert np.all(jacobians > 0) or np.all(jacobians < 0)
+
+
+def test_thinning_on_concave_wall_instead_of_folding():
+ # synthetic concave arc: offsetting toward the center must trigger caps
+ theta = np.linspace(0.25 * np.pi, 0.75 * np.pi, 80)
+ radius = 0.05
+ wall = np.column_stack((radius * np.cos(theta),
+ -radius * np.sin(theta)))
+ normals = OrthoLayers.wall_normals(wall, [])
+ # ensure normals point toward the center (concave side) for this test
+ to_center = -wall / np.linalg.norm(wall, axis=1)[:, None]
+ if np.sum(normals * to_center) < 0:
+ normals = -normals
+ caps = np.full(len(wall), 0.5 * radius)
+ rows = OrthoLayers.build_layers(wall, normals, layer_count=10,
+ first_height=0.01, growth=1.2,
+ max_heights=caps)
+ total = np.linalg.norm(rows[-1] - rows[0], axis=1)
+ assert np.all(total <= 0.5 * radius + 1e-9) # thinned
+ jacobians = core.cell_jacobians(rows)
+ assert np.all(jacobians > 0) or np.all(jacobians < 0) # not folded
diff --git a/tests/test_print_layout.py b/tests/test_print_layout.py
new file mode 100644
index 0000000..af32787
--- /dev/null
+++ b/tests/test_print_layout.py
@@ -0,0 +1,63 @@
+import sys
+import unittest
+from pathlib import Path
+
+
+PROJECT_ROOT = Path(__file__).resolve().parents[1]
+SRC_ROOT = PROJECT_ROOT / 'src'
+if str(SRC_ROOT) not in sys.path:
+ sys.path.insert(0, str(SRC_ROOT))
+
+try:
+ import PrintLayout
+except ModuleNotFoundError as error:
+ if error.name != 'PySide6':
+ raise
+ PrintLayout = None
+
+
+@unittest.skipIf(PrintLayout is None, 'PySide6 is not available in the test environment.')
+class PrintLayoutTests(unittest.TestCase):
+ def test_normalize_frame_mode_maps_legacy_iso_to_frame(self):
+ self.assertEqual(
+ PrintLayout.normalize_frame_mode('ISO'),
+ PrintLayout.FRAME_MODE_FRAME,
+ )
+
+ def test_normalize_paper_size_rejects_unknown_size(self):
+ with self.assertRaisesRegex(ValueError, 'Paper size must be one of'):
+ PrintLayout.normalize_paper_size('Letter')
+
+ def test_build_metrics_from_contour_extracts_max_values(self):
+ coordinates = (
+ [1.0, 0.5, 0.0, 0.5, 1.0],
+ [0.0, 0.10, 0.0, -0.02, 0.0],
+ )
+
+ metrics = PrintLayout.build_metrics_from_contour(
+ coordinates,
+ name='test',
+ contour_label='raw contour',
+ )
+
+ self.assertAlmostEqual(metrics.chord, 1.0)
+ self.assertAlmostEqual(metrics.max_thickness, 0.12, places=3)
+ self.assertAlmostEqual(metrics.max_thickness_position, 0.5, places=2)
+ self.assertAlmostEqual(metrics.max_camber, 0.04, places=3)
+ self.assertAlmostEqual(metrics.max_camber_position, 0.5, places=2)
+
+ def test_default_print_layout_options_prefills_airfoil_data(self):
+ class DummyAirfoil:
+ name = 'RG15'
+
+ options = PrintLayout.default_print_layout_options(DummyAirfoil())
+
+ self.assertEqual(options.paper_size, PrintLayout.PAPER_SIZE_A4)
+ self.assertFalse(options.footer.show_author)
+ self.assertFalse(options.footer.show_date)
+ self.assertTrue(options.footer.author)
+ self.assertTrue(options.footer.date_of_issue)
+
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/tests/test_quad_pipeline.py b/tests/test_quad_pipeline.py
new file mode 100644
index 0000000..c8855ec
--- /dev/null
+++ b/tests/test_quad_pipeline.py
@@ -0,0 +1,452 @@
+import sys
+import unittest
+from pathlib import Path
+
+import numpy as np
+
+
+PROJECT_ROOT = Path(__file__).resolve().parents[1]
+SRC_ROOT = PROJECT_ROOT / 'src'
+if str(SRC_ROOT) not in sys.path:
+ sys.path.insert(0, str(SRC_ROOT))
+
+import BlockMesh
+import Connect
+import Domain
+import Mesh
+import MeshBuilders
+import QuadLayout
+import QuadPipeline
+
+
+class HybridQuadPipelineTests(unittest.TestCase):
+ def setUp(self):
+ self.main_element = (
+ np.array([1.0, 0.78, 0.42, 0.0, 0.40, 0.80, 1.0], dtype=float),
+ np.array([0.0, 0.05, 0.08, 0.0, -0.08, -0.05, 0.0], dtype=float),
+ )
+ self.flap_element = (
+ np.array([1.55, 1.38, 1.08, 0.92, 1.10, 1.40, 1.55], dtype=float),
+ np.array([-0.08, -0.02, 0.00, -0.04, -0.12, -0.11, -0.08], dtype=float),
+ )
+ self.airfoil_settings = MeshBuilders.AirfoilBlockSettings(
+ name='block_hybrid_airfoil',
+ divisions=12,
+ growth=1.05,
+ thickness=0.004,
+ )
+ self.trailing_edge_settings = MeshBuilders.TrailingEdgeBlockSettings(
+ trailing_edge_divisions=3,
+ thickness=0.004,
+ divisions=12,
+ growth=1.05,
+ )
+ self.tunnel_settings = MeshBuilders.TunnelBlockSettings(
+ tunnel_height=3.5,
+ divisions_height=40,
+ smoothing_iterations=4,
+ smoothing_tolerance=1.0e-5,
+ elliptic_relaxation=0.45,
+ protected_guide_smoothing=2,
+ )
+ self.wake_settings = MeshBuilders.WakeBlockSettings(
+ tunnel_wake=6.0,
+ divisions=60,
+ growth=1.0,
+ spread=0.2,
+ )
+
+ @staticmethod
+ def _make_demo_block():
+ block = BlockMesh.BlockMesh('demo')
+ block.addLine([
+ (0.0, 0.0),
+ (0.20, 0.00),
+ (0.45, 0.00),
+ (0.75, 0.00),
+ (1.0, 0.0),
+ ])
+ block.addLine([
+ (0.0, 0.35),
+ (0.18, 0.42),
+ (0.43, 0.46),
+ (0.76, 0.41),
+ (1.0, 0.35),
+ ])
+ block.addLine([
+ (0.0, 0.70),
+ (0.16, 0.84),
+ (0.40, 0.92),
+ (0.77, 0.81),
+ (1.0, 0.70),
+ ])
+ block.addLine([
+ (0.0, 1.0),
+ (0.12, 1.0),
+ (0.38, 1.0),
+ (0.72, 1.0),
+ (1.0, 1.0),
+ ])
+ return block
+
+ def test_stage2_redistributes_interior_lines_but_preserves_boundaries(self):
+ block = self._make_demo_block()
+ before_bottom = np.asarray(block.getULines()[0], dtype=float)
+ before_top = np.asarray(block.getULines()[-1], dtype=float)
+ before_interior = np.asarray(block.getULines()[1], dtype=float)
+
+ pipeline = QuadPipeline.HybridQuadPipeline()
+ stage2 = QuadPipeline.HybridStage2Settings(
+ enabled=True,
+ sweeps=1,
+ redistribute_u=True,
+ redistribute_v=False,
+ )
+ changed = pipeline._redistribute_block(block, stage2)
+
+ after_bottom = np.asarray(block.getULines()[0], dtype=float)
+ after_top = np.asarray(block.getULines()[-1], dtype=float)
+ after_interior = np.asarray(block.getULines()[1], dtype=float)
+
+ self.assertTrue(changed)
+ np.testing.assert_allclose(after_bottom, before_bottom)
+ np.testing.assert_allclose(after_top, before_top)
+ self.assertFalse(np.allclose(after_interior, before_interior))
+
+ def test_layout_plan_builds_multi_element_block_specs(self):
+ settings = QuadPipeline.HybridQuadPipelineSettings(
+ layout_strategy='multi_element_oc',
+ )
+
+ layout_plan = QuadPipeline.HybridQuadPipeline.build_layout_plan(
+ self.main_element,
+ settings,
+ airfoil_settings=self.airfoil_settings,
+ tunnel_settings=self.tunnel_settings,
+ wake_settings=self.wake_settings,
+ trailing_edge_settings=self.trailing_edge_settings,
+ boundary_loops=[self.main_element, self.flap_element],
+ )
+
+ self.assertEqual(layout_plan.metadata['element_count'], 2)
+ self.assertEqual(len(layout_plan.boundary_loops), 2)
+ self.assertEqual(len(layout_plan.near_wall_loops), 2)
+ self.assertGreaterEqual(len(layout_plan.block_specs), 13)
+ protected = [spec for spec in layout_plan.block_specs if spec.protected]
+ self.assertEqual(len(protected), 8)
+
+ def test_pipeline_run_builds_connected_multi_element_mesh(self):
+ pipeline = QuadPipeline.HybridQuadPipeline()
+ settings = QuadPipeline.HybridQuadPipelineSettings(
+ layout_strategy='multi_element_oc',
+ protect_near_wall=True,
+ stage2=QuadPipeline.HybridStage2Settings(enabled=True, sweeps=1),
+ stage1=QuadPipeline.HybridStage1Settings(
+ enabled=True,
+ algorithm='elliptic',
+ iterations=2,
+ tolerance=1.0e-4,
+ relaxation=0.4,
+ ),
+ )
+
+ result = pipeline.run(
+ contour=self.main_element,
+ settings=settings,
+ airfoil_settings=self.airfoil_settings,
+ tunnel_settings=self.tunnel_settings,
+ wake_settings=self.wake_settings,
+ trailing_edge_settings=self.trailing_edge_settings,
+ boundary_loops=[self.main_element, self.flap_element],
+ )
+
+ vertices, connectivity = Connect.Connect().connectAllBlocks(result.blocks)
+ topology = Mesh.MeshTopology.from_mesh(vertices, connectivity)
+
+ self.assertEqual(result.metadata['engine'], 'hybrid')
+ self.assertEqual(result.metadata['layout_strategy'], 'multi_element_oc')
+ self.assertTrue(result.metadata['stage4']['implemented'])
+ self.assertTrue(result.metadata['protect_near_wall'])
+ self.assertGreater(result.metadata['stage2']['blocks_skipped'], 0)
+ self.assertGreater(result.metadata['stage1']['blocks_skipped'], 0)
+ self.assertGreater(len(vertices), 0)
+ self.assertGreater(len(connectivity), 0)
+ self.assertGreater(len(topology.boundary_tags['airfoil']), 0)
+ self.assertGreater(len(topology.boundary_tags['inlet']), 0)
+ self.assertGreater(len(topology.boundary_tags['outlet']), 0)
+ self.assertGreater(len(topology.boundary_tags['top']), 0)
+ self.assertGreater(len(topology.boundary_tags['bottom']), 0)
+
+ mesh_data = Mesh.MeshData(
+ vertices=vertices,
+ connectivity=connectivity,
+ boundary_tags=topology.boundary_tags,
+ )
+ mesh = Mesh.BlockStructuredMesh(name='hybrid_multi', data=mesh_data)
+ domain = Domain.DomainBuilder.from_mesh(mesh, airfoil=None)
+ self.assertGreater(len(domain.outer_boundary.to_polygon()), 0)
+
+ def test_run_stage4_keeps_stage2_and_stage1_unapplied(self):
+ pipeline = QuadPipeline.HybridQuadPipeline()
+ settings = QuadPipeline.HybridQuadPipelineSettings(
+ layout_strategy='multi_element_oc',
+ protect_near_wall=True,
+ )
+
+ result = pipeline.run_stage4(
+ contour=self.main_element,
+ settings=settings,
+ airfoil_settings=self.airfoil_settings,
+ tunnel_settings=self.tunnel_settings,
+ wake_settings=self.wake_settings,
+ trailing_edge_settings=self.trailing_edge_settings,
+ boundary_loops=[self.main_element, self.flap_element],
+ engine='hybrid_staged',
+ )
+
+ self.assertEqual(result.metadata['engine'], 'hybrid_staged')
+ self.assertTrue(result.metadata['stages']['stage4_ready'])
+ self.assertFalse(result.metadata['stages']['stage2_applied'])
+ self.assertFalse(result.metadata['stages']['stage1_applied'])
+ self.assertTrue(result.metadata['stage4']['applied'])
+ self.assertFalse(result.metadata['stage2']['applied'])
+ self.assertFalse(result.metadata['stage1']['applied'])
+
+ def test_staged_metadata_tracks_stage2_then_stage1(self):
+ pipeline = QuadPipeline.HybridQuadPipeline()
+ settings = QuadPipeline.HybridQuadPipelineSettings(
+ layout_strategy='multi_element_oc',
+ protect_near_wall=True,
+ stage2=QuadPipeline.HybridStage2Settings(
+ enabled=True,
+ sweeps=1,
+ ),
+ stage1=QuadPipeline.HybridStage1Settings(
+ enabled=True,
+ algorithm='simple',
+ iterations=2,
+ ),
+ )
+
+ stage4_result = pipeline.run_stage4(
+ contour=self.main_element,
+ settings=settings,
+ airfoil_settings=self.airfoil_settings,
+ tunnel_settings=self.tunnel_settings,
+ wake_settings=self.wake_settings,
+ trailing_edge_settings=self.trailing_edge_settings,
+ boundary_loops=[self.main_element, self.flap_element],
+ engine='hybrid_staged',
+ )
+
+ stage2_metadata = pipeline.apply_stage2(
+ stage4_result.blocks,
+ settings.stage2,
+ protect_near_wall=settings.protect_near_wall,
+ )
+ stage2_pipeline_metadata = pipeline.build_metadata(
+ stage4_result.layout_plan,
+ settings,
+ engine='hybrid_staged',
+ stage2_metadata=stage2_metadata,
+ stage1_metadata=pipeline.default_stage1_metadata(
+ settings.stage1,
+ protect_near_wall=settings.protect_near_wall,
+ ),
+ )
+
+ self.assertTrue(stage2_pipeline_metadata['stages']['stage2_applied'])
+ self.assertFalse(stage2_pipeline_metadata['stages']['stage1_applied'])
+
+ stage1_metadata = pipeline.apply_stage1(
+ stage4_result.blocks,
+ settings.stage1,
+ protect_near_wall=settings.protect_near_wall,
+ )
+ stage1_pipeline_metadata = pipeline.build_metadata(
+ stage4_result.layout_plan,
+ settings,
+ engine='hybrid_staged',
+ stage2_metadata=stage2_metadata,
+ stage1_metadata=stage1_metadata,
+ )
+
+ self.assertTrue(stage1_pipeline_metadata['stages']['stage2_applied'])
+ self.assertTrue(stage1_pipeline_metadata['stages']['stage1_applied'])
+ self.assertTrue(stage1_pipeline_metadata['stage1']['applied'])
+
+ def test_layout_generator_auto_closes_near_closed_and_blunt_inputs(self):
+ near_closed = (
+ np.array([1.0, 0.78, 0.42, 0.0, 0.40, 0.80, 1.0 + 1.0e-8], dtype=float),
+ np.array([0.0, 0.05, 0.08, 0.0, -0.08, -0.05, 2.0e-8], dtype=float),
+ )
+ blunt = (
+ np.array([1.0, 0.7, 0.2, 0.0, 0.2, 0.7, 1.0], dtype=float),
+ np.array([0.03, 0.08, 0.10, 0.0, -0.10, -0.08, -0.03], dtype=float),
+ )
+
+ for contour in (near_closed, blunt):
+ plan = QuadLayout.QuadLayoutGenerator.generate(
+ contour,
+ tunnel_height=3.5,
+ wake_length=6.0,
+ surface_points=len(contour[0]),
+ normal_divisions=12,
+ first_layer_thickness=0.004,
+ layer_growth=1.05,
+ connector_layers=40,
+ trailing_edge_divisions=3,
+ )
+ loop = plan.boundary_loops[0]
+ np.testing.assert_allclose(loop[0], loop[-1])
+
+ def test_explicit_boundary_loops_keep_per_loop_resolution(self):
+ theta_a = np.linspace(0.0, 2.0 * np.pi, 17)
+ theta_b = np.linspace(0.0, 2.0 * np.pi, 25)
+ loop_a = np.column_stack((
+ 0.50 + 0.45 * np.cos(theta_a),
+ 0.00 + 0.08 * np.sin(theta_a),
+ ))
+ loop_b = np.column_stack((
+ 1.65 + 0.25 * np.cos(theta_b),
+ -0.05 + 0.06 * np.sin(theta_b),
+ ))
+
+ plan = QuadPipeline.HybridQuadPipeline.build_layout_plan(
+ self.main_element,
+ QuadPipeline.HybridQuadPipelineSettings(
+ layout_strategy='multi_element_oc',
+ ),
+ airfoil_settings=self.airfoil_settings,
+ tunnel_settings=self.tunnel_settings,
+ wake_settings=self.wake_settings,
+ trailing_edge_settings=self.trailing_edge_settings,
+ boundary_loops=[loop_a, loop_b],
+ )
+
+ self.assertEqual([len(loop) for loop in plan.boundary_loops], [17, 25])
+
+ def test_metric_c_grid_layout_uses_boundary_singularities(self):
+ settings = QuadPipeline.HybridQuadPipelineSettings(
+ layout_strategy='metric_c_grid',
+ singularity_template='auto_boundary_c',
+ stage2=QuadPipeline.HybridStage2Settings(enabled=False, sweeps=0),
+ stage1=QuadPipeline.HybridStage1Settings(
+ enabled=False,
+ algorithm='none',
+ iterations=0,
+ ),
+ )
+
+ plan = QuadPipeline.HybridQuadPipeline.build_layout_plan(
+ self.main_element,
+ settings,
+ airfoil_settings=self.airfoil_settings,
+ tunnel_settings=self.tunnel_settings,
+ wake_settings=self.wake_settings,
+ trailing_edge_settings=self.trailing_edge_settings,
+ )
+
+ self.assertEqual(plan.metadata['strategy'], 'metric_c_grid')
+ self.assertEqual(plan.metadata['farfield_shape'], 'legacy_wind_tunnel')
+ self.assertEqual(plan.metadata['mesh_family'], 'c_grid')
+ self.assertEqual(plan.metadata['te_geometry'], 'sharp')
+ self.assertEqual(plan.metadata['singularity_template'], 'auto_boundary_c')
+ self.assertEqual(len(plan.block_specs), 1)
+ self.assertEqual(len(plan.singularities), 4)
+ self.assertEqual(len(plan.separatrices), 2)
+ self.assertTrue(all(s.kind == 'boundary' for s in plan.singularities))
+ self.assertEqual(plan.block_specs[0].role, 'metric_c_grid')
+ roles = [s.metadata['role'] for s in plan.singularities]
+ self.assertEqual(
+ roles,
+ ['te_upper', 'te_lower', 'wake_upper', 'wake_lower'],
+ )
+ np.testing.assert_allclose(
+ plan.singularities[0].position,
+ plan.singularities[1].position,
+ )
+ np.testing.assert_allclose(
+ plan.singularities[2].position,
+ plan.singularities[3].position,
+ )
+ self.assertGreater(
+ float(np.max(plan.outer_boundary[:, 0])),
+ float(np.max(plan.boundary_loops[0][:-1, 0])) + 5.5,
+ )
+ self.assertLess(
+ float(np.min(plan.outer_boundary[:, 0])),
+ float(np.min(plan.boundary_loops[0][:-1, 0])) - 3.0,
+ )
+
+ def test_metric_pipeline_stage4_builds_c_grid_block(self):
+ pipeline = QuadPipeline.HybridQuadPipeline()
+ settings = QuadPipeline.HybridQuadPipelineSettings(
+ layout_strategy='metric_c_grid',
+ stage2=QuadPipeline.HybridStage2Settings(enabled=False, sweeps=0),
+ stage1=QuadPipeline.HybridStage1Settings(
+ enabled=False,
+ algorithm='none',
+ iterations=0,
+ ),
+ )
+
+ result = pipeline.run_stage4(
+ contour=self.main_element,
+ settings=settings,
+ airfoil_settings=self.airfoil_settings,
+ tunnel_settings=self.tunnel_settings,
+ wake_settings=self.wake_settings,
+ trailing_edge_settings=self.trailing_edge_settings,
+ engine='metric_based',
+ )
+
+ vertices, connectivity = Connect.Connect().connectAllBlocks(result.blocks)
+
+ self.assertEqual(result.metadata['engine'], 'metric_based')
+ self.assertEqual(result.metadata['layout_strategy'], 'metric_c_grid')
+ self.assertEqual(result.metadata['stage4']['block_count'], 1)
+ self.assertEqual(result.metadata['stage4']['singularity_count'], 4)
+ self.assertFalse(result.metadata['stage2']['applied'])
+ self.assertFalse(result.metadata['stage1']['applied'])
+ self.assertEqual(len(result.blocks), 1)
+ self.assertGreater(len(vertices), 0)
+ self.assertGreater(len(connectivity), 0)
+
+ def test_layout_connectors_use_matching_vertical_point_counts(self):
+ plan = QuadPipeline.HybridQuadPipeline.build_layout_plan(
+ self.main_element,
+ QuadPipeline.HybridQuadPipelineSettings(
+ layout_strategy='multi_element_oc',
+ ),
+ airfoil_settings=self.airfoil_settings,
+ tunnel_settings=self.tunnel_settings,
+ wake_settings=self.wake_settings,
+ trailing_edge_settings=self.trailing_edge_settings,
+ )
+
+ specs_by_name = {spec.name: spec for spec in plan.block_specs}
+ for name in ('block_hybrid_top', 'block_hybrid_bottom'):
+ spec = specs_by_name[name]
+ self.assertEqual(len(spec.left_boundary), len(spec.right_boundary))
+
+ for name in ('block_hybrid_inlet', 'block_hybrid_outlet'):
+ spec = specs_by_name[name]
+ self.assertEqual(len(spec.lower_boundary), len(spec.upper_boundary))
+
+ def test_stage1_none_skips_cleanup(self):
+ metadata = QuadPipeline.HybridQuadPipeline.apply_stage1(
+ [self._make_demo_block()],
+ QuadPipeline.HybridStage1Settings(
+ enabled=True,
+ algorithm='none',
+ iterations=10,
+ ),
+ )
+
+ self.assertEqual(metadata['blocks_processed'], 0)
+ self.assertEqual(metadata['algorithm'], 'none')
+
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/tests/test_quad_quality.py b/tests/test_quad_quality.py
new file mode 100644
index 0000000..3a947e1
--- /dev/null
+++ b/tests/test_quad_quality.py
@@ -0,0 +1,87 @@
+import sys
+import unittest
+from pathlib import Path
+
+import numpy as np
+
+
+PROJECT_ROOT = Path(__file__).resolve().parents[1]
+SRC_ROOT = PROJECT_ROOT / 'src'
+if str(SRC_ROOT) not in sys.path:
+ sys.path.insert(0, str(SRC_ROOT))
+
+import QuadQuality
+
+
+class QuadQualityTests(unittest.TestCase):
+ def test_k2inf_is_one_for_unit_square(self):
+ report = QuadQuality.QuadQualityEvaluator.evaluate(
+ vertices=[
+ (0.0, 0.0),
+ (1.0, 0.0),
+ (1.0, 1.0),
+ (0.0, 1.0),
+ ],
+ connectivity=[(0, 1, 2, 3)],
+ )
+
+ self.assertEqual(report.criterion, 'k2inf')
+ self.assertAlmostEqual(report.values[0], 1.0)
+ self.assertAlmostEqual(report.signed_areas[0], 1.0)
+ self.assertFalse(report.has_inverted_cells)
+
+ def test_k2inf_penalizes_a_sheared_quad(self):
+ square = QuadQuality.QuadQualityEvaluator.evaluate(
+ vertices=[
+ (0.0, 0.0),
+ (1.0, 0.0),
+ (1.0, 1.0),
+ (0.0, 1.0),
+ ],
+ connectivity=[(0, 1, 2, 3)],
+ )
+ sheared = QuadQuality.QuadQualityEvaluator.evaluate(
+ vertices=[
+ (0.0, 0.0),
+ (1.0, 0.0),
+ (1.4, 1.0),
+ (0.4, 1.0),
+ ],
+ connectivity=[(0, 1, 2, 3)],
+ )
+
+ self.assertGreater(sheared.values[0], square.values[0])
+ self.assertGreater(sheared.minimum_signed_area, 0.0)
+
+ def test_report_flags_inverted_cells_from_signed_area(self):
+ report = QuadQuality.QuadQualityEvaluator.evaluate(
+ vertices=[
+ (0.0, 0.0),
+ (0.0, 1.0),
+ (1.0, 1.0),
+ (1.0, 0.0),
+ ],
+ connectivity=[(0, 1, 2, 3)],
+ )
+
+ self.assertTrue(report.has_inverted_cells)
+ self.assertLess(report.minimum_signed_area, 0.0)
+ np.testing.assert_array_equal(
+ report.inverted_cell_indices,
+ np.array([0], dtype=int),
+ )
+
+ def test_evaluate_rejects_non_quad_connectivity(self):
+ with self.assertRaisesRegex(ValueError, 'M x 4'):
+ QuadQuality.QuadQualityEvaluator.evaluate(
+ vertices=[
+ (0.0, 0.0),
+ (1.0, 0.0),
+ (0.0, 1.0),
+ ],
+ connectivity=[(0, 1, 2)],
+ )
+
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/tests/test_quad_redistribute.py b/tests/test_quad_redistribute.py
new file mode 100644
index 0000000..2da896e
--- /dev/null
+++ b/tests/test_quad_redistribute.py
@@ -0,0 +1,94 @@
+import sys
+import unittest
+from pathlib import Path
+
+import numpy as np
+
+
+PROJECT_ROOT = Path(__file__).resolve().parents[1]
+SRC_ROOT = PROJECT_ROOT / 'src'
+if str(SRC_ROOT) not in sys.path:
+ sys.path.insert(0, str(SRC_ROOT))
+
+import BlockMesh
+import QuadLayout
+import QuadMonitor
+import QuadRedistribute
+
+
+class QuadRedistributionTests(unittest.TestCase):
+ def test_metric_density_matches_isotropic_size_field(self):
+ metric = QuadMonitor.LineMetricField.from_isotropic_sizes(
+ [2.0, 1.0, 0.5]
+ )
+ density = metric.density_values()
+
+ np.testing.assert_allclose(density, np.array([0.25, 1.0, 4.0]))
+
+ def test_monitor_weighted_redistribution_clusters_points(self):
+ line = np.column_stack((
+ np.linspace(0.0, 1.0, 5),
+ np.zeros(5, dtype=float),
+ ))
+ redistributed = QuadRedistribute.redistribute_polyline(
+ line,
+ monitor=[4.0, 4.0, 1.0, 1.0, 1.0],
+ )
+
+ x_values = redistributed[:, 0]
+ self.assertAlmostEqual(x_values[0], 0.0)
+ self.assertAlmostEqual(x_values[-1], 1.0)
+ self.assertLess(x_values[1] - x_values[0], x_values[-1] - x_values[-2])
+
+ def test_metric_weighted_redistribution_clusters_points(self):
+ line = np.column_stack((
+ np.linspace(0.0, 1.0, 5),
+ np.zeros(5, dtype=float),
+ ))
+ metric = QuadMonitor.LineMetricField.from_isotropic_sizes(
+ [0.5, 0.5, 1.0, 1.0, 1.0]
+ )
+ redistributed = QuadRedistribute.redistribute_polyline(
+ line,
+ metric=metric,
+ )
+
+ x_values = redistributed[:, 0]
+ self.assertLess(x_values[1] - x_values[0], x_values[-1] - x_values[-2])
+
+ def test_block_mesh_can_redistribute_u_line_with_monitor(self):
+ block = BlockMesh.BlockMesh('demo')
+ block.addLine([(0.0, 0.0), (0.25, 0.0), (0.5, 0.0), (0.75, 0.0), (1.0, 0.0)])
+ block.addLine([(0.0, 1.0), (0.25, 1.0), (0.5, 1.0), (0.75, 1.0), (1.0, 1.0)])
+
+ redistributed = block.redistributeLine(
+ direction='u',
+ number=0,
+ monitor=[4.0, 4.0, 1.0, 1.0, 1.0],
+ )
+
+ x_values = np.asarray([point[0] for point in redistributed], dtype=float)
+ self.assertAlmostEqual(x_values[0], 0.0)
+ self.assertAlmostEqual(x_values[-1], 1.0)
+ self.assertLess(x_values[1] - x_values[0], x_values[-1] - x_values[-2])
+
+ def test_layout_stub_can_create_plan_with_singularity_metadata(self):
+ singularity = QuadLayout.QuadSingularity(
+ position=(0.5, 0.5),
+ charge=1,
+ kind='valence_3',
+ metadata={'source': 'manual'},
+ )
+ plan = QuadLayout.QuadLayoutGenerator.create_plan(
+ boundary_points=[(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0)],
+ singularities=[singularity],
+ metadata={'layout': 'stub'},
+ )
+
+ self.assertEqual(len(plan.singularities), 1)
+ self.assertEqual(plan.singularities[0].charge, 1)
+ self.assertEqual(plan.metadata['layout'], 'stub')
+
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/tests/test_structured_core.py b/tests/test_structured_core.py
new file mode 100644
index 0000000..4debaf5
--- /dev/null
+++ b/tests/test_structured_core.py
@@ -0,0 +1,71 @@
+import sys
+from pathlib import Path
+
+import numpy as np
+
+PROJECT_ROOT = Path(__file__).resolve().parents[1]
+SRC_ROOT = PROJECT_ROOT / 'src'
+if str(SRC_ROOT) not in sys.path:
+ sys.path.insert(0, str(SRC_ROOT))
+
+import StructuredCore as core
+
+
+def _sharp_contour():
+ x = np.array([1.0, 0.75, 0.35, 0.0, 0.35, 0.75, 1.0])
+ y = np.array([0.0, 0.05, 0.08, 0.0, -0.08, -0.05, 0.0])
+ return np.column_stack((x, y))
+
+
+def _blunt_contour():
+ x = np.array([1.0, 0.75, 0.35, 0.0, 0.35, 0.75, 1.0])
+ y = np.array([0.01, 0.05, 0.08, 0.0, -0.08, -0.05, -0.01])
+ return np.column_stack((x, y))
+
+
+def test_detect_te_type():
+ assert core.detect_te_type(_sharp_contour()) == 'sharp'
+ assert core.detect_te_type(_blunt_contour()) == 'blunt'
+
+
+def test_geometric_distances_first_spacing_and_length():
+ d = core.geometric_distances(length=2.0, first_spacing=0.01, count=30)
+ assert d.shape == (30,)
+ assert d[0] == 0.0
+ assert np.isclose(d[-1], 2.0)
+ assert np.isclose(d[1] - d[0], 0.01, rtol=1e-6)
+ assert np.all(np.diff(d) > 0.0)
+ # growth is monotone: each step at least as large as the previous
+ steps = np.diff(d)
+ assert np.all(steps[1:] >= steps[:-1] - 1e-12)
+
+
+def test_sample_te_base_endpoints_verbatim():
+ lower = np.array([1.0, -0.01])
+ upper = np.array([1.0, 0.01])
+ base = core.sample_te_base(lower, upper, spacing=0.004)
+ assert np.allclose(base[0], lower)
+ assert np.allclose(base[-1], upper)
+ assert len(base) >= 3
+ assert np.all(np.diff(base[:, 1]) > 0.0)
+
+
+def test_distribute_on_polyline_uniform_and_clustered():
+ line = np.column_stack((np.linspace(0.0, 10.0, 5), np.zeros(5)))
+ uniform = core.distribute_on_polyline(line, 21, distribution='uniform')
+ assert uniform.shape == (21, 2)
+ assert np.allclose(uniform[0], line[0])
+ assert np.allclose(uniform[-1], line[-1])
+ assert np.allclose(np.diff(uniform[:, 0]), 0.5)
+
+ clustered = core.distribute_on_polyline(
+ line, 21, distribution='clustered', ratio=4.0)
+ steps = np.diff(clustered[:, 0])
+ assert np.isclose(steps[-1] / steps[0], 4.0, rtol=0.05)
+
+
+def test_settings_defaults_valid():
+ settings = core.StructuredMeshSettings()
+ assert settings.topology in ('c', 'o')
+ assert settings.tunnel_shape in ('legacy', 'circular')
+ assert settings.boundary_control.distribution == 'uniform'
diff --git a/tests/test_structured_engine.py b/tests/test_structured_engine.py
new file mode 100644
index 0000000..f6f3363
--- /dev/null
+++ b/tests/test_structured_engine.py
@@ -0,0 +1,309 @@
+import sys
+import unittest
+from pathlib import Path
+from unittest import mock
+
+import numpy as np
+
+PROJECT_ROOT = Path(__file__).resolve().parents[1]
+SRC_ROOT = PROJECT_ROOT / 'src'
+if str(SRC_ROOT) not in sys.path:
+ sys.path.insert(0, str(SRC_ROOT))
+
+import Connect
+import StructuredCore as core
+from StructuredEngine import StructuredEngine
+from test_ortho_layers import _build_spline_data
+from test_structured_topologies import (
+ NACA0012, _blunt_contour, _load_dat, _sharp_contour,
+)
+
+DATA_DIR = Path(__file__).resolve().parent / 'data'
+NACA2315 = DATA_DIR / 'naca2315.dat'
+MW166 = DATA_DIR / 'MW-166-39-44-43.dat'
+
+
+def _spline_data_for(contour):
+ return _build_spline_data(np.asarray(contour, dtype=float))
+
+
+def _minimum_cell_area(vertices, connectivity):
+ points = np.asarray(vertices, dtype=float)
+ areas = []
+ for cell in np.asarray(connectivity, dtype=int):
+ polygon = points[cell]
+ x, y = polygon[:, 0], polygon[:, 1]
+ areas.append(0.5 * abs(np.dot(x, np.roll(y, -1)) -
+ np.dot(y, np.roll(x, -1))))
+ return min(areas) if areas else 0.0
+
+
+class StructuredEngineMatrixTests(unittest.TestCase):
+ def test_full_matrix_no_inverted_cells(self):
+ engine = StructuredEngine()
+ contours = {'sharp': _sharp_contour(), 'blunt': _blunt_contour()}
+ for topology in ('c', 'o'):
+ for shape in ('legacy', 'circular'):
+ for te_name, contour in contours.items():
+ for ortho in (0, 6):
+ with self.subTest(topology=topology, shape=shape,
+ te=te_name, ortho=ortho):
+ settings = core.StructuredMeshSettings(
+ topology=topology, tunnel_shape=shape,
+ tunnel_height=(3.5 if shape == 'legacy'
+ else 10.0),
+ normal_divisions=40, wake_points=40,
+ ortho_layers=ortho,
+ )
+ spline_data = _spline_data_for(contour)
+ named = engine.build_blocks(
+ spline_data=spline_data, settings=settings)
+ blocks = [block for _, block in named]
+ connector = Connect.Connect()
+ vertices, connectivity = \
+ connector.connectAllBlocks(blocks)
+ self.assertGreater(
+ _minimum_cell_area(vertices, connectivity),
+ 1.0e-12)
+
+ def test_wall_row_is_contour_verbatim_every_airfoil(self):
+ engine = StructuredEngine()
+ for dat in (NACA0012, NACA2315, MW166):
+ contour = _load_dat(dat)
+ spline_data = _spline_data_for(contour)
+ prepared = core.contour_array(spline_data)
+ for topology in ('c', 'o'):
+ with self.subTest(airfoil=dat.name, topology=topology):
+ settings = core.StructuredMeshSettings(
+ topology=topology, normal_divisions=30,
+ wake_points=30)
+ named = engine.build_blocks(
+ spline_data=spline_data, settings=settings)
+ wall = np.asarray(named[0][1].getULines()[0])
+ found = any(
+ np.allclose(wall[s:s + len(prepared)], prepared,
+ atol=1e-12)
+ for s in range(len(wall) - len(prepared) + 1))
+ self.assertTrue(found,
+ 'contour not verbatim in wall row')
+
+ def test_blunt_c_interface_node_match(self):
+ engine = StructuredEngine()
+ spline_data = _spline_data_for(_blunt_contour())
+ settings = core.StructuredMeshSettings(topology='c',
+ normal_divisions=30,
+ wake_points=30)
+ named = dict(engine.build_blocks(spline_data=spline_data,
+ settings=settings))
+ self.assertIn('block_structured_wake_strip', named)
+ main = np.asarray(named['block_structured'].getULines()[0])
+ strip = named['block_structured_wake_strip']
+ strip_lower = np.asarray(strip.getULines()[0])
+ strip_upper = np.asarray(strip.getULines()[-1])
+ wake_points = settings.wake_points
+ self.assertTrue(np.allclose(strip_lower, main[-wake_points:]))
+ self.assertTrue(np.allclose(strip_upper,
+ main[:wake_points][::-1]))
+
+
+class StructuredEllipticEngineTests(unittest.TestCase):
+ def test_elliptic_matrix_no_inverted_cells(self):
+ engine = StructuredEngine()
+ contours = {'sharp': _sharp_contour(), 'blunt': _blunt_contour(),
+ 'mw166': _load_dat(MW166)}
+ for topology in ('c', 'o'):
+ for te_name, contour in contours.items():
+ for ortho in (0, 6):
+ with self.subTest(topology=topology, te=te_name,
+ ortho=ortho):
+ settings = core.StructuredMeshSettings(
+ topology=topology,
+ algorithm='elliptic',
+ elliptic_iterations=40,
+ normal_divisions=40, wake_points=40,
+ ortho_layers=ortho,
+ )
+ spline_data = _spline_data_for(contour)
+ named = engine.build_blocks(
+ spline_data=spline_data, settings=settings)
+ blocks = [block for _, block in named]
+ connector = Connect.Connect()
+ vertices, connectivity = \
+ connector.connectAllBlocks(blocks)
+ self.assertGreater(
+ _minimum_cell_area(vertices, connectivity),
+ 1.0e-12)
+
+ def test_elliptic_keeps_wall_verbatim(self):
+ engine = StructuredEngine()
+ contour = _load_dat(MW166)
+ spline_data = _spline_data_for(contour)
+ prepared = core.contour_array(spline_data)
+ for topology in ('c', 'o'):
+ with self.subTest(topology=topology):
+ settings = core.StructuredMeshSettings(
+ topology=topology, algorithm='elliptic',
+ elliptic_iterations=30,
+ normal_divisions=30, wake_points=30)
+ named = engine.build_blocks(
+ spline_data=spline_data, settings=settings)
+ wall = np.asarray(named[0][1].getULines()[0])
+ found = any(
+ np.allclose(wall[s:s + len(prepared)], prepared,
+ atol=1e-12)
+ for s in range(len(wall) - len(prepared) + 1))
+ self.assertTrue(found)
+
+ def test_unknown_algorithm_raises(self):
+ engine = StructuredEngine()
+ spline_data = _spline_data_for(_sharp_contour())
+ settings = core.StructuredMeshSettings(
+ algorithm='spectral', normal_divisions=20, wake_points=20)
+ with self.assertRaises(ValueError):
+ engine.build_blocks(spline_data=spline_data, settings=settings)
+
+
+class StructuredHyperbolicEngineTests(unittest.TestCase):
+ def test_hyperbolic_matrix_no_inverted_cells(self):
+ engine = StructuredEngine()
+ contours = {'sharp': _sharp_contour(), 'blunt': _blunt_contour(),
+ 'mw166': _load_dat(MW166)}
+ for topology in ('c', 'o'):
+ for te_name, contour in contours.items():
+ for ortho in (0, 6):
+ with self.subTest(topology=topology, te=te_name,
+ ortho=ortho):
+ settings = core.StructuredMeshSettings(
+ topology=topology,
+ algorithm='hyperbolic',
+ normal_divisions=40, wake_points=40,
+ ortho_layers=ortho,
+ )
+ spline_data = _spline_data_for(contour)
+ named = engine.build_blocks(
+ spline_data=spline_data, settings=settings)
+ blocks = [block for _, block in named]
+ connector = Connect.Connect()
+ vertices, connectivity = \
+ connector.connectAllBlocks(blocks)
+ self.assertGreater(
+ _minimum_cell_area(vertices, connectivity),
+ 1.0e-12)
+
+ def test_hyperbolic_keeps_wall_verbatim(self):
+ engine = StructuredEngine()
+ contour = _load_dat(MW166)
+ spline_data = _spline_data_for(contour)
+ prepared = core.contour_array(spline_data)
+ for topology in ('c', 'o'):
+ with self.subTest(topology=topology):
+ settings = core.StructuredMeshSettings(
+ topology=topology, algorithm='hyperbolic',
+ normal_divisions=30, wake_points=30)
+ named = engine.build_blocks(
+ spline_data=spline_data, settings=settings)
+ wall = np.asarray(named[0][1].getULines()[0])
+ found = any(
+ np.allclose(wall[s:s + len(prepared)], prepared,
+ atol=1e-12)
+ for s in range(len(wall) - len(prepared) + 1))
+ self.assertTrue(found)
+
+
+class StructuredSmootherEngineTests(unittest.TestCase):
+ def test_smoother_applied_with_correct_frozen_rows_main_frame(self):
+ import GridSmoothers
+ engine = StructuredEngine()
+ settings = core.StructuredMeshSettings(
+ topology='c', smoother='laplacian', smoother_iterations=3,
+ ortho_layers=6, normal_divisions=40, wake_points=40)
+ spline_data = _spline_data_for(_sharp_contour())
+ with mock.patch.object(GridSmoothers, 'smooth',
+ wraps=GridSmoothers.smooth) as spy:
+ engine.build_blocks(spline_data=spline_data, settings=settings)
+ # first call is the main block; frozen_rows must protect the wall
+ # plus every ortho layer
+ _args, kwargs = spy.call_args_list[0]
+ self.assertEqual(kwargs['frozen_rows'], 1 + settings.ortho_layers)
+ self.assertEqual(kwargs['method'], 'laplacian')
+
+ def test_smoother_applied_with_frozen_rows_one_on_wake_strip(self):
+ import GridSmoothers
+ engine = StructuredEngine()
+ settings = core.StructuredMeshSettings(
+ topology='c', smoother='laplacian', smoother_iterations=3,
+ ortho_layers=6, normal_divisions=40, wake_points=40)
+ spline_data = _spline_data_for(_blunt_contour())
+ with mock.patch.object(GridSmoothers, 'smooth',
+ wraps=GridSmoothers.smooth) as spy:
+ named = engine.build_blocks(spline_data=spline_data,
+ settings=settings)
+ names = [name for name, _block in named]
+ self.assertIn('block_structured_wake_strip', names)
+ strip_index = names.index('block_structured_wake_strip')
+ _args, kwargs = spy.call_args_list[strip_index]
+ self.assertEqual(kwargs['frozen_rows'], 1)
+
+ def test_smoother_none_skips_call(self):
+ import GridSmoothers
+ engine = StructuredEngine()
+ settings = core.StructuredMeshSettings(
+ topology='c', smoother='none', normal_divisions=30,
+ wake_points=30)
+ spline_data = _spline_data_for(_sharp_contour())
+ with mock.patch.object(GridSmoothers, 'smooth') as spy:
+ engine.build_blocks(spline_data=spline_data, settings=settings)
+ spy.assert_not_called()
+
+ def test_wall_verbatim_end_to_end_with_smoothing_on(self):
+ engine = StructuredEngine()
+ contour = _load_dat(MW166)
+ spline_data = _spline_data_for(contour)
+ prepared = core.contour_array(spline_data)
+ for method in ('laplacian', 'elliptic', 'angle_based'):
+ for topology in ('c', 'o'):
+ with self.subTest(method=method, topology=topology):
+ settings = core.StructuredMeshSettings(
+ topology=topology, smoother=method,
+ smoother_iterations=8, normal_divisions=30,
+ wake_points=30)
+ named = engine.build_blocks(
+ spline_data=spline_data, settings=settings)
+ wall = np.asarray(named[0][1].getULines()[0])
+ found = any(
+ np.allclose(wall[s:s + len(prepared)], prepared,
+ atol=1e-12)
+ for s in range(len(wall) - len(prepared) + 1))
+ self.assertTrue(found)
+
+
+class StructuredDispatchTests(unittest.TestCase):
+ def test_makemesh_dispatches_structured(self):
+ import Meshing
+ with mock.patch.object(Meshing, 'get_main_window',
+ return_value=None):
+ tunnel = Meshing.Windtunnel()
+ spline_data = _spline_data_for(_sharp_contour())
+ airfoil = mock.Mock()
+ airfoil.spline_data = spline_data
+ airfoil.has_spline = True
+ airfoil.name = 'naca0012'
+ airfoil.mesh_blocks = None
+ settings = mock.Mock(spec=Meshing.WindtunnelMeshSettings)
+ settings.engine = 'structured'
+ settings.structured = core.StructuredMeshSettings(
+ normal_divisions=25, wake_points=25)
+ with mock.patch.object(tunnel, '_finalizeMeshGeneration',
+ return_value=True) as finalize, \
+ mock.patch.object(tunnel, 'MeshQuality') as quality:
+ result = tunnel.makeMesh(settings, airfoil=airfoil)
+ self.assertTrue(result)
+ finalize.assert_called_once()
+ quality.assert_called_once_with(crit='k2inf')
+ self.assertTrue(tunnel.blocks)
+ self.assertEqual(tunnel.tunnel_height,
+ settings.structured.tunnel_height)
+
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/tests/test_structured_gui.py b/tests/test_structured_gui.py
new file mode 100644
index 0000000..7d56c1d
--- /dev/null
+++ b/tests/test_structured_gui.py
@@ -0,0 +1,106 @@
+import os
+import sys
+from pathlib import Path
+from types import SimpleNamespace
+
+os.environ.setdefault('QT_QPA_PLATFORM', 'offscreen')
+
+PROJECT_ROOT = Path(__file__).resolve().parents[1]
+SRC_ROOT = PROJECT_ROOT / 'src'
+if str(SRC_ROOT) not in sys.path:
+ sys.path.insert(0, str(SRC_ROOT))
+
+from PySide6 import QtWidgets
+
+import ToolboxPagesMeshing
+
+
+def _toolbox_with_group():
+ app = QtWidgets.QApplication.instance() or QtWidgets.QApplication([])
+ assert app is not None
+ toolbox = SimpleNamespace()
+ toolbox.structured_group = \
+ ToolboxPagesMeshing._build_structured_group(toolbox)
+ return toolbox
+
+
+def test_structured_group_defaults_map_to_settings():
+ toolbox = _toolbox_with_group()
+ settings = ToolboxPagesMeshing.structured_settings_from_toolbox(toolbox)
+ assert settings.topology == 'c'
+ assert settings.tunnel_shape == 'legacy'
+ assert settings.tunnel_height == 3.5
+ assert settings.tfi_variant == 'standard'
+ assert settings.ortho_layers == 0
+ assert settings.boundary_control.distribution == 'uniform'
+ assert settings.boundary_control.angle_mode == 'free'
+
+
+def test_structured_group_selection_roundtrip():
+ toolbox = _toolbox_with_group()
+ toolbox.structured_topology.setCurrentIndex(1) # O-grid
+ toolbox.structured_tunnel_shape.setCurrentIndex(1) # circular
+ toolbox.structured_algorithm.setCurrentIndex(1) # TFI Hermite
+ toolbox.structured_ortho_layers.setValue(8)
+ settings = ToolboxPagesMeshing.structured_settings_from_toolbox(toolbox)
+ assert settings.topology == 'o'
+ assert settings.tunnel_shape == 'circular'
+ assert settings.algorithm == 'tfi'
+ assert settings.tfi_variant == 'hermite'
+ assert settings.ortho_layers == 8
+
+
+def test_structured_algorithm_defaults_to_tfi():
+ toolbox = _toolbox_with_group()
+ settings = ToolboxPagesMeshing.structured_settings_from_toolbox(toolbox)
+ assert settings.algorithm == 'tfi'
+ assert settings.elliptic_iterations == 150
+
+
+def test_structured_elliptic_selection():
+ toolbox = _toolbox_with_group()
+ index = toolbox.structured_algorithm.findData('elliptic:standard')
+ assert index >= 0
+ toolbox.structured_algorithm.setCurrentIndex(index)
+ toolbox.structured_elliptic_iterations.setValue(80)
+ settings = ToolboxPagesMeshing.structured_settings_from_toolbox(toolbox)
+ assert settings.algorithm == 'elliptic'
+ assert settings.tfi_variant == 'standard'
+ assert settings.elliptic_iterations == 80
+
+
+def test_structured_hyperbolic_selection():
+ toolbox = _toolbox_with_group()
+ index = toolbox.structured_algorithm.findData('hyperbolic:standard')
+ assert index >= 0
+ toolbox.structured_algorithm.setCurrentIndex(index)
+ toolbox.structured_hyperbolic_fraction_cap.setValue(0.35)
+ settings = ToolboxPagesMeshing.structured_settings_from_toolbox(toolbox)
+ assert settings.algorithm == 'hyperbolic'
+ assert settings.tfi_variant == 'standard'
+ assert abs(settings.hyperbolic_fraction_cap - 0.35) < 1e-9
+
+
+def test_structured_hyperbolic_defaults():
+ toolbox = _toolbox_with_group()
+ settings = ToolboxPagesMeshing.structured_settings_from_toolbox(toolbox)
+ assert abs(settings.hyperbolic_fraction_cap - 0.5) < 1e-9
+ assert abs(settings.hyperbolic_smoothing - 1.0) < 1e-9
+
+
+def test_structured_smoother_defaults_to_none():
+ toolbox = _toolbox_with_group()
+ settings = ToolboxPagesMeshing.structured_settings_from_toolbox(toolbox)
+ assert settings.smoother == 'none'
+ assert settings.smoother_iterations == 10
+
+
+def test_structured_smoother_selection_roundtrip():
+ toolbox = _toolbox_with_group()
+ index = toolbox.structured_smoother.findData('angle_based')
+ assert index >= 0
+ toolbox.structured_smoother.setCurrentIndex(index)
+ toolbox.structured_smoother_iterations.setValue(25)
+ settings = ToolboxPagesMeshing.structured_settings_from_toolbox(toolbox)
+ assert settings.smoother == 'angle_based'
+ assert settings.smoother_iterations == 25
diff --git a/tests/test_structured_topologies.py b/tests/test_structured_topologies.py
new file mode 100644
index 0000000..d8ce4c1
--- /dev/null
+++ b/tests/test_structured_topologies.py
@@ -0,0 +1,123 @@
+import sys
+from pathlib import Path
+
+import numpy as np
+
+PROJECT_ROOT = Path(__file__).resolve().parents[1]
+SRC_ROOT = PROJECT_ROOT / 'src'
+if str(SRC_ROOT) not in sys.path:
+ sys.path.insert(0, str(SRC_ROOT))
+
+import StructuredCore as core
+import StructuredTopologies as topo
+
+
+def _load_dat(path):
+ xs, ys = [], []
+ for line in Path(path).read_text().splitlines():
+ parts = line.split()
+ if len(parts) != 2:
+ continue
+ try:
+ xs.append(float(parts[0]))
+ ys.append(float(parts[1]))
+ except ValueError:
+ continue
+ return np.column_stack((np.array(xs), np.array(ys)))
+
+
+NACA0012 = Path(__file__).resolve().parent / 'data' / 'naca0012.dat'
+
+
+def _sharp_contour():
+ contour = _load_dat(NACA0012)
+ te = 0.5 * (contour[0] + contour[-1])
+ contour[0] = te # force exactly sharp, symmetrically
+ contour[-1] = te
+ return contour
+
+
+def _blunt_contour(thickness=0.005, blend=0.6):
+ contour = _load_dat(NACA0012)
+ x, y = contour[:, 0], contour[:, 1].copy()
+ ramp = np.clip((x - (1.0 - blend)) / blend, 0.0, 1.0) ** 2
+ le_index = int(np.argmin(x))
+ y[:le_index] += 0.5 * thickness * ramp[:le_index] # upper surface
+ y[le_index:] -= 0.5 * thickness * ramp[le_index:] # lower surface
+ return np.column_stack((x, y))
+
+
+def _contour_embedded_in_wall(wall, contour):
+ """True if contour appears as a contiguous verbatim slice of wall."""
+ n = len(contour)
+ for start in range(len(wall) - n + 1):
+ if np.allclose(wall[start:start + n], contour, atol=1e-12):
+ return True
+ return False
+
+
+def test_c_sharp_single_frame_wall_verbatim():
+ settings = core.StructuredMeshSettings(topology='c')
+ frames = topo.build_frames(_sharp_contour(), settings)
+ assert len(frames) == 1
+ frame = frames[0]
+ assert frame.te_type == 'sharp'
+ assert frame.metadata.get('wake_cut_matched') is True
+ assert _contour_embedded_in_wall(frame.wall, _sharp_contour())
+ assert len(frame.outer) == len(frame.wall)
+ assert len(frame.side_start) == settings.normal_divisions + 1
+
+
+def test_c_blunt_two_frames_shared_cuts_and_base():
+ settings = core.StructuredMeshSettings(topology='c')
+ contour = _blunt_contour()
+ frames = topo.build_frames(contour, settings)
+ assert len(frames) == 2
+ main, strip = frames
+ assert main.te_type == 'blunt'
+ assert strip.kind == 'wake_strip'
+ assert _contour_embedded_in_wall(main.wall, contour)
+ base = strip.metadata['base']
+ assert np.allclose(base[0], contour[-1])
+ assert np.allclose(base[-1], contour[0])
+ # strip boundaries are the same wake-cut nodes bounding the main wall
+ wake_points = settings.wake_points
+ lower_cut_in_main = main.wall[-wake_points:]
+ upper_cut_in_main = main.wall[:wake_points][::-1]
+ assert np.allclose(strip.wall, lower_cut_in_main)
+ assert np.allclose(strip.outer, upper_cut_in_main)
+
+
+def test_o_blunt_closed_loop_with_base():
+ settings = core.StructuredMeshSettings(topology='o',
+ tunnel_shape='circular')
+ contour = _blunt_contour()
+ frames = topo.build_frames(contour, settings)
+ assert len(frames) == 1
+ frame = frames[0]
+ assert frame.periodic is True
+ assert np.allclose(frame.wall[0], frame.wall[-1])
+ assert _contour_embedded_in_wall(frame.wall, contour)
+
+
+def test_o_sharp_both_tunnels():
+ contour = _sharp_contour()
+ for shape in ('legacy', 'circular'):
+ settings = core.StructuredMeshSettings(topology='o',
+ tunnel_shape=shape)
+ frame = topo.build_frames(contour, settings)[0]
+ assert np.allclose(frame.wall[0], frame.wall[-1])
+ assert len(frame.outer) == len(frame.wall)
+
+
+def test_circular_c_wake_too_long_raises():
+ settings = core.StructuredMeshSettings(
+ topology='c', tunnel_shape='circular',
+ tunnel_height=2.0, wake_length=7.0)
+ try:
+ topo.build_frames(_sharp_contour(), settings)
+ except ValueError as error:
+ assert 'farfield' in str(error).lower() or \
+ 'wake' in str(error).lower()
+ else:
+ raise AssertionError('Expected ValueError for wake past farfield.')
diff --git a/tests/test_window_settings.py b/tests/test_window_settings.py
new file mode 100644
index 0000000..2d8bd31
--- /dev/null
+++ b/tests/test_window_settings.py
@@ -0,0 +1,41 @@
+import sys
+import unittest
+from pathlib import Path
+
+
+PROJECT_ROOT = Path(__file__).resolve().parents[1]
+SRC_ROOT = PROJECT_ROOT / 'src'
+if str(SRC_ROOT) not in sys.path:
+ sys.path.insert(0, str(SRC_ROOT))
+
+import Settings
+
+
+class WindowSettingsTests(unittest.TestCase):
+ def test_parse_window_geometry_accepts_four_integers(self):
+ self.assertEqual(
+ Settings.parse_window_geometry('120, 80, 1800, 1200'),
+ (120, 80, 1800, 1200),
+ )
+
+ def test_parse_window_geometry_rejects_invalid_length(self):
+ with self.assertRaisesRegex(ValueError, 'exactly four integers'):
+ Settings.parse_window_geometry('120, 80, 1800')
+
+ def test_parse_window_geometry_rejects_non_positive_size(self):
+ with self.assertRaisesRegex(ValueError, 'must be positive'):
+ Settings.parse_window_geometry('120, 80, 0, 1200')
+
+ def test_normalize_window_startup_mode_accepts_preset(self):
+ self.assertEqual(
+ Settings.normalize_window_startup_mode('Preset_2'),
+ 'preset_2',
+ )
+
+ def test_normalize_window_startup_mode_rejects_unknown_mode(self):
+ with self.assertRaisesRegex(ValueError, 'must be one of'):
+ Settings.normalize_window_startup_mode('fullscreen')
+
+
+if __name__ == '__main__':
+ unittest.main()