Hyperspace Nine

Two intermeshed hot/cold toroid networks of tubing sit at the apex or drain of the hourglass funnels, directing the hot and cold ejection ports perpendicular to the funneled action where their corresponding temperatures are recycled, re-temperatured, and brought back to their corresponding funnel of same temperature. The choice between one mixed exit port, or two being hot/cold pends further simulation results. Obviously, choosing to spin the vortex as to further increase its potency is considered, and probably correct.

hdglhourglassanimation

#!/usr/bin/env python3
"""
===============================================================================
HDGL HOURGLASS — FULL INTEGRATED ASSEMBLY
===============================================================================

Three concentric shells, each a complete Tesla-valve hourglass:
  - hot branch climbs +z, cold branch descends -z
  - both branches spiral inward toward the shared toroid plane at z=0
  - the toroid void IS the apex — flow enters from the funnel coils,
    circulates through the intermeshed toroid tubes, exits 90° radially

Per shell (3 shells):
  - 3 threads (multi-start) x 2 branches (hot/cold) = 6 funnel spirals
  - each spiral is num_cells Tesla-valve segments (forward + mirrored alternating)
  - coil amplitude tapered by the drain factor so it never goes negative

Per shell, the toroid:
  - R_major = drained spine radius at the funnel rim (emerges from the geometry)
  - r_minor = A_eff at the rim (emerges from the geometry)
  - n_turns = 6 (3 hot entries + 3 cold entries, alternating every 60 deg)
  - TUBE geometry — swept square cross-section, not centerlines
  - hot coil and cold coil phase-offset so their outer points interleave exactly
    and every funnel thread lands on an outer point of its own coil

Exit pipes (3 per branch per shell = 9 total per branch):
  - anchored at the 3 outer points NOT used for funnel entry
  - run radially outward (perpendicular to z axis)
  - tube radius = channel_width / 2

Coil amplitude taper — derived, not invented:
  A_eff = min(A_base + shell_idx*4, drained_spine)
  This is just "the coil cannot exceed the space available to it."
  It emerges from the geometry rather than a separate constant.

Run inside FreeCAD's Python console to build. Run standalone for preview.
===============================================================================
"""

from pathlib import Path
import math

import numpy as np
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
from mpl_toolkits.mplot3d import Axes3D

try:
    import FreeCAD as App
    import Part
    HAVE_FREECAD = True
except ImportError:
    HAVE_FREECAD = False


# ============================================================================
# HDGL SUBSTRATE
# ============================================================================

def T(x):
    return 1.0 + 1.0 / x

def emergent_omega(seed=1.5, tol=1e-15, max_iter=1000):
    x = seed
    for _ in range(max_iter):
        y = T(x)
        if abs(y - x) < tol:
            return y
        x = y
    return x

OMEGA = emergent_omega()

# ============================================================================
# PHYSICAL / MANUFACTURING CONSTANTS
# ============================================================================

L             = 32.0     # local Tesla-valve segment length
A_base        = 12.0     # coil amplitude at shell 0
k             = 2.6      # Tesla-valve re-injection sharpness
num_cells     = 6        # segments from toroid to outer rim, per branch
channel_width = 3.8
pitch_base    = 40.0     # vertical climb per unit of turns

num_shells         = 3
threads_per_shell  = 3
TOTAL_L            = num_cells * L
APEX_EPS           = 1e-9
tube_radius        = channel_width / 2.0   # coil tube wall
pipe_length_scale  = 1.5                   # exit pipe length = R_major * scale

# ============================================================================
# FUNNEL GEOMETRY — shared by both branches
# ============================================================================

def funnel_drained_spine(progress, shell_idx):
    shell_rate = OMEGA ** (shell_idx + 1)
    turns = progress * OMEGA
    spine_r = max(shell_rate * turns, APEX_EPS)
    return spine_r / T(spine_r)

def funnel_A_eff(shell_idx, drained):
    A = A_base + shell_idx * 4
    return min(A, drained)

