Examples

Start with the smallest public examples first. They show the intended import style and the current user-facing entry points.

Airfoil From Designation

This example creates a NACA 2412 airfoil from a designation and samples a few surface coordinates.

Run it with:

uv run python examples/airfoil/create_naca4_from_designation.py
"""Create and inspect a NACA 4-digit airfoil from a designation."""

import numpy as np

import buffalo_wings.airfoil as bwa


def main() -> None:
    """Create a NACA 2412 airfoil and print basic geometry data."""
    airfoil = bwa.AirfoilFactory.naca4_from_designation("2412")
    t = np.linspace(-1.0, 1.0, 9, dtype=np.float64)
    x, y = airfoil.xy(t)
    leading_edge = airfoil.leading_edge()
    trailing_edge = airfoil.trailing_edge()

    print("Airfoil: NACA 2412")
    print(f"Chord: {airfoil.chord():.6f}")
    print(
        "Leading edge:",
        f"({leading_edge[0]:.6f}, {leading_edge[1]:.6f})",
    )
    print(
        "Trailing edge:",
        f"({trailing_edge[0]:.6f}, {trailing_edge[1]:.6f})",
    )
    print("Sampled surface coordinates:")
    for ti, xi, yi in zip(t, x, y, strict=True):
        print(f"  t={ti: .2f} -> x={xi:.6f}, y={yi:.6f}")


if __name__ == "__main__":
    main()

Expected output:

Airfoil: NACA 2412
Chord: 1.000000
Leading edge: (0.000000, 0.000000)
Trailing edge: (1.000000, 0.000000)
Sampled surface coordinates:
  t=-1.00 -> x=0.999916, y=-0.001257
  t=-0.75 -> x=0.561623, y=-0.030053
  t=-0.50 -> x=0.252226, y=-0.042183
  t=-0.25 -> x=0.065781, y=-0.033127
  t= 0.00 -> x=0.000000, y=0.000000
  t= 0.25 -> x=0.059219, y=0.044650
  t= 0.50 -> x=0.247774, y=0.076558
  t= 0.75 -> x=0.563377, y=0.067119
  t= 1.00 -> x=1.000084, y=0.001257

Airfoil From Spec

This example creates the same airfoil from a public schema object and passes it through AirfoilFactory.from_spec(...).

Run it with:

uv run python examples/airfoil/create_naca4_from_spec.py
"""Create and inspect a NACA 4-digit airfoil from a public spec."""

import numpy as np

import buffalo_wings.airfoil as bwa


def main() -> None:
    """Create a NACA 2412 airfoil from a public schema object."""
    spec = bwa.Naca4AirfoilSpec(designation="2412")
    airfoil = bwa.AirfoilFactory.from_spec(spec)
    t = np.linspace(-1.0, 1.0, 5, dtype=np.float64)
    x, y = airfoil.xy(t)

    print("Spec:")
    print(spec)
    print(f"Surface length: {airfoil.surface_length:.6f}")
    print("Sampled coordinates from AirfoilFactory.from_spec:")
    for ti, xi, yi in zip(t, x, y, strict=True):
        print(f"  t={ti: .2f} -> x={xi:.6f}, y={yi:.6f}")


if __name__ == "__main__":
    main()

Expected output:

Spec:
Naca4AirfoilSpec(type='naca4', designation='2412', params=None)
Surface length: 2.041402
Sampled coordinates from AirfoilFactory.from_spec:
  t=-1.00 -> x=0.999916, y=-0.001257
  t=-0.50 -> x=0.252226, y=-0.042183
  t= 0.00 -> x=0.000000, y=0.000000
  t= 0.50 -> x=0.247774, y=0.076558
  t= 1.00 -> x=1.000084, y=0.001257

Rectangular Wing

This example builds a simple one-panel rectangular wing through the public wing schema and evaluates one span station and section.

Run it with:

uv run python examples/wing/create_rectangular_wing.py
"""Create and inspect a simple rectangular wing from a public spec."""

import numpy as np

import buffalo_wings.airfoil as bwa
import buffalo_wings.wing as bww


