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())