def map_funnel(vec, theta_offset, shell_idx, branch):
    """
    vec.x = distance from the toroid plane (0 = toroid, TOTAL_L = outer rim)
    branch = +1 (hot, +z) or -1 (cold, -z)
    Coil amplitude tapered so it never exceeds the available spine radius.
    """
    progress = (vec.x / TOTAL_L) * num_cells
    turns    = progress * OMEGA

    binary_mod  = math.sin(math.pi * progress)
    trinary_mod = math.sin(2.0 * math.pi * progress / 3.0)
    phase_mod   = 0.075 * binary_mod + 0.050 * trinary_mod

    angle = 2.0 * math.pi * turns * branch + theta_offset + phase_mod * branch

    drained  = funnel_drained_spine(progress, shell_idx)
    A_eff    = funnel_A_eff(shell_idx, drained)
    r        = max(drained + vec.y * (A_eff / max(A_base + shell_idx * 4, APEX_EPS)), APEX_EPS)
    z        = pitch_base * turns * branch + vec.z

    return (r * math.cos(angle), r * math.sin(angle), z)

# ============================================================================
# TOROID GEOMETRY — sized from the funnel rim
# ============================================================================

def toroid_params(shell_idx):
    """
    R_major and r_minor emerge from the funnel rim geometry.
    n_turns=6 so 3 hot entry + 3 cold entry outer-points fit with 30-deg gaps.
    phi_offset_hot/cold rotate the toroid so funnel threads land on outer points.
    """
    drained = funnel_drained_spine(num_cells, shell_idx)
    A_eff   = funnel_A_eff(shell_idx, drained)
    R_major = drained
    r_minor = A_eff
    n_turns = 6

    turns_rim   = num_cells * OMEGA
    phi_h = math.radians((math.degrees(2 * math.pi * turns_rim) % 60))
    phi_c = math.radians((math.degrees(-2 * math.pi * turns_rim) % 60 + (60 / n_turns / 2)) % 60)

    return R_major, r_minor, n_turns, phi_h, phi_c

def toroid_centerline(phi, R_major, r_minor, n_turns, phi_offset):
    theta = n_turns * (phi - phi_offset)
    r = R_major + r_minor * math.cos(theta)
    return (r * math.cos(phi), r * math.sin(phi), r_minor * math.sin(theta))

def toroid_outer_points(R_major, r_minor, n_turns, phi_offset):
    """phi angles where the coil sits on its outer surface (theta=0 mod 2pi)."""
    return [(phi_offset + 2 * math.pi * m / n_turns) % (2 * math.pi)
            for m in range(n_turns)]

# ============================================================================
# FREECAD GEOMETRY PRIMITIVES
# ============================================================================

if HAVE_FREECAD:
    def make_tri_face(p1, p2, p3):
        return Part.Face(Part.makePolygon([
            App.Vector(*p1), App.Vector(*p2), App.Vector(*p3), App.Vector(*p1)]))

    def sweep_tube(centerline_fn, n_pts, half):
        """Sweep a square tube (half=half-width) along a centerline function."""
        paths = [[], [], [], []]
        for i in range(n_pts):
            t = i / (n_pts - 1)
            cx, cy, cz = centerline_fn(t)
            rl = math.hypot(cx, cy)
            rdx, rdy = (cx / rl, cy / rl) if rl > 1e-9 else (1.0, 0.0)
            for j, (ro, zo) in enumerate([(-half,-half),(half,-half),(half,half),(-half,half)]):
                paths[j].append((cx + rdx*ro, cy + rdy*ro, cz + zo))
        faces = []
        for j in range(4):
            pc, pn = paths[j], paths[(j+1)%4]
            for i in range(len(pc)-1):
                a,b,c,d = pc[i],pc[i+1],pn[i],pn[i+1]
                faces += [make_tri_face(a,b,d), make_tri_face(a,d,c)]
        sc = [p[0] for p in paths]; ec = [p[-1] for p in paths]
        faces += [make_tri_face(sc[0],sc[1],sc[2]), make_tri_face(sc[0],sc[2],sc[3])]
        faces += [make_tri_face(ec[0],ec[1],ec[2]), make_tri_face(ec[0],ec[2],ec[3])]
        return Part.Solid(Part.makeShell(faces))

    def create_funnel_segment(x_offset, local_mirror, theta_off, shell_idx, branch):
        m = -1 if local_mirror else 1
        A = A_base + shell_idx * 4
        c_height = channel_width
        half = channel_width / 2.0
        num_pts = 30

        def centerline(t):
            lx = (L + 1.5) * t + x_offset - 0.75
            drained = funnel_drained_spine((lx / TOTAL_L) * num_cells, shell_idx)
            A_eff   = funnel_A_eff(shell_idx, drained)
            shape   = m * math.sin(math.pi * t) * math.exp(-k * t) * (1 - t) ** 1.2
            ly = shape * A_eff
            return map_funnel(
                type('V', (), {'x': lx, 'y': ly, 'z': 0})(),
                theta_off, shell_idx, branch)

        return sweep_tube(centerline, num_pts, half)

    def create_toroid_tube_freecad(shell_idx, branch):
        R_major, r_minor, n_turns, phi_h, phi_c = toroid_params(shell_idx)
        phi_offset = phi_h if branch > 0 else phi_c
        num_pts = n_turns * 24 + 1

        def centerline(t):
            phi = 2 * math.pi * t
            return toroid_centerline(phi, R_major, r_minor, n_turns, phi_offset)

        return sweep_tube(centerline, num_pts, tube_radius)

    def create_exit_pipe_freecad(shell_idx, branch, phi_exit):
        R_major, r_minor, n_turns, phi_h, phi_c = toroid_params(shell_idx)
        phi_offset = phi_h if branch > 0 else phi_c
        anchor = toroid_centerline(phi_exit, R_major, r_minor, n_turns, phi_offset)
        direction = App.Vector(math.cos(phi_exit), math.sin(phi_exit), 0.0)
        base = App.Vector(*anchor)
        return Part.makeCylinder(tube_radius, R_major * pipe_length_scale, base, direction)

# ============================================================================
# BUILD
# ============================================================================

def build_full_assembly():
    doc = App.activeDocument() or App.newDocument("HDGL_Hourglass_Full")

    for shell_idx in range(num_shells):
        R_major, r_minor, n_turns, phi_h, phi_c = toroid_params(shell_idx)
        print(f"\nShell {shell_idx}: R_major={R_major:.2f}  r_minor={r_minor:.2f}  void={R_major-r_minor:.2f}")

        for branch, label, color, phi_off in [
            (+1.0, "Hot", (0.85,0.25,0.10), phi_h),
            (-1.0, "Cold", (0.15,0.45,0.90), phi_c),
        ]:
            voids = []

            # Funnel segments — full length, all cells
            for t_idx in range(threads_per_shell):
                theta_off = (2 * math.pi / threads_per_shell) * t_idx
                for n in range(num_cells):
                    voids.append(create_funnel_segment(n*L, False, theta_off, shell_idx, branch))
                    voids.append(create_funnel_segment(n*L + L/2, True, theta_off, shell_idx, branch))

            print(f"  {label}: fusing {len(voids)} funnel segments...")
            fluid = voids[0]
            for v in voids[1:]:
                fluid = fluid.fuse(v)

            # Toroid tube
            print(f"  {label}: building toroid tube...")
            trd = create_toroid_tube_freecad(shell_idx, branch)
            fluid = fluid.fuse(trd)

            # Exit pipes — outer points NOT used for funnel entry
            all_outer = toroid_outer_points(R_major, r_minor, n_turns, phi_off)
            turns_rim = num_cells * OMEGA
            arrival_angles = set()
            for t_idx in range(threads_per_shell):
                theta_off = (2 * math.pi / threads_per_shell) * t_idx
                arr = (2 * math.pi * turns_rim * branch + theta_off) % (2 * math.pi)
                arrival_angles.add(round(arr, 2))

            exit_phis = [p for p in all_outer
                         if not any(abs((p - a + math.pi) % (2*math.pi) - math.pi) < 0.05
                                    for a in arrival_angles)]

            print(f"  {label}: adding {len(exit_phis)} exit pipes...")
            for phi_e in exit_phis:
                pipe = create_exit_pipe_freecad(shell_idx, branch, phi_e)
                fluid = fluid.fuse(pipe)

            obj = doc.addObject("Part::Feature", f"Shell{shell_idx}_{label}")
            obj.Shape = fluid
            if App.GuiUp:
                obj.ViewObject.ShapeColor = color
                obj.ViewObject.Transparency = 30

    doc.recompute()
    print("\nFull hourglass assembly complete.")

# ============================================================================
# PREVIEW — centerlines + toroid tubes, full hourglass
# ============================================================================

def render_full_preview():
    OUT_DIR = (Path(__file__).resolve().parent if "__file__" in globals() else Path.cwd()) / "hdgl_graphs"
    OUT_DIR.mkdir(parents=True, exist_ok=True)
    paths_out = []

    N_FUNNEL  = 800
    N_TOROID  = 600

    # ---- 3D hourglass view ----
    fig = plt.figure(figsize=(14, 16))
    ax  = fig.add_subplot(111, projection="3d")

    shell_hot_cmaps  = [plt.cm.autumn, plt.cm.Reds,   plt.cm.YlOrRd]
    shell_cold_cmaps = [plt.cm.winter, plt.cm.Blues,  plt.cm.GnBu  ]

    for shell_idx in range(num_shells):
        R_major, r_minor, n_turns, phi_h, phi_c = toroid_params(shell_idx)
        A = A_base + shell_idx * 4

        for branch, label, cmaps, phi_off in [
            (+1.0, "hot", shell_hot_cmaps,  phi_h),
            (-1.0, "cold", shell_cold_cmaps, phi_c),
        ]:
            cmap = cmaps[shell_idx]
            color = cmap(0.65)

            # Funnel centerlines with coil wiggle
            for t_idx in range(threads_per_shell):
                theta_off = (2 * math.pi / threads_per_shell) * t_idx
                for n in range(num_cells):
                    for local_mirror, x_off in [(False, n*L), (True, n*L+L/2)]:
                        m = -1 if local_mirror else 1
                        xs, ys, zs = [], [], []
                        for i in range(N_FUNNEL // (num_cells * 2)):
                            t = i / max(N_FUNNEL // (num_cells * 2) - 1, 1)
                            lx = (L + 1.5) * t + x_off - 0.75
                            drained  = funnel_drained_spine((lx/TOTAL_L)*num_cells, shell_idx)
                            A_eff    = funnel_A_eff(shell_idx, drained)
                            shape    = m * math.sin(math.pi*t) * math.exp(-k*t) * (1-t)**1.2
                            ly = shape * A_eff
                            p = map_funnel(
                                type('V',(),{'x':lx,'y':ly,'z':0})(),
                                theta_off, shell_idx, branch)
                            xs.append(p[0]); ys.append(p[1]); zs.append(p[2])
                        ax.plot(xs, ys, zs, color=color, linewidth=0.9, alpha=0.75)

            # Toroid centerline tube (represented as a thick line here)
            xs, ys, zs = [], [], []
            for i in range(N_TOROID):
                phi = 2 * math.pi * i / (N_TOROID - 1)
                cx, cy, cz = toroid_centerline(phi, R_major, r_minor, n_turns, phi_off)
                xs.append(cx); ys.append(cy); zs.append(cz)
            ax.plot(xs, ys, zs,
                    color='tab:red' if branch > 0 else 'tab:blue',
                    linewidth=2.5, alpha=0.9,
                    label=f"shell{shell_idx} {label} toroid" if t_idx == 0 else None)

            # Exit pipes
            all_outer = toroid_outer_points(R_major, r_minor, n_turns, phi_off)
            turns_rim = num_cells * OMEGA
            arrivals  = set()
            for ti in range(threads_per_shell):
                toff = (2*math.pi/threads_per_shell)*ti
                arr  = (2*math.pi*turns_rim*branch + toff) % (2*math.pi)
                arrivals.add(round(arr, 2))

            exit_phis = [p for p in all_outer
                         if not any(abs((p-a+math.pi)%(2*math.pi)-math.pi) < 0.05
                                    for a in arrivals)]
            for phi_e in exit_phis:
                anchor = toroid_centerline(phi_e, R_major, r_minor, n_turns, phi_off)
                dx, dy = math.cos(phi_e), math.sin(phi_e)
                ex = anchor[0] + dx * R_major * pipe_length_scale
                ey = anchor[1] + dy * R_major * pipe_length_scale
                ax.plot([anchor[0], ex], [anchor[1], ey], [anchor[2], anchor[2]],
                        color='tab:red' if branch > 0 else 'tab:blue',
                        linewidth=2.5, linestyle='--', alpha=0.9)

    ax.set_title("HDGL Full Hourglass Assembly\nhot = warm tones  cold = cool tones  dashed = exit pipes",
                 fontsize=12)
    ax.set_xlabel("x"); ax.set_ylabel("y"); ax.set_zlabel("z")
    fig.tight_layout()
    p1 = OUT_DIR / "hdgl_full_hourglass_3d.png"
    fig.savefig(p1, dpi=160, bbox_inches="tight")
    plt.close(fig)
    paths_out.append(p1)

    # ---- Side profile: shows the hourglass silhouette ----
    fig2, ax2 = plt.subplots(figsize=(8, 14))
    for shell_idx in range(num_shells):
        R_major, r_minor, n_turns, phi_h, phi_c = toroid_params(shell_idx)
        for branch, cname in [(+1.0,'tab:red'), (-1.0,'tab:blue')]:
            phi_off = phi_h if branch > 0 else phi_c
            for t_idx in range(threads_per_shell):
                theta_off = (2*math.pi/threads_per_shell)*t_idx
                for n in range(num_cells):
                    for local_mirror, x_off in [(False,n*L),(True,n*L+L/2)]:
                        m = -1 if local_mirror else 1
                        rs, zs = [], []
                        for i in range(40):
                            t = i/39
                            lx = (L+1.5)*t+x_off-0.75
                            drained = funnel_drained_spine((lx/TOTAL_L)*num_cells, shell_idx)
                            A_eff   = funnel_A_eff(shell_idx, drained)
                            shape   = m*math.sin(math.pi*t)*math.exp(-k*t)*(1-t)**1.2
                            ly = shape*A_eff
                            px,py,pz = map_funnel(type('V',(),{'x':lx,'y':ly,'z':0})(),
                                                  theta_off, shell_idx, branch)
                            rs.append(math.hypot(px,py)); zs.append(pz)
                        ax2.plot(rs, zs, color=cname, linewidth=0.7, alpha=0.5)
                        ax2.plot([-r for r in rs], zs, color=cname, linewidth=0.7, alpha=0.5)

            # toroid silhouette
            for i in range(N_TOROID):
                pass  # already drawn in the 3d view; side view gets funnel only for clarity

        ax2.axhline(0, color='gold', linewidth=1.2, linestyle='--', label='toroid plane (z=0)')

    ax2.set_aspect('equal', adjustable='box')
    ax2.set_title("Hourglass side profile\nred=hot (+z)   blue=cold (−z)   toroid plane at z=0")
    ax2.set_xlabel("radius (mirrored for silhouette)")
    ax2.set_ylabel("z")
    ax2.legend()
    fig2.tight_layout()
    p2 = OUT_DIR / "hdgl_full_hourglass_side.png"
    fig2.savefig(p2, dpi=160, bbox_inches="tight")
    plt.close(fig2)
    paths_out.append(p2)

    # ---- Top-down: shows toroid intermeshing ----
    fig3, ax3 = plt.subplots(figsize=(12, 12))
    for shell_idx in range(num_shells):
        R_major, r_minor, n_turns, phi_h, phi_c = toroid_params(shell_idx)
        for branch, cname, phi_off in [
            (+1.0,'tab:red',phi_h), (-1.0,'tab:blue',phi_c)]:
            xs, ys = [], []
            for i in range(N_TOROID):
                phi = 2*math.pi*i/(N_TOROID-1)
                cx,cy,cz = toroid_centerline(phi, R_major, r_minor, n_turns, phi_off)
                xs.append(cx); ys.append(cy)
            ax3.plot(xs, ys, color=cname, linewidth=1.2, alpha=0.85)

            all_outer = toroid_outer_points(R_major, r_minor, n_turns, phi_off)
            turns_rim = num_cells*OMEGA
            arrivals  = set()
            for ti in range(threads_per_shell):
                toff=(2*math.pi/threads_per_shell)*ti
                arr=(2*math.pi*turns_rim*branch+toff)%(2*math.pi)
                arrivals.add(round(arr,2))
            exit_phis = [p for p in all_outer
                         if not any(abs((p-a+math.pi)%(2*math.pi)-math.pi)<0.05 for a in arrivals)]
            for phi_e in exit_phis:
                anchor = toroid_centerline(phi_e, R_major, r_minor, n_turns, phi_off)
                dx,dy  = math.cos(phi_e), math.sin(phi_e)
                ex     = anchor[0]+dx*R_major*pipe_length_scale
                ey     = anchor[1]+dy*R_major*pipe_length_scale
                ax3.plot([anchor[0],ex],[anchor[1],ey],
                         color=cname, linewidth=2.5, linestyle='--', alpha=0.9)

    ax3.set_aspect('equal', adjustable='box')
    ax3.set_title("Top-down: intermeshed toroids (3 shells)\nred=hot coils   blue=cold coils   dashed=exit pipes")
    ax3.set_xlabel("x"); ax3.set_ylabel("y")
    fig3.tight_layout()
    p3 = OUT_DIR / "hdgl_full_hourglass_topdown.png"
    fig3.savefig(p3, dpi=160, bbox_inches="tight")
    plt.close(fig3)
    paths_out.append(p3)

    return paths_out

# ============================================================================
# ANIMATION — particles riding real geometry, toroid included
# ============================================================================

def render_animation_frames():
    OUT_DIR = (Path(__file__).resolve().parent if "__file__" in globals() else Path.cwd()) / "hdgl_graphs"
    OUT_DIR.mkdir(parents=True, exist_ok=True)

    N_FRAMES = 90
    N_PARTICLES_PER_THREAD = 4

    # Build particle paths: funnel then toroid then exit pipe
    particle_paths = []
    for shell_idx in range(num_shells):
        R_major, r_minor, n_turns, phi_h, phi_c = toroid_params(shell_idx)
        for branch, cname, phi_off in [(+1.0,'tomato',''), (-1.0,'royalblue','')]:
            phi_off = phi_h if branch > 0 else phi_c
            for t_idx in range(threads_per_shell):
                theta_off = (2*math.pi/threads_per_shell)*t_idx

                # funnel path (outer rim -> toroid plane)
                funnel_pts = []
                for n in range(num_cells-1, -1, -1):  # outer to inner
                    for local_mirror, x_off in [(True, n*L+L/2), (False, n*L)]:
                        m = -1 if local_mirror else 1
                        for i in range(20):
                            t = (19-i)/19
                            lx = (L+1.5)*t+x_off-0.75
                            drained = funnel_drained_spine((lx/TOTAL_L)*num_cells, shell_idx)
                            A_eff   = funnel_A_eff(shell_idx, drained)
                            shape   = m*math.sin(math.pi*t)*math.exp(-k*t)*(1-t)**1.2
                            ly = shape*A_eff
                            px,py,pz = map_funnel(type('V',(),{'x':lx,'y':ly,'z':0})(),
                                                  theta_off, shell_idx, branch)
                            funnel_pts.append((px,py,pz))

                # toroid path (full loop starting from funnel arrival angle)
                turns_rim = num_cells*OMEGA
                arr_phi = (2*math.pi*turns_rim*branch + theta_off) % (2*math.pi)
                toroid_pts = []
                for i in range(60):
                    phi = (arr_phi + 2*math.pi*i/59) % (2*math.pi)
                    toroid_pts.append(toroid_centerline(phi, R_major, r_minor, n_turns, phi_off))

                # pick the exit pipe closest to 180 deg from entry
                all_outer = toroid_outer_points(R_major, r_minor, n_turns, phi_off)
                arrivals  = set()
                for ti in range(threads_per_shell):
                    toff=(2*math.pi/threads_per_shell)*ti
                    arr=(2*math.pi*turns_rim*branch+toff)%(2*math.pi)
                    arrivals.add(round(arr,2))
                exit_phis = [p for p in all_outer
                             if not any(abs((p-a+math.pi)%(2*math.pi)-math.pi)<0.05 for a in arrivals)]
                target_exit = (arr_phi + math.pi) % (2*math.pi)
                phi_e = min(exit_phis, key=lambda p: abs((p-target_exit+math.pi)%(2*math.pi)-math.pi))
                exit_anchor = toroid_centerline(phi_e, R_major, r_minor, n_turns, phi_off)

                # exit pipe path
                dx,dy = math.cos(phi_e), math.sin(phi_e)
                pipe_pts = [(exit_anchor[0]+dx*R_major*pipe_length_scale*s/19,
                             exit_anchor[1]+dy*R_major*pipe_length_scale*s/19,
                             exit_anchor[2]) for s in range(20)]

                full_path = funnel_pts + toroid_pts + pipe_pts
                particle_paths.append((full_path, cname, shell_idx))

    # Render frames
    fig = plt.figure(figsize=(10, 12), facecolor='#0d0d12')
    ax  = fig.add_subplot(111, projection='3d', facecolor='#0d0d12')

    all_pts = [p for path,c,s in particle_paths for p in path]
    xs_all  = [p[0] for p in all_pts]
    ys_all  = [p[1] for p in all_pts]
    zs_all  = [p[2] for p in all_pts]

    frame_paths = []
    for frame_i in range(N_FRAMES):
        ax.cla()
        ax.set_facecolor('#0d0d12')
        ax.set_xlim(min(xs_all), max(xs_all))
        ax.set_ylim(min(ys_all), max(ys_all))
        ax.set_zlim(min(zs_all), max(zs_all))
        ax.set_axis_off()
        ax.set_title("HDGL Hourglass — hot/cold riding real geometry",
                     color='white', fontsize=11, pad=4)

        phase = frame_i / N_FRAMES

        for path, cname, shell_idx in particle_paths:
            n = len(path)
            for pp in range(N_PARTICLES_PER_THREAD):
                frac = (phase + pp/N_PARTICLES_PER_THREAD) % 1.0
                idx  = int(frac * n)
                x,y,z = path[idx]

                # tail
                tail_len = max(n//12, 3)
                tail_idxs = [(idx - j) % n for j in range(tail_len)]
                tx = [path[ti][0] for ti in tail_idxs]
                ty = [path[ti][1] for ti in tail_idxs]
                tz = [path[ti][2] for ti in tail_idxs]
                alphas = np.linspace(0.7, 0.0, tail_len)
                for seg in range(len(tx)-1):
                    ax.plot(tx[seg:seg+2], ty[seg:seg+2], tz[seg:seg+2],
                            color=cname, linewidth=1.2, alpha=float(alphas[seg]))
                ax.scatter([x],[y],[z], color=cname, s=18, alpha=0.95, depthshade=False)

        frame_path = OUT_DIR / f"anim_{frame_i:03d}.png"
        fig.savefig(frame_path, dpi=110, bbox_inches='tight',
                    facecolor='#0d0d12', edgecolor='none')
        frame_paths.append(frame_path)
        if frame_i % 10 == 0:
            print(f"  frame {frame_i}/{N_FRAMES}")

    plt.close(fig)
    return frame_paths

# ============================================================================
# MAIN
# ============================================================================

if __name__ == "__main__":
    print(f"OMEGA = {OMEGA:.15f}")
    print()

    print("Rendering static previews...")
    preview_paths = render_full_preview()
    for p in preview_paths:
        print(f"  [OK] {p}")

    print()
    print("Rendering animation frames...")
    frame_paths = render_animation_frames()
    print(f"  {len(frame_paths)} frames saved")

    # stitch to GIF if imageio available
    try:
        import imageio
        gif_path = Path(frame_paths[0]).parent / "hdgl_hourglass_animation.gif"
        with imageio.get_writer(gif_path, mode='I', fps=24, loop=0) as writer:
            for fp in frame_paths:
                writer.append_data(imageio.imread(fp))
        print(f"  [GIF] {gif_path}")
    except ImportError:
        print("  (imageio not available — frames saved as PNGs, stitch manually)")

    if HAVE_FREECAD:
        print()
        print("Building FreeCAD solid...")
        build_full_assembly()
    else:
        print()
        print("FreeCAD not available — previews and animation only.")
        print("Run inside FreeCAD's Python console to build the solid.")


hdglhourglassanimation (1)