def main() -> None:
    """Build a one-panel rectangular wing and print sampled outputs."""
    zero_offset = bww.PiecewiseLinearDistribution(
        data=[(0.0, 0.0), (1.0, 0.0)],
    )
    constant_chord = bww.PiecewiseLinearDistribution(
        data=[(0.0, 1.5), (1.0, 1.5)],
    )
    zero_twist = bww.PiecewiseLinearDistribution(
        data=[(0.0, 0.0), (1.0, 0.0)],
    )
    wing = bww.WingDefinitionSpec(
        symmetry="mirror_y",
        half_span=5.0,
        reference_axis="quarter_chord",
        twist_axis="quarter_chord",
        panels=[
            bww.PanelSpec(
                id="P0",
                eta_range=(0.0, 1.0),
                ref_line=bww.RefLineSpec(
                    x_ref=zero_offset,
                    z_ref=zero_offset,
                ),
                chord=constant_chord,
                twist=zero_twist,
                airfoil=bww.SingleAirfoilRef(name="root_section"),
            )
        ],
    )
    spec = bww.WingSpec(
        schema_version=2,
        units=bww.UnitsSpec(length="m", angle="deg"),
        wing=wing,
        airfoils={
            "root_section": bwa.Naca4AirfoilSpec(designation="0012"),
        },
    )
    canonical = bww.WingCanonical.from_spec(spec)

    station = canonical.evaluate_panel("P0", 0.5)
    chordwise_samples = np.linspace(0.0, 1.0, 5, dtype=np.float64)
    section = canonical.section_curves(
        "P0",
        eta=0.5,
        chordwise_samples=chordwise_samples,
    )
    span_stations = canonical.sample_span_stations("P0", 5, spacing="uniform")

    print("Rectangular wing example")
    print(f"Panel: {station.panel_id}")
    print(f"Half span: {spec.wing.half_span:.3f} {spec.units.length}")
    print(f"Station eta: {station.eta:.3f}")
    print(f"Station y: {station.y:.3f} {spec.units.length}")
    print(f"Station chord: {station.chord:.3f} {spec.units.length}")
    print(f"Station twist: {station.twist_rad:.6f} rad")
    print("Uniform eta stations:", span_stations)
    print("Upper surface sample points at eta=0.5:")
    for point in section.upper_curve:
        print(f"  ({point[0]:.6f}, {point[1]:.6f}, {point[2]:.6f})")


if __name__ == "__main__":
    main()

Expected output:

Rectangular wing example
Panel: P0
Half span: 5.000 m
Station eta: 0.500
Station y: 2.500 m
Station chord: 1.500 m
Station twist: 0.000000 rad
Uniform eta stations: [0.   0.25 0.5  0.75 1.  ]
Upper surface sample points at eta=0.5:
  (-0.375000, 2.500000, 0.000000)
  (0.000000, 2.500000, 0.089119)
  (0.375000, 2.500000, 0.079410)
  (0.750000, 2.500000, 0.047405)
  (1.125000, 2.500000, 0.001890)

Wing Schema GUI Prototype

This example is a refactored scaffold for a future desktop GUI application. It demonstrates a schema edit, validation, undo/redo, debounced rebuild, and PyVista viewport loop in a compact prototype script. Before running it, install optional example dependencies:

uv sync --extra examples

Run it with:

uv run python examples/wing/wing_schema_gui_prototype.py

On Linux, this prototype defaults to xcb for better Qt/VTK stability. If you need to override it, use --qt-platform xcb or --qt-platform wayland. You can also use --no-viewport to run the schema editor without PyVista.

"""Prototype wing schema GUI starter for future desktop application work.

This example is intentionally a scaffold, not production application code.
It preserves the core edit-validate-rebuild-render loop while keeping the
implementation small enough to evolve into a dedicated app package.
"""

# pyright: reportUnknownVariableType=false
# pyright: reportUnknownArgumentType=false
# pyright: reportUnknownMemberType=false
# pyright: reportUnknownLambdaType=false

from __future__ import annotations

import argparse
import importlib
import math
import os
import sys
from collections.abc import Callable
from dataclasses import dataclass
from typing import Any

