diff --git a/main.nf b/main.nf index f689482..d686546 100644 --- a/main.nf +++ b/main.nf @@ -26,6 +26,9 @@ params.radiation_model = "plane_wave" // plane_wave, flanged_piston, unflanged_ params.num_bands = 8 // Number of parallel jobs for the solver params.outdir = "./results" +// Directivity (opt-in, single mode only, requires BEM) +params.directivity = false + // Auto mode settings params.target_f_low = 500 params.target_f_high = 4000 @@ -177,6 +180,92 @@ process generate_dashboard { """ } +process render_horn_3d { + publishDir "${params.outdir}", mode: 'copy' + + input: + val throat_radius + val mouth_radius + val length + val profile + + output: + path "horn_3d.png" + + script: + """ + python3 -m horn_analysis.horn_render \ + horn_3d.png \ + --throat-radius ${throat_radius} \ + --mouth-radius ${mouth_radius} \ + --length ${length} \ + --profile ${profile} + """ +} + +process render_auto_horn_3d { + publishDir "${params.outdir}/auto", mode: 'copy' + + input: + val profile + + output: + path "horn_3d_${profile}.png" + + script: + """ + python3 -m horn_analysis.horn_render \ + horn_3d_${profile}.png \ + --throat-radius ${params.throat_radius} \ + --mouth-radius ${params.mouth_radius} \ + --length ${params.length} \ + --profile ${profile} + """ +} + +process run_simulation_directivity { + publishDir "${params.outdir}", mode: 'copy' + + input: + path horn_step + + output: + path "directivity.csv" + + script: + """ + python3 -m horn_solver.solver \ + --step-file ${horn_step} \ + --output-file solver_directivity.csv \ + --min-freq ${params.min_freq} \ + --max-freq ${params.max_freq} \ + --num-intervals ${params.num_intervals} \ + --length ${params.length} \ + --mesh-size ${params.mesh_size} \ + --radiation-model bem \ + --compute-directivity \ + --directivity-file directivity.csv + """ +} + +process generate_directivity_plots { + publishDir "${params.outdir}/directivity", mode: 'copy' + + input: + path directivity_csv + + output: + path "polar_directivity.png" + path "directivity_contour.png" + path "beamwidth.png" + path "directivity_index.png" + + script: + """ + python3 -m horn_analysis.directivity_plot ${directivity_csv} --output-dir . + """ +} + // ======================================================================== // Auto mode processes // ======================================================================== @@ -414,6 +503,20 @@ workflow single { // 9. Combined dashboard generate_dashboard(ch_merged_results) + + // 10. 3D horn geometry render (runs in parallel with simulation) + render_horn_3d( + params.throat_radius, + params.mouth_radius, + params.length, + params.profile + ) + + // 11. Directivity (opt-in, requires BEM) + if (params.directivity) { + ch_directivity_csv = run_simulation_directivity(ch_step_file) + generate_directivity_plots(ch_directivity_csv) + } } workflow auto { @@ -468,6 +571,10 @@ workflow auto { ch_drivers_db, ch_prescreen, ) + + // 9. 3D horn geometry renders (one per profile, parallel) + ch_render_profiles = Channel.from("conical", "exponential", "hyperbolic") + render_auto_horn_3d(ch_render_profiles) } workflow { diff --git a/packages/horn-analysis/pyproject.toml b/packages/horn-analysis/pyproject.toml index 9d73c37..892d90b 100644 --- a/packages/horn-analysis/pyproject.toml +++ b/packages/horn-analysis/pyproject.toml @@ -23,6 +23,8 @@ horn-prescreen = "horn_analysis.prescreen:main" horn-rank = "horn_analysis.rank_pipeline:main" horn-auto-report = "horn_analysis.auto_report:main" horn-dashboard = "horn_analysis.dashboard:main" +horn-render = "horn_analysis.horn_render:main" +horn-directivity-plot = "horn_analysis.directivity_plot:main" [project.optional-dependencies] test = [ diff --git a/packages/horn-analysis/src/horn_analysis/directivity_plot.py b/packages/horn-analysis/src/horn_analysis/directivity_plot.py new file mode 100644 index 0000000..0238791 --- /dev/null +++ b/packages/horn-analysis/src/horn_analysis/directivity_plot.py @@ -0,0 +1,358 @@ +"""Akabak-style directivity / radiation pattern visualization. + +Reads a directivity CSV produced by the solver (columns: +``frequency``, ``theta_deg``, ``spl_db``) and generates four +diagnostic plots: + +1. **Polar directivity** — SPL vs angle at selected frequencies. +2. **Directivity contour** (sonogram) — SPL heat-map over frequency and angle. +3. **Beamwidth vs frequency** — coverage angle where SPL drops by a threshold. +4. **Directivity index vs frequency** — on-axis gain relative to omnidirectional. + +All plots use the shared :mod:`horn_analysis.plot_theme` styling. +""" + +import argparse +from pathlib import Path +from typing import List, Optional, Tuple + +import matplotlib +matplotlib.use("Agg") +import matplotlib.pyplot as plt +import numpy as np +import pandas as pd + +from horn_analysis import plot_theme + + +# -- Data loading ------------------------------------------------------------- + +def load_directivity(csv_file: str) -> pd.DataFrame: + """Load and validate a directivity CSV file. + + Expected columns: ``frequency``, ``theta_deg``, ``spl_db``. + + Returns + ------- + pd.DataFrame + Sorted by (frequency, theta_deg). + """ + df = pd.read_csv(csv_file) + required = {"frequency", "theta_deg", "spl_db"} + missing = required - set(df.columns) + if missing: + raise ValueError(f"Directivity CSV missing columns: {missing}") + return df.sort_values(["frequency", "theta_deg"]).reset_index(drop=True) + + +# -- Polar directivity ------------------------------------------------------- + +def plot_polar_directivity( + df: pd.DataFrame, + frequencies: Optional[List[float]] = None, + output_file: str = "polar_directivity.png", + *, + n_auto: int = 6, +): + """Polar plot of SPL vs angle at selected frequencies. + + Parameters + ---------- + df : DataFrame + Directivity data with columns ``frequency``, ``theta_deg``, ``spl_db``. + frequencies : list of float, optional + Frequencies to plot. If *None*, auto-selects ``n_auto`` log-spaced + values from the data range. + output_file : str + Output image path. + n_auto : int + Number of frequencies to auto-select when *frequencies* is None. + """ + plot_theme.apply_theme() + + avail_freqs = np.sort(df["frequency"].unique()) + if frequencies is None: + indices = np.round(np.linspace(0, len(avail_freqs) - 1, n_auto)).astype(int) + frequencies = avail_freqs[indices] + else: + # Snap each requested frequency to the nearest available + frequencies = [avail_freqs[np.argmin(np.abs(avail_freqs - f))] for f in frequencies] + + fig, ax = plt.subplots(subplot_kw={"projection": "polar"}, figsize=(8, 8)) + ax.set_theta_zero_location("N") # 0 degrees at top (on-axis) + ax.set_theta_direction(-1) + ax.set_thetamin(0) + ax.set_thetamax(180) + + colors = plot_theme.MULTI_COLORS + for i, freq in enumerate(frequencies): + sub = df[df["frequency"] == freq].sort_values("theta_deg") + theta_rad = np.radians(sub["theta_deg"].values) + spl = sub["spl_db"].values + label = f"{freq:.0f} Hz" if freq < 1000 else f"{freq / 1000:.1f} kHz" + ax.plot(theta_rad, spl, color=colors[i % len(colors)], linewidth=1.3, label=label) + + ax.set_title("Polar Directivity", pad=20) + ax.legend(loc="lower left", fontsize=7, bbox_to_anchor=(1.05, 0)) + + plot_theme.save_figure(fig, output_file) + + +# -- Directivity contour (sonogram) ------------------------------------------ + +def plot_directivity_contour( + df: pd.DataFrame, + output_file: str = "directivity_contour.png", + *, + db_range: float = 40, +): + """Frequency-angle heat-map of SPL (Akabak-style sonogram). + + Parameters + ---------- + df : DataFrame + Directivity data. + output_file : str + Output image path. + db_range : float + Dynamic range below peak to display (dB). + """ + plot_theme.apply_theme() + + freqs = np.sort(df["frequency"].unique()) + angles = np.sort(df["theta_deg"].unique()) + + spl_grid = np.full((len(angles), len(freqs)), np.nan) + for j, f in enumerate(freqs): + sub = df[df["frequency"] == f].sort_values("theta_deg") + for i, a in enumerate(angles): + row = sub[sub["theta_deg"] == a] + if not row.empty: + spl_grid[i, j] = row["spl_db"].values[0] + + vmax = np.nanmax(spl_grid) + vmin = vmax - db_range + + fig, ax = plot_theme.create_figure(figsize=(11, 6)) + mesh = ax.pcolormesh( + freqs, angles, spl_grid, + cmap="inferno", + vmin=vmin, + vmax=vmax, + shading="gouraud", + ) + plot_theme.setup_freq_axis(ax, freqs.min(), freqs.max()) + ax.set_ylabel("Angle (degrees)") + ax.set_title("Directivity Contour") + cbar = fig.colorbar(mesh, ax=ax, pad=0.02) + cbar.set_label("SPL (dB)") + plot_theme.setup_grid(ax) + + plot_theme.save_figure(fig, output_file) + + +# -- Beamwidth ---------------------------------------------------------------- + +def compute_beamwidth( + df: pd.DataFrame, + threshold_db: float = -6, +) -> Tuple[np.ndarray, np.ndarray]: + """Compute coverage angle vs frequency. + + The beamwidth is twice the angle at which the SPL drops by + ``threshold_db`` relative to the on-axis (0 degrees) value. + + Parameters + ---------- + df : DataFrame + Directivity data. + threshold_db : float + Drop from on-axis SPL (negative, e.g. -6). + + Returns + ------- + (frequencies, beamwidth_deg) : tuple of 1-D arrays + """ + freqs = np.sort(df["frequency"].unique()) + beamwidths = np.full(len(freqs), np.nan) + + for i, f in enumerate(freqs): + sub = df[df["frequency"] == f].sort_values("theta_deg") + theta = sub["theta_deg"].values + spl = sub["spl_db"].values + + # On-axis SPL (theta=0 or closest) + idx_on = np.argmin(np.abs(theta)) + spl_on = spl[idx_on] + target = spl_on + threshold_db # threshold_db is negative + + # Find first angle where SPL drops below target + below = np.where(spl < target)[0] + if len(below) > 0: + idx_cross = below[0] + if idx_cross > 0: + # Linear interpolation between adjacent points + t0, t1 = theta[idx_cross - 1], theta[idx_cross] + s0, s1 = spl[idx_cross - 1], spl[idx_cross] + if s1 != s0: + angle = t0 + (target - s0) * (t1 - t0) / (s1 - s0) + else: + angle = t0 + beamwidths[i] = 2 * angle # Full coverage angle + else: + beamwidths[i] = 0.0 + else: + beamwidths[i] = 2 * theta.max() # Never drops below threshold + + return freqs, beamwidths + + +def plot_beamwidth( + df: pd.DataFrame, + output_file: str = "beamwidth.png", + *, + threshold_db: float = -6, +): + """Plot beamwidth (coverage angle) vs frequency. + + Parameters + ---------- + df : DataFrame + Directivity data. + output_file : str + Output image path. + threshold_db : float + dB threshold for beamwidth calculation. + """ + plot_theme.apply_theme() + + freqs, bw = compute_beamwidth(df, threshold_db=threshold_db) + + fig, ax = plot_theme.create_figure(figsize=(10, 5)) + ax.plot(freqs, bw, color=plot_theme.COLORS["primary"], linewidth=1.4) + plot_theme.setup_freq_axis(ax, freqs.min(), freqs.max()) + ax.set_ylabel("Beamwidth (degrees)") + ax.set_title(f"Beamwidth ({threshold_db} dB) vs Frequency") + plot_theme.setup_grid(ax) + + plot_theme.save_figure(fig, output_file) + + +# -- Directivity index ------------------------------------------------------- + +def compute_directivity_index( + df: pd.DataFrame, +) -> Tuple[np.ndarray, np.ndarray]: + """Compute axisymmetric directivity index vs frequency. + + DI = 10 * log10( p_on_axis^2 /
) + + where
is the power-weighted angular average: +
= integral( p^2 * sin(theta) dtheta ) / integral( sin(theta) dtheta ) + + Returns + ------- + (frequencies, DI_dB) : tuple of 1-D arrays + """ + freqs = np.sort(df["frequency"].unique()) + di = np.full(len(freqs), np.nan) + + for i, f in enumerate(freqs): + sub = df[df["frequency"] == f].sort_values("theta_deg") + theta_deg = sub["theta_deg"].values + spl = sub["spl_db"].values + + # Convert SPL to linear pressure squared (relative) + p_sq = 10 ** (spl / 10) + + theta_rad = np.radians(theta_deg) + + # On-axis value (closest to 0 degrees) + idx_on = np.argmin(np.abs(theta_deg)) + p_sq_on = p_sq[idx_on] + + # Numerical integration using trapezoidal rule + integrand = p_sq * np.sin(theta_rad) + numerator = np.trapezoid(integrand, theta_rad) + denominator = np.trapezoid(np.sin(theta_rad), theta_rad) + + if denominator > 0 and numerator > 0: + p_sq_avg = numerator / denominator + di[i] = 10 * np.log10(p_sq_on / p_sq_avg) + + return freqs, di + + +def plot_directivity_index( + df: pd.DataFrame, + output_file: str = "directivity_index.png", +): + """Plot directivity index (DI) vs frequency. + + Parameters + ---------- + df : DataFrame + Directivity data. + output_file : str + Output image path. + """ + plot_theme.apply_theme() + + freqs, di = compute_directivity_index(df) + + fig, ax = plot_theme.create_figure(figsize=(10, 5)) + ax.plot(freqs, di, color=plot_theme.COLORS["primary"], linewidth=1.4) + plot_theme.setup_freq_axis(ax, freqs.min(), freqs.max()) + ax.set_ylabel("Directivity Index (dB)") + ax.set_title("Directivity Index vs Frequency") + plot_theme.setup_grid(ax) + + plot_theme.save_figure(fig, output_file) + + +# -- Combined report --------------------------------------------------------- + +def generate_directivity_report(csv_file: str, output_dir: str): + """Generate all four directivity plots into a directory. + + Creates: + - ``polar_directivity.png`` + - ``directivity_contour.png`` + - ``beamwidth.png`` + - ``directivity_index.png`` + + Parameters + ---------- + csv_file : str + Path to the directivity CSV. + output_dir : str + Directory for output images (created if needed). + """ + out = Path(output_dir) + out.mkdir(parents=True, exist_ok=True) + + df = load_directivity(csv_file) + + plot_polar_directivity(df, output_file=str(out / "polar_directivity.png")) + plot_directivity_contour(df, output_file=str(out / "directivity_contour.png")) + plot_beamwidth(df, output_file=str(out / "beamwidth.png")) + plot_directivity_index(df, output_file=str(out / "directivity_index.png")) + + +# -- CLI ---------------------------------------------------------------------- + +def main(): + """Command-line interface: ``horn-directivity-plot``.""" + parser = argparse.ArgumentParser( + description="Generate Akabak-style directivity plots from a CSV." + ) + parser.add_argument("csv_file", help="Directivity CSV (frequency, theta_deg, spl_db)") + parser.add_argument("--output-dir", default="directivity", + help="Output directory (default: directivity/)") + args = parser.parse_args() + + generate_directivity_report(args.csv_file, args.output_dir) + + +if __name__ == "__main__": + main() diff --git a/packages/horn-analysis/src/horn_analysis/horn_render.py b/packages/horn-analysis/src/horn_analysis/horn_render.py new file mode 100644 index 0000000..2cfbea8 --- /dev/null +++ b/packages/horn-analysis/src/horn_analysis/horn_render.py @@ -0,0 +1,283 @@ +"""3D horn geometry rendering with surface-of-revolution visualization. + +Produces a side-by-side figure: 3D surface-of-revolution plot (left) +and 2D wall profile cross-section (right). Uses the shared plot theme +for consistent styling across the analysis package. +""" + +import argparse +import base64 +import io +from pathlib import Path + +import matplotlib +matplotlib.use("Agg") +import matplotlib.pyplot as plt +import numpy as np +from mpl_toolkits.mplot3d import Axes3D # noqa: F401 – registers 3D projection + +from horn_analysis import plot_theme + + +# -- Radius profile functions ------------------------------------------------ + +def _radius_profile( + z: np.ndarray, + throat_radius: float, + mouth_radius: float, + length: float, + profile: str, +) -> np.ndarray: + """Compute radius along the horn axis for a given flare profile. + + Parameters + ---------- + z : array-like + Axial positions (m), expected in [0, length]. + throat_radius, mouth_radius, length : float + Horn dimensions (m). + profile : str + One of ``"conical"``, ``"exponential"``, ``"hyperbolic"``. + + Returns + ------- + numpy array of radii at each z position. + """ + z = np.asarray(z, dtype=float) + if profile == "conical": + return throat_radius + (mouth_radius - throat_radius) * z / length + elif profile == "exponential": + m = np.log(mouth_radius / throat_radius) + return throat_radius * np.exp(m * z / length) + elif profile == "hyperbolic": + m = np.arccosh(mouth_radius / throat_radius) + return throat_radius * np.cosh(m * z / length) + else: + raise ValueError( + f"Unknown profile '{profile}'. " + f"Choose from: conical, exponential, hyperbolic" + ) + + +# -- 3D rendering ------------------------------------------------------------ + +def render_horn_3d( + throat_radius: float, + mouth_radius: float, + length: float, + profile: str, + output_file: str, + *, + n_z: int = 80, + n_theta: int = 60, + show_profile: bool = True, + figsize: tuple = (14, 6), +): + """Render a 3D surface-of-revolution horn with optional 2D profile panel. + + Parameters + ---------- + throat_radius, mouth_radius, length : float + Horn dimensions (m). + profile : str + Flare profile name. + output_file : str + Path to save the rendered image. + n_z : int + Number of axial samples for the surface mesh. + n_theta : int + Number of angular samples around the axis. + show_profile : bool + If True, add a 2D cross-section panel on the right. + figsize : tuple + Figure size (width, height) in inches. + """ + plot_theme.apply_theme() + + z_vals = np.linspace(0, length, n_z) + r_vals = _radius_profile(z_vals, throat_radius, mouth_radius, length, profile) + theta = np.linspace(0, 2 * np.pi, n_theta) + + Z, Theta = np.meshgrid(z_vals, theta) + R = np.meshgrid(r_vals, theta)[0] + X = R * np.cos(Theta) + Y = R * np.sin(Theta) + + ncols = 2 if show_profile else 1 + fig = plt.figure(figsize=figsize) + + # -- 3D surface panel -- + ax3d = fig.add_subplot(1, ncols, 1, projection="3d") + ax3d.plot_surface( + Z, X, Y, + cmap="coolwarm", + alpha=0.85, + edgecolor="none", + rstride=1, + cstride=1, + ) + ax3d.set_xlabel("Axial position (m)") + ax3d.set_ylabel("X (m)") + ax3d.set_zlabel("Y (m)") + ax3d.set_title(f"{profile.capitalize()} Horn — 3D View") + ax3d.view_init(elev=20, azim=-60) + + # Equal aspect ratio for all three axes + max_range = max(length, 2 * mouth_radius) / 2 + mid_z = length / 2 + ax3d.set_xlim(mid_z - max_range, mid_z + max_range) + ax3d.set_ylim(-max_range, max_range) + ax3d.set_zlim(-max_range, max_range) + + # -- 2D profile panel -- + if show_profile: + ax2d = fig.add_subplot(1, 2, 2) + r_mm = r_vals * 1000 + z_mm = z_vals * 1000 + + ax2d.fill_between(z_mm, -r_mm, r_mm, alpha=0.15, color=plot_theme.COLORS["primary"]) + ax2d.plot(z_mm, r_mm, color=plot_theme.COLORS["primary"], linewidth=1.4, label="Wall") + ax2d.plot(z_mm, -r_mm, color=plot_theme.COLORS["primary"], linewidth=1.4) + + # Annotate throat and mouth radii + ax2d.annotate( + f"Throat: {throat_radius * 1000:.1f} mm", + xy=(0, throat_radius * 1000), + xytext=(length * 1000 * 0.15, mouth_radius * 1000 * 0.9), + fontsize=8, + arrowprops=dict(arrowstyle="->", color="#888888", lw=0.8), + color="#555555", + ) + ax2d.annotate( + f"Mouth: {mouth_radius * 1000:.1f} mm", + xy=(length * 1000, mouth_radius * 1000), + xytext=(length * 1000 * 0.65, mouth_radius * 1000 * 0.9), + fontsize=8, + arrowprops=dict(arrowstyle="->", color="#888888", lw=0.8), + color="#555555", + ) + + ax2d.set_xlabel("Axial position (mm)") + ax2d.set_ylabel("Radius (mm)") + ax2d.set_title(f"{profile.capitalize()} Horn — Wall Profile") + ax2d.set_aspect("equal", adjustable="datalim") + plot_theme.setup_grid(ax2d) + + plot_theme.save_figure(fig, output_file) + + +def fig_to_b64_3d( + throat_radius: float, + mouth_radius: float, + length: float, + profile: str, + **kwargs, +) -> str: + """Render a 3D horn to a base64 data-URI string for HTML embedding. + + Accepts the same keyword arguments as :func:`render_horn_3d` + (except ``output_file``). + + Returns + ------- + str + ``data:image/png;base64,...`` encoded image. + """ + plot_theme.apply_theme() + + buf = io.BytesIO() + # Render to a temporary buffer by writing to BytesIO + n_z = kwargs.pop("n_z", 80) + n_theta = kwargs.pop("n_theta", 60) + show_profile = kwargs.pop("show_profile", True) + figsize = kwargs.pop("figsize", (14, 6)) + + z_vals = np.linspace(0, length, n_z) + r_vals = _radius_profile(z_vals, throat_radius, mouth_radius, length, profile) + theta = np.linspace(0, 2 * np.pi, n_theta) + + Z, Theta = np.meshgrid(z_vals, theta) + R = np.meshgrid(r_vals, theta)[0] + X = R * np.cos(Theta) + Y = R * np.sin(Theta) + + ncols = 2 if show_profile else 1 + fig = plt.figure(figsize=figsize) + + ax3d = fig.add_subplot(1, ncols, 1, projection="3d") + ax3d.plot_surface( + Z, X, Y, + cmap="coolwarm", + alpha=0.85, + edgecolor="none", + rstride=1, + cstride=1, + ) + ax3d.set_xlabel("Axial position (m)") + ax3d.set_ylabel("X (m)") + ax3d.set_zlabel("Y (m)") + ax3d.set_title(f"{profile.capitalize()} Horn — 3D View") + ax3d.view_init(elev=20, azim=-60) + + max_range = max(length, 2 * mouth_radius) / 2 + mid_z = length / 2 + ax3d.set_xlim(mid_z - max_range, mid_z + max_range) + ax3d.set_ylim(-max_range, max_range) + ax3d.set_zlim(-max_range, max_range) + + if show_profile: + ax2d = fig.add_subplot(1, 2, 2) + r_mm = r_vals * 1000 + z_mm = z_vals * 1000 + ax2d.fill_between(z_mm, -r_mm, r_mm, alpha=0.15, color=plot_theme.COLORS["primary"]) + ax2d.plot(z_mm, r_mm, color=plot_theme.COLORS["primary"], linewidth=1.4) + ax2d.plot(z_mm, -r_mm, color=plot_theme.COLORS["primary"], linewidth=1.4) + ax2d.set_xlabel("Axial position (mm)") + ax2d.set_ylabel("Radius (mm)") + ax2d.set_title(f"{profile.capitalize()} Horn — Wall Profile") + ax2d.set_aspect("equal", adjustable="datalim") + plot_theme.setup_grid(ax2d) + + fig.tight_layout() + fig.savefig(buf, format="png", dpi=150, bbox_inches="tight") + plt.close(fig) + buf.seek(0) + encoded = base64.b64encode(buf.read()).decode("ascii") + return f"data:image/png;base64,{encoded}" + + +# -- CLI ---------------------------------------------------------------------- + +def main(): + """Command-line interface: ``horn-render``.""" + parser = argparse.ArgumentParser( + description="Render a 3D horn geometry visualization." + ) + parser.add_argument("output", help="Output image file path (e.g. horn.png)") + parser.add_argument("--throat-radius", type=float, required=True, + help="Throat radius in metres") + parser.add_argument("--mouth-radius", type=float, required=True, + help="Mouth radius in metres") + parser.add_argument("--length", type=float, required=True, + help="Horn length in metres") + parser.add_argument("--profile", type=str, default="conical", + choices=["conical", "exponential", "hyperbolic"], + help="Horn flare profile (default: conical)") + parser.add_argument("--no-profile-panel", action="store_true", + help="Omit the 2D cross-section panel") + args = parser.parse_args() + + Path(args.output).parent.mkdir(parents=True, exist_ok=True) + + render_horn_3d( + throat_radius=args.throat_radius, + mouth_radius=args.mouth_radius, + length=args.length, + profile=args.profile, + output_file=args.output, + show_profile=not args.no_profile_panel, + ) + + +if __name__ == "__main__": + main() diff --git a/packages/horn-analysis/src/horn_analysis/html_report.py b/packages/horn-analysis/src/horn_analysis/html_report.py index 74338c8..9b42db4 100644 --- a/packages/horn-analysis/src/horn_analysis/html_report.py +++ b/packages/horn-analysis/src/horn_analysis/html_report.py @@ -17,6 +17,7 @@ from horn_core.parameters import DriverParameters from horn_analysis.scoring import TargetSpec from horn_analysis import plot_theme +from horn_analysis.horn_render import fig_to_b64_3d # -- Profile badge styles (HTML-only; matplotlib styles come from plot_theme) -- @@ -309,6 +310,8 @@ def _render_drivers_rows(drivers: Dict[str, DriverParameters]) -> str: