FoundationPartsSheet MetalWing Rib Attachment

Wing Rib Attachment

A minimal integration demo combining a guide-curve Loft (the wing skin), a regular Extrusion (the spar) and a sheet-metal L-bracket attaching the rib to the spar. This is the smallest example that exercises both the new surface ops and the sheet-metal feature tree side-by-side in a single Part.

The full variable-NACA wing assembly lives in the user-side cadbuildr_projects/wing/ — NACA polynomial generation is a project-level concern and isn’t part of foundation itself.

"""End-to-end wing assembly demo for the surface + sheet-metal release.

The wing is an ``Assembly`` of seven independent ``Part`` instances that
mirror real aircraft construction:
- 1 *upper* skin panel: ``Thicken(SurfaceLoft(shapes=[CustomOpenShape NACA top
  curves], guides=[LE Spline3D]), 1.5)`` — a thin offset shell
- 1 *lower* skin panel: same thing on the bottom curves
- 3 sheet-metal ribs (root, mid, tip): ``SheetMetalBaseFlange`` on a
  NACA-spline ``CustomClosedShape``
- 2 solid spars (front, rear): ``Extrusion`` of a rectangular profile

The skins are *open* surfaces lifted from ``CustomOpenShape`` chains (NACA
top-surface points only, not the full closed airfoil) and thickened into
thin panels. This is the realistic decomposition: a real wing's upper and
lower skins are separate sheets riveted to the spars, not a single closed
volume.

This is structured as an Assembly rather than a single multi-operation Part
on purpose. ``PartRoot.toReplicad`` boolean-fuses every ``add_operation``
into the previous shape; the wing's ribs and spars share coincident faces
with the skins (rib plane == skin section plane; spars thread through the
skin surface) and OpenCascade's BRepAlgoAPI_Fuse silently produces a null
shape on coincident faces. Keeping each component a separate Part avoids
the boolean entirely — they coexist in the assembly tree with their own
identities, the way a real wing is manufactured.
"""

import math

from cadbuildr.foundation import (
    Assembly,
    Part,
    Sketch,
    Point,
    Point3D,
    Rectangle,
    Spline,
    Spline3D,
    CustomClosedShape,
    CustomOpenShape,
    Extrusion,
    SurfaceLoft,
    Thicken,
    PlaneFactory,
    show,
)
from cadbuildr.foundation.sheet_metal import base_flange, to_solid


SPAN = 320.0
CHORD_ROOT = 160.0
CHORD_TIP = 90.0  # moderate taper (1.78:1) — visually balanced ribs
N_SECTIONS = 6
N_RIBS = 3
SKIN_THICKNESS = 1.2

# The tip rib AND the last skin section share this airfoil-sizing t so the
# rib is flush with the skin at the wing tip — neither pokes out of the
# other, and the wing tapers smoothly without a bulge. We use the
# second-to-last skin section's natural t (not the n-1-rib's t=0.5) so the
# taper from t=0.6 → 0.8 → 1.0 stays monotonic.
_TIP_T_SIZE = (N_SECTIONS - 2) / (N_SECTIONS - 1)
_TIP_CHORD = CHORD_ROOT - (CHORD_ROOT - CHORD_TIP) * _TIP_T_SIZE**1.6
# Spar chordwise positions referenced to the (now slightly larger) tip
# chord. Front spar ~25% chord, rear ~70% chord measured from LE — both
# translated to the chord-centered frame.
_FRONT_SPAR_X = -_TIP_CHORD * (0.5 - 0.25)
_REAR_SPAR_X = _TIP_CHORD * (0.70 - 0.5)


def _chord_at(t):
    return CHORD_ROOT - (CHORD_ROOT - CHORD_TIP) * t**1.6


def _thk_at(t):
    return 0.18 - 0.10 * (1 - math.cos(math.pi * t)) / 2


def _camber_at(t):
    return 0.04 * (1 - t) ** 2


def _camber_pos_at(t):
    return 0.4 + 0.1 * t


def _twist_at(t):
    return -5.0 * t**2


def _naca_points(
    sketch,
    chord,
    thickness_ratio,
    camber_ratio=0.0,
    camber_pos=0.4,
    twist_deg=0.0,
    n_points=64,
    surface=None,
):
    """Sample NACA-4 surface points.

    ``surface`` selects which side(s):
    - ``"upper"``: TE → LE on the top surface (n_points/2 samples, LE-first
      ordering reversed to TE-first so panels align with the LE guide).
    - ``"lower"``: LE → TE on the bottom surface.
    - ``None`` (default): full closed loop (top TE→LE → bottom LE→TE).

    Cosine x-spacing concentrates samples at LE/TE where curvature is
    highest.
    """
    cos_t = math.cos(math.radians(twist_deg))
    sin_t = math.sin(math.radians(twist_deg))

    def naca_pt(x_norm, surface_sign):
        x_norm = max(0.0, min(1.0, x_norm))
        y_t = 5 * thickness_ratio * (
            0.2969 * math.sqrt(x_norm)
            - 0.1260 * x_norm
            - 0.3516 * x_norm**2
            + 0.2843 * x_norm**3
            - 0.1015 * x_norm**4
        )
        if camber_ratio > 0 and 0 < camber_pos < 1:
            if x_norm < camber_pos:
                y_c = (camber_ratio / camber_pos**2
                       * (2 * camber_pos * x_norm - x_norm**2))
                dy_c = 2 * camber_ratio / camber_pos**2 * (camber_pos - x_norm)
            else:
                y_c = (camber_ratio / (1 - camber_pos) ** 2
                       * ((1 - 2 * camber_pos) + 2 * camber_pos * x_norm - x_norm**2))
                dy_c = 2 * camber_ratio / (1 - camber_pos) ** 2 * (camber_pos - x_norm)
            theta = math.atan(dy_c)
        else:
            y_c, theta = 0.0, 0.0
        x = x_norm - surface_sign * y_t * math.sin(theta)
        y = y_c + surface_sign * y_t * math.cos(theta)
        x_world = (x - 0.5) * chord
        y_world = y * chord
        return Point(sketch, x_world * cos_t - y_world * sin_t,
                     x_world * sin_t + y_world * cos_t)

    half = max(16, n_points // 2)
    # Top surface: TE (x=1) → LE (x=0)
    upper = [naca_pt(0.5 * (1 - math.cos(math.pi - i * math.pi / (half - 1))), +1)
             for i in range(half)]
    # Bottom surface: LE (x=0) → TE (x=1)
    lower = [naca_pt(0.5 * (1 - math.cos(i * math.pi / (half - 1))), -1)
             for i in range(half)]

    if surface == "upper":
        return upper
    if surface == "lower":
        return lower
    return upper + lower


def _naca4_airfoil(sketch, chord, thickness_ratio, camber_ratio=0.0,
                   camber_pos=0.4, twist_deg=0.0, n_points=64):
    """Full closed NACA-4 airfoil as a single-Spline ``CustomClosedShape``."""
    pts = _naca_points(sketch, chord, thickness_ratio, camber_ratio,
                       camber_pos, twist_deg, n_points)
    return CustomClosedShape([Spline(pts)])


def _naca4_open_surface(sketch, chord, thickness_ratio, camber_ratio,
                        camber_pos, twist_deg, surface, n_points=64):
    """One side (top or bottom) of a NACA-4 as an open ``CustomOpenShape``.

    Used as a SurfaceLoft section — open sections produce open shells,
    which Thicken offsets into thin solid skin panels.
    """
    pts = _naca_points(sketch, chord, thickness_ratio, camber_ratio,
                       camber_pos, twist_deg, n_points, surface=surface)
    return CustomOpenShape([Spline(pts)])


class WingSkinPanel(Part):
    """One half of the wing skin (upper or lower) as a thickened open shell.

    ``surface`` is ``"upper"`` or ``"lower"``. Each section is an open
    ``CustomOpenShape`` chain along just one side of the NACA profile;
    SurfaceLoft over those open sections returns an open Shell, and
    Thicken offsets that shell by ``SKIN_THICKNESS`` into a thin solid panel.
    """

    def __init__(self, surface: str):
        pf = PlaneFactory()
        sections = []
        # Tip skin section + tip rib share ``_TIP_T_SIZE`` so they agree at
        # the wing tip — see module docstring near _TIP_T_SIZE.
        for i in range(N_SECTIONS):
            t = i / (N_SECTIONS - 1)
            t_size = _TIP_T_SIZE if i == N_SECTIONS - 1 else t
            plane = self.xy() if i == 0 else pf.get_parallel_plane(self.xy(), t * SPAN)
            sketch = Sketch(plane)
            sections.append(_naca4_open_surface(
                sketch,
                chord=_chord_at(t_size),
                thickness_ratio=_thk_at(t_size),
                camber_ratio=_camber_at(t_size),
                camber_pos=_camber_pos_at(t_size),
                twist_deg=_twist_at(t_size),
                surface=surface,
            ))

        def _le_t_size(i):
            return _TIP_T_SIZE if i == N_SECTIONS - 1 else i / (N_SECTIONS - 1)

        # LE sweep uses ``_le_t_size`` for *every* term so the LE freezes at
        # the t=_TIP_T_SIZE position when the airfoil does, keeping the last
        # span segment uniform (no bulge). z still uses the natural i-spacing
        # so the wing physically extends to z=SPAN.
        le_pts = [
            Point3D(
                -_chord_at(_le_t_size(i)) / 2 - 35 * (_le_t_size(i)) ** 1.4,
                18 * (_le_t_size(i) - (_le_t_size(i)) ** 3 / 3),
                (i / (N_SECTIONS - 1)) * SPAN,
            )
            for i in range(N_SECTIONS)
        ]
        leading_edge = Spline3D(points=le_pts)
        shell = SurfaceLoft(shapes=sections, guides=[leading_edge])
        # ``both_sides=False`` offsets the panel by the full thickness on
        # one side; the resulting solid is a thin skin sitting flush with
        # the sampled NACA surface.
        self.add_operation(Thicken(surface=shell,
                                   thickness=SKIN_THICKNESS,
                                   both_sides=False))


class WingRib(Part):
    """One sheet-metal rib at spanwise position ``t_pos``.

    ``t_size`` independently controls the airfoil sizing — useful when the
    last rib should reuse the penultimate rib's profile so the tip rib
    isn't a scrawny version of the heavily tapered tip airfoil.
    """

    def __init__(self, t_pos: float, t_size: float | None = None):
        if t_size is None:
            t_size = t_pos
        pf = PlaneFactory()
        plane = (
            self.xy()
            if t_pos == 0
            else pf.get_parallel_plane(self.xy(), t_pos * SPAN)
        )
        sketch = Sketch(plane)
        # Small uniform inset (3%) so the rib has skin clearance on every side
        # without disappearing into the airfoil curvature.
        inset = 0.97
        profile = _naca4_airfoil(
            sketch,
            chord=_chord_at(t_size) * inset,
            thickness_ratio=_thk_at(t_size) * inset,
            camber_ratio=_camber_at(t_size),
            camber_pos=_camber_pos_at(t_size),
            twist_deg=_twist_at(t_size),
        )
        rib_body = base_flange(profile=profile, sketch=sketch, thickness=2)
        self.add_operation(to_solid(rib_body))


class WingSpar(Part):
    """One rectangular spar threading the span at chordwise offset ``x``."""

    def __init__(self, x: float, width: float, height: float):
        pf = PlaneFactory()
        plane = pf.get_parallel_plane(self.xy(), -8)
        sketch = Sketch(plane)
        profile = Rectangle.from_center_and_sides(Point(sketch, x, 0), width, height)
        self.add_operation(Extrusion(profile, SPAN + 16))


class WingRibAttachment(Assembly):
    """Wing assembly: 2 skin panels + 3 ribs + 2 spars as independent components."""

    def __init__(self):
        upper = WingSkinPanel("upper")
        upper.paint("light_blue")
        self.add_component(upper)

        lower = WingSkinPanel("lower")
        lower.paint("light_blue")
        self.add_component(lower)

        for j in range(N_RIBS):
            t = j / (N_RIBS - 1)
            # Tip rib shares ``_TIP_T_SIZE`` with the tip skin section so
            # they're flush at the wing tip — see _TIP_T_SIZE near the top.
            t_size = _TIP_T_SIZE if j == N_RIBS - 1 else t
            rib = WingRib(t, t_size=t_size)
            rib.paint("orange")
            self.add_component(rib)

        # Spar cross-sections sized to fit inside the tapered tip airfoil
        # (chord ~90mm, t/c ~0.08, so ~7mm thick; thinner at the spar's
        # chordwise positions). Heights stay below half the local thickness
        # so neither spar pokes through the upper or lower skin surface.
        front = WingSpar(x=_FRONT_SPAR_X, width=4, height=3)
        front.paint("dark_grey")
        self.add_component(front)

        rear = WingSpar(x=_REAR_SPAR_X, width=2, height=2)
        rear.paint("dark_grey")
        self.add_component(rear)


show(WingRibAttachment())