import numpy as np
import numpy.typing as npt
from pydantic import BaseModel, Field

_FloatArray = npt.NDArray[np.float64]
_IntArray = npt.NDArray[np.int64]


class _WingStation(BaseModel):
    """One spanwise station in a simple wing model."""

    name: str = "ST"
    eta: float = Field(ge=0.0, le=1.0)
    chord: float = Field(gt=0.0)
    twist_deg: float = 0.0


class _WingPlanform(BaseModel):
    """Simple planform controls used by this prototype."""

    span: float = Field(gt=0.0)
    sweep_deg: float = 0.0
    dihedral_deg: float = 0.0


class _WingModel(BaseModel):
    """Top-level wing model for this prototype."""

    planform: _WingPlanform
    sections: list[_WingStation]


class _AppSchema(BaseModel):
    """Schema root for this prototype."""

    wing: _WingModel


@dataclass(frozen=True)
class _DerivedGeometry:
    """Derived preview geometry for rendering."""

    station_lines: tuple[_FloatArray, ...]
    points: _FloatArray
    faces: _IntArray


@dataclass
class _GuiContext:
    """Mutable GUI runtime state used by this scaffold."""

    window: Any
    qt_widgets: Any
    qt_core: Any
    qt_gui: Any
    pyvista: Any | None
    viewport: Any | None
    schema: _AppSchema
    undo_stack: list[_AppSchema]
    redo_stack: list[_AppSchema]
    status_label: Any
    rebuild_timer: Any
    rebuild_delay_ms: int


def _default_schema() -> _AppSchema:
    """Return a default schema used by the GUI."""
    return _AppSchema(
        wing=_WingModel(
            planform=_WingPlanform(
                span=10.0,
                sweep_deg=20.0,
                dihedral_deg=5.0,
            ),
            sections=[
                _WingStation(name="Root", eta=0.0, chord=2.0, twist_deg=2.0),
                _WingStation(name="Mid", eta=0.5, chord=1.4, twist_deg=0.0),
                _WingStation(name="Tip", eta=1.0, chord=0.8, twist_deg=-2.0),
            ],
        ),
    )


def _build_preview_geometry(schema: _AppSchema) -> _DerivedGeometry:
    """Build a coarse preview mesh from schema values."""
    wing = schema.wing
    sections = sorted(wing.sections, key=lambda section: section.eta)

    half_span = 0.5 * wing.planform.span
    sweep_rad = math.radians(wing.planform.sweep_deg)
    dihedral_rad = math.radians(wing.planform.dihedral_deg)

    line_list: list[_FloatArray] = []
    endpoints: list[tuple[_FloatArray, _FloatArray]] = []

    for section in sections:
        y_coord = half_span * section.eta
        x_le = y_coord * math.tan(sweep_rad)
        z_coord = y_coord * math.tan(dihedral_rad)
        leading_edge = np.array([x_le, y_coord, z_coord], dtype=np.float64)
        trailing_edge = np.array(
            [x_le + section.chord, y_coord, z_coord],
            dtype=np.float64,
        )
        line_list.append(np.vstack((leading_edge, trailing_edge)))
        endpoints.append((leading_edge, trailing_edge))

    points_list: list[_FloatArray] = []
    for leading_edge, trailing_edge in endpoints:
        points_list.append(leading_edge)
        points_list.append(trailing_edge)
    points = np.asarray(points_list, dtype=np.float64)

    faces_list: list[list[int]] = []
    for index in range(len(sections) - 1):
        le0 = 2 * index
        te0 = le0 + 1
        le1 = 2 * (index + 1)
        te1 = le1 + 1
        faces_list.append([3, le0, te0, te1])
        faces_list.append([3, le0, te1, le1])
    faces = np.asarray(faces_list, dtype=np.int64).ravel()

    return _DerivedGeometry(
        station_lines=tuple(line_list),
        points=points,
        faces=faces,
    )


def _load_optional_gui_module(module_name: str) -> Any:
    """Import one optional GUI module by name."""
    try:
        return importlib.import_module(module_name)
    except ModuleNotFoundError as exc:
        message = (
            "This GUI example needs optional dependencies.\n"
            "Install extras: uv sync --extra examples\n"
            "Or run once with extras: uv run --extra examples "
            "python examples/wing/wing_schema_gui_prototype.py\n"
            f"Missing module: {module_name}"
        )
        raise RuntimeError(message) from exc


def _configure_linux_qt_environment(platform_override: str | None) -> None:
    """Set conservative Qt/GL defaults for Linux desktop stability."""
    if not sys.platform.startswith("linux"):
        return
    if platform_override is not None:
        os.environ["QT_QPA_PLATFORM"] = platform_override
    if "QT_QPA_PLATFORM" not in os.environ:
        # Prefer xcb for this prototype because Qt+VTK/pyvistaqt is more
        # reliable there than Wayland on some Linux desktops.
        os.environ["QT_QPA_PLATFORM"] = "xcb"
    os.environ.setdefault("QT_XCB_GL_INTEGRATION", "none")
    os.environ.setdefault("LIBGL_ALWAYS_SOFTWARE", "1")


def _render(ctx: _GuiContext) -> None:
    """Render the current preview geometry in the viewport."""
    if ctx.pyvista is None or ctx.viewport is None:
        ctx.status_label.setText("Preview rebuilt (viewport disabled)")
        return
    geom = _build_preview_geometry(ctx.schema)
    mesh = ctx.pyvista.PolyData(geom.points, geom.faces)
    ctx.viewport.clear()
    ctx.viewport.add_mesh(mesh, opacity=0.55)
    for line in geom.station_lines:
        line_poly = ctx.pyvista.lines_from_points(line)
        ctx.viewport.add_mesh(line_poly, line_width=3)
    ctx.viewport.add_axes()
    ctx.viewport.reset_camera()
    ctx.status_label.setText("Preview rebuilt")


def _schedule_rebuild(ctx: _GuiContext) -> None:
    """Start a debounced preview rebuild timer."""
    ctx.rebuild_timer.start(ctx.rebuild_delay_ms)


def _apply_update(
    ctx: _GuiContext,
    mutator: Callable[[_AppSchema], None],
) -> None:
    """Apply one schema update with undo tracking and validation."""
    ctx.undo_stack.append(ctx.schema.model_copy(deep=True))
    ctx.redo_stack.clear()
    updated = ctx.schema.model_copy(deep=True)
    mutator(updated)
    ctx.schema = _AppSchema.model_validate(updated.model_dump())
    ctx.status_label.setText("Schema changed (debounced rebuild)...")
    _schedule_rebuild(ctx)


def _make_planform_dock(ctx: _GuiContext) -> Any:
    """Build the planform controls dock."""
    dock = ctx.qt_widgets.QDockWidget("Planform", ctx.window)
    panel = ctx.qt_widgets.QWidget()
    form = ctx.qt_widgets.QFormLayout(panel)

    span_box = ctx.qt_widgets.QDoubleSpinBox()
    span_box.setRange(0.01, 1e6)
    span_box.setDecimals(4)
    span_box.setValue(ctx.schema.wing.planform.span)
    span_box.valueChanged.connect(
        lambda value: _apply_update(
            ctx,
            lambda model: setattr(model.wing.planform, "span", float(value)),
        )
    )
    form.addRow("Span", span_box)

    sweep_box = ctx.qt_widgets.QDoubleSpinBox()
    sweep_box.setRange(-89.0, 89.0)
    sweep_box.setDecimals(3)
    sweep_box.setValue(ctx.schema.wing.planform.sweep_deg)
    sweep_box.valueChanged.connect(
        lambda value: _apply_update(
            ctx,
            lambda model: setattr(
                model.wing.planform,
                "sweep_deg",
                float(value),
            ),
        )
    )
    form.addRow("Sweep (deg)", sweep_box)

    dihedral_box = ctx.qt_widgets.QDoubleSpinBox()
    dihedral_box.setRange(-45.0, 45.0)
    dihedral_box.setDecimals(3)
    dihedral_box.setValue(ctx.schema.wing.planform.dihedral_deg)
    dihedral_box.valueChanged.connect(
        lambda value: _apply_update(
            ctx,
            lambda model: setattr(
                model.wing.planform,
                "dihedral_deg",
                float(value),
            ),
        )
    )
    form.addRow("Dihedral (deg)", dihedral_box)

    dock.setWidget(panel)
    return dock


def _make_stations_dock(ctx: _GuiContext) -> Any:
    """Build the stations controls dock."""
    dock = ctx.qt_widgets.QDockWidget("Stations", ctx.window)
    scroll = ctx.qt_widgets.QScrollArea()
    body = ctx.qt_widgets.QWidget()
    layout = ctx.qt_widgets.QVBoxLayout(body)

    for index, section in enumerate(ctx.schema.wing.sections):
        group = ctx.qt_widgets.QGroupBox(f"Station {index}: {section.name}")
        form = ctx.qt_widgets.QFormLayout(group)

        eta_box = ctx.qt_widgets.QDoubleSpinBox()
        eta_box.setRange(0.0, 1.0)
        eta_box.setDecimals(4)
        eta_box.setSingleStep(0.01)
        eta_box.setValue(section.eta)
        eta_box.valueChanged.connect(
            lambda value, i=index: _apply_update(
                ctx,
                lambda model: setattr(
                    model.wing.sections[i],
                    "eta",
                    float(value),
                ),
            )
        )
        form.addRow("Eta", eta_box)

        chord_box = ctx.qt_widgets.QDoubleSpinBox()
        chord_box.setRange(0.001, 1e6)
        chord_box.setDecimals(4)
        chord_box.setValue(section.chord)
        chord_box.valueChanged.connect(
            lambda value, i=index: _apply_update(
                ctx,
                lambda model: setattr(
                    model.wing.sections[i],
                    "chord",
                    float(value),
                ),
            )
        )
        form.addRow("Chord", chord_box)

        twist_box = ctx.qt_widgets.QDoubleSpinBox()
        twist_box.setRange(-90.0, 90.0)
        twist_box.setDecimals(3)
        twist_box.setValue(section.twist_deg)
        twist_box.valueChanged.connect(
            lambda value, i=index: _apply_update(
                ctx,
                lambda model: setattr(
                    model.wing.sections[i],
                    "twist_deg",
                    float(value),
                ),
            )
        )
        form.addRow("Twist (deg)", twist_box)
        layout.addWidget(group)

    layout.addStretch(1)
    scroll.setWidget(body)
    scroll.setWidgetResizable(True)
    dock.setWidget(scroll)
    return dock


def _undo(ctx: _GuiContext) -> None:
    """Restore one schema snapshot from undo history."""
    if not ctx.undo_stack:
        return
    ctx.redo_stack.append(ctx.schema.model_copy(deep=True))
    ctx.schema = ctx.undo_stack.pop()
    ctx.status_label.setText("Undo")
    _schedule_rebuild(ctx)


def _redo(ctx: _GuiContext) -> None:
    """Restore one schema snapshot from redo history."""
    if not ctx.redo_stack:
        return
    ctx.undo_stack.append(ctx.schema.model_copy(deep=True))
    ctx.schema = ctx.redo_stack.pop()
    ctx.status_label.setText("Redo")
    _schedule_rebuild(ctx)


def _make_status_dock(ctx: _GuiContext) -> Any:
    """Build the bottom status dock."""
    status_dock = ctx.qt_widgets.QDockWidget("Messages", ctx.window)
    status_panel = ctx.qt_widgets.QWidget()
    status_layout = ctx.qt_widgets.QHBoxLayout(status_panel)
    status_layout.addWidget(ctx.status_label)
    status_layout.addStretch(1)
    rebuild_button = ctx.qt_widgets.QPushButton("Rebuild Now")
    rebuild_button.clicked.connect(lambda: _render(ctx))
    status_layout.addWidget(rebuild_button)
    status_dock.setWidget(status_panel)
    return status_dock


def _wire_menu(ctx: _GuiContext) -> None:
    """Create menu actions for undo and redo."""
    edit_menu = ctx.window.menuBar().addMenu("&Edit")
    undo_action = ctx.qt_gui.QAction("Undo", ctx.window)
    undo_action.triggered.connect(lambda: _undo(ctx))
    edit_menu.addAction(undo_action)
    redo_action = ctx.qt_gui.QAction("Redo", ctx.window)
    redo_action.triggered.connect(lambda: _redo(ctx))
    edit_menu.addAction(redo_action)


def _parse_args() -> argparse.Namespace:
    """Parse command-line options for this prototype."""
    parser = argparse.ArgumentParser(
        description="Wing schema GUI prototype scaffold.",
    )
    parser.add_argument(
        "--no-viewport",
        action="store_true",
        help="Run GUI controls without initializing PyVista/VTK viewport.",
    )
    parser.add_argument(
        "--qt-platform",
        choices=("wayland", "xcb"),
        default=None,
        help="Override QT_QPA_PLATFORM for backend troubleshooting.",
    )
    return parser.parse_args()


def main() -> None:
    """Run the wing schema GUI prototype."""
    args = _parse_args()
    _configure_linux_qt_environment(args.qt_platform)
    qt_widgets = _load_optional_gui_module("PySide6.QtWidgets")
    qt_core = _load_optional_gui_module("PySide6.QtCore")
    qt_gui = _load_optional_gui_module("PySide6.QtGui")
    pyvista: Any | None = None
    pyvistaqt: Any | None = None
    if not args.no_viewport:
        pyvista = _load_optional_gui_module("pyvista")
        pyvistaqt = _load_optional_gui_module("pyvistaqt")

    qt_core.QCoreApplication.setAttribute(qt_core.Qt.AA_ShareOpenGLContexts)
    if hasattr(qt_core.Qt, "AA_UseSoftwareOpenGL"):
        qt_core.QCoreApplication.setAttribute(qt_core.Qt.AA_UseSoftwareOpenGL)

    app = qt_widgets.QApplication(sys.argv)
    window = qt_widgets.QMainWindow()
    window.setWindowTitle("Wing Schema GUI Prototype")
    window.resize(1280, 840)

    central = qt_widgets.QWidget(window)
    central_layout = qt_widgets.QVBoxLayout(central)
    central_layout.setContentsMargins(0, 0, 0, 0)
    viewport: Any | None
    if pyvistaqt is None:
        viewport = None
        placeholder = qt_widgets.QLabel(
            "Viewport disabled.\n"
            "Run without --no-viewport after Qt/VTK backend is stable.",
        )
        placeholder.setAlignment(qt_core.Qt.AlignCenter)
        central_layout.addWidget(placeholder)
    else:
        viewport = pyvistaqt.QtInteractor(central)
        central_layout.addWidget(viewport)
    window.setCentralWidget(central)
    ctx = _GuiContext(
        window=window,
        qt_widgets=qt_widgets,
        qt_core=qt_core,
        qt_gui=qt_gui,
        pyvista=pyvista,
        viewport=viewport,
        schema=_default_schema(),
        undo_stack=[],
        redo_stack=[],
        status_label=qt_widgets.QLabel("Ready"),
        rebuild_timer=qt_core.QTimer(window),
        rebuild_delay_ms=250,
    )
    ctx.rebuild_timer.setSingleShot(True)
    ctx.rebuild_timer.timeout.connect(lambda: _render(ctx))

    _wire_menu(ctx)
    window.addDockWidget(
        qt_core.Qt.LeftDockWidgetArea,
        _make_planform_dock(ctx),
    )
    window.addDockWidget(
        qt_core.Qt.RightDockWidgetArea,
        _make_stations_dock(ctx),
    )
    window.addDockWidget(
        qt_core.Qt.BottomDockWidgetArea,
        _make_status_dock(ctx),
    )

    window.show()
    # Render only after the native window is realized to avoid
    # X11/VTK BadWindow errors on some Linux setups.
    qt_core.QTimer.singleShot(0, lambda: _render(ctx))
    sys.exit(app.exec())


if __name__ == "__main__":
    main()