"""Parametric OpenCascade teaching model for a four-rotor layout.

All dimensions are arbitrary visualization coordinates in millimetres. The
rotor-disk diameter merely preserves the scale note in the source teaching
diagram; no dimensions are an aircraft proposal or validated design. Rotor
swept-area rings are visualization markers, not physical hardware.

Rebuild with: python build_atmospheric_quad_cad.py
Requires: cadquery==2.7.0, matplotlib
"""
from __future__ import annotations

import csv
import math
from pathlib import Path

import cadquery as cq
import matplotlib

matplotlib.use("Agg")
import matplotlib.pyplot as plt
from matplotlib.patches import Patch
from mpl_toolkits.mplot3d.art3d import Poly3DCollection


OUT = Path(__file__).resolve().parent
# Visualization dimensions, mm for CAD exchange. Rotor diameter derives from
# the source diagram's stated 1 m² circle, not from an engineering requirement.
P = {
    "rotor_radius": 564.19,
    "rotor_center_offset": 660.0,
    "body_length": 390.0,
    "body_width": 345.0,
    "body_height": 110.0,
    "battery_length": 225.0,
    "battery_width": 180.0,
    "battery_height": 80.0,
    "arm_width": 48.0,
    "arm_height": 42.0,
    "motor_radius": 54.0,
    "motor_height": 60.0,
    "blade_span": 760.0,
    "blade_width": 34.0,
    "blade_height": 13.0,
    "sweep_marker_thickness": 2.5,
}
COLORS = {
    "frame": (0.55, 0.48, 0.78),
    "battery": (0.95, 0.36, 0.70),
    "motor": (0.36, 0.79, 0.92),
    "blade": (0.80, 0.84, 0.93),
    "sweep": (0.30, 0.75, 0.88),
    "marker": (1.00, 0.45, 0.82),
}


def box(l: float, w: float, h: float, center: tuple[float, float, float]) -> cq.Shape:
    return cq.Workplane("XY").box(l, w, h).translate(center).val()


def rotor_centres() -> list[tuple[float, float]]:
    o = P["rotor_center_offset"]
    return [(-o, -o), (o, -o), (-o, o), (o, o)]


def build_parts() -> list[tuple[str, str, cq.Shape, tuple[float, float, float]]]:
    parts = []
    parts.append(("Central frame and body", "Frame", box(
        P["body_length"], P["body_width"], P["body_height"], (0, 0, 0)), COLORS["frame"]))
    parts.append(("Battery placeholder", "Energy-storage placeholder", box(
        P["battery_length"], P["battery_width"], P["battery_height"], (0, 0, 0)), COLORS["battery"]))

    # Four straight arm solids follow the diagonal, rotor-centre arrangement.
    o = P["rotor_center_offset"]
    for idx, (x, y) in enumerate(rotor_centres(), 1):
        arm = cq.Workplane("XY").box(math.sqrt(2) * o, P["arm_width"], P["arm_height"])
        arm = arm.rotate((0, 0, 0), (0, 0, 1), 45 if x * y > 0 else -45)
        # Position each arm on its quadrant's radial line, stopping at centre.
        angle = 45 if x * y > 0 else -45
        arm_center = (x / 2, y / 2, 0)
        arm = cq.Workplane(obj=arm.val()).translate(arm_center).val()
        parts.append((f"Radial arm {idx}", "Frame", arm, COLORS["frame"]))

    for idx, (x, y) in enumerate(rotor_centres(), 1):
        z = P["body_height"] / 2 + P["motor_height"] / 2
        motor = cq.Solid.makeCylinder(P["motor_radius"], P["motor_height"],
                                      cq.Vector(x, y, z - P["motor_height"] / 2))
        parts.append((f"Rotor motor housing {idx}", "Motor placeholder", motor, COLORS["motor"]))

        # A simple crossed two-blade solid; it communicates a rotor without
        # implying airfoil, pitch, rotation, thrust or control performance.
        z_blade = z + P["motor_height"] / 2 + P["blade_height"] / 2
        b1 = cq.Workplane("XY").box(P["blade_span"], P["blade_width"], P["blade_height"])
        b2 = cq.Workplane("XY").box(P["blade_width"], P["blade_span"], P["blade_height"])
        blades = b1.union(b2).translate((x, y, z_blade)).val()
        parts.append((f"Two-blade rotor placeholder {idx}", "Rotor placeholder", blades, COLORS["blade"]))

        # Annular swept-area marker mirrors the source diagram; not hardware.
        r = P["rotor_radius"]
        outer = cq.Workplane("XY").circle(r).extrude(P["sweep_marker_thickness"])
        inner = cq.Workplane("XY").circle(r - P["sweep_marker_thickness"] * 2).extrude(P["sweep_marker_thickness"])
        marker = outer.cut(inner).translate((x, y, z + P["motor_height"] / 2 + 4)).val()
        parts.append((f"Swept-area visualization ring {idx}", "Visualization marker only", marker, COLORS["sweep"]))

    # A spherical CAD marker makes the assumed centre location visible.
    com = cq.Solid.makeSphere(24.0, cq.Vector(0, 0, P["body_height"] / 2 + 8))
    parts.append(("Assumed centre-of-mass marker", "Visualization marker only", com, COLORS["marker"]))
    return parts


def render(parts, path: Path, mobile: bool) -> None:
    fig = plt.figure(figsize=(6.2, 7.4) if mobile else (11.2, 8.0), facecolor="#07152e")
    ax = fig.add_subplot(111, projection="3d", facecolor="#07152e")
    ax.set_proj_type("ortho")
    for name, group, shape, color in parts:
        verts, triangles = shape.tessellate(0.8, 0.25)
        xyz = [(v.x, v.y, v.z) for v in verts]
        faces = [[xyz[i] for i in face] for face in triangles]
        if name == "Central frame and body":
            alpha = 0.30  # X-ray-like teaching view so the contained battery is visible.
        elif group == "Visualization marker only" and "ring" in name:
            alpha = 0.23
        else:
            alpha = 0.95
        ax.add_collection3d(Poly3DCollection(faces, facecolor=color, edgecolor="none", alpha=alpha))
    lim = P["rotor_center_offset"] + P["rotor_radius"] + 95
    ax.set_xlim(-lim, lim); ax.set_ylim(-lim, lim); ax.set_zlim(-150, 245)
    if mobile:
        ax.view_init(elev=90, azim=-90)
        ax.set_box_aspect((1, 1, 0.20))
        ax.set_position([0.04, 0.23, 0.92, 0.57])
        fig.text(.06, .95, "FOUR-ROTOR LAYOUT · TOP VIEW", color="#f1f5ff", fontsize=15, weight="bold")
        fig.text(.06, .90, "Parametric CAD · schematic · not to scale", color="#69d9f7", fontsize=10)
        fig.text(.06, .15, "Four rotor placeholders around a central body and battery.", color="#dce6f4", fontsize=9)
        fig.text(.06, .10, "Rings mark swept areas; they are not physical guards.", color="#93a9c9", fontsize=9)
        fig.text(.06, .055, "No thrust, power, stability or flight performance calculated.", color="#ff9bdc", fontsize=8.5)
    else:
        ax.view_init(elev=31, azim=-53)
        ax.set_box_aspect((1, 1, 0.36))
        ax.set_position([0.02, 0.22, 0.96, 0.65])
        fig.text(.055, .95, "ATMOSPHERIC FOUR-ROTOR LAYOUT", color="#f1f5ff", fontsize=19, weight="bold")
        fig.text(.055, .90, "PARAMETRIC CAD ASSEMBLY · CONCEPTUAL GEOMETRY · NOT TO SCALE", color="#69d9f7", fontsize=10)
        fig.text(.055, .845, "Four independent rotor placeholders surround a central body, battery and assumed mass-centre marker.", color="#dce6f4", fontsize=9)
        handles = [Patch(facecolor=COLORS["frame"], label="Frame / arms"), Patch(facecolor=COLORS["battery"], label="Battery placeholder"),
                   Patch(facecolor=COLORS["motor"], label="Motor placeholders"), Patch(facecolor=COLORS["blade"], label="Two-blade placeholders"),
                   Patch(facecolor=COLORS["sweep"], label="Swept-area markers · not hardware")]
        fig.legend(handles=handles, loc="lower center", bbox_to_anchor=(.5, .12), ncol=2, frameon=False, fontsize=9, labelcolor="#dce6f4")
        fig.text(.055, .035, "Visualization dimensions only. No aerodynamic, electrical, control, structural or flight performance is represented.", color="#93a9c9", fontsize=9)
    ax.set_axis_off()
    fig.savefig(path, dpi=180, facecolor=fig.get_facecolor(), bbox_inches="tight", pad_inches=.25)
    plt.close(fig)


def main() -> None:
    parts = build_parts()
    assembly = cq.Assembly(name="Atmospheric four-rotor illustrative layout")
    shapes = []
    for name, _group, shape, rgb in parts:
        if not shape.isValid() or shape.Volume() <= 0:
            raise RuntimeError(f"Invalid or zero-volume solid: {name}")
        shapes.append(shape)
        assembly.add(shape, name=name.replace(" ", "_"), color=cq.Color(*rgb), loc=cq.Location())

    step = OUT / "atmospheric-quad-parametric.step"
    stl = OUT / "atmospheric-quad-parametric.stl"
    assembly.export(str(step))
    cq.Compound.makeCompound(shapes).exportStl(str(stl), tolerance=0.5, angularTolerance=0.22)

    rows = [
        ("Overall rotor-centre span", "2640 mm", "Visualization-only dimension derived from chosen rotor-centre offsets."),
        ("Rotor swept-area diameter", "1128.38 mm", "Carried over from the source diagram's 1 m² per-disk teaching label; not a validated aircraft choice."),
        ("Body and battery envelope", "390 × 345 × 110 mm; battery 225 × 180 × 80 mm", "Arbitrary visualization dimensions; no packaging, mass, thermal or structural analysis."),
        ("Rotor blades and motors", "Simplified solids", "No airfoil, pitch, motor, propeller speed, thrust, power, acoustic or control properties."),
        ("Swept-area rings", "CAD visualization markers", "Not rotor guards, ducting or physical surfaces."),
        ("Centre-of-mass marker", "Assumed at body centre", "No component masses or balance analysis."),
        ("Performance status", "Not calculated", "No hover, stability, endurance, safety, manufacturing or flight claim."),
    ]
    with (OUT / "atmospheric-quad-assumptions.csv").open("w", newline="", encoding="utf-8") as f:
        writer = csv.writer(f); writer.writerow(["item", "model value", "assumption and limit"]); writer.writerows(rows)

    render(parts, OUT / "atmospheric-quad-cad-isometric.png", mobile=False)
    render(parts, OUT / "atmospheric-quad-cad-top-mobile.png", mobile=True)

    imported = cq.importers.importStep(str(step)).val()
    imported_solids = imported.Solids()
    if len(imported_solids) != len(parts) or any(not s.isValid() or s.Volume() <= 0 for s in imported_solids):
        raise RuntimeError(f"STEP round-trip failed: {len(imported_solids)} valid solids from {len(parts)} parts")
    print(f"Built {len(parts)} valid solids; STEP re-import preserved {len(imported_solids)} solids.")
    print(f"STEP: {step}\nSTL: {stl}\nPNG renders: {OUT / 'atmospheric-quad-cad-isometric.png'}, {OUT / 'atmospheric-quad-cad-top-mobile.png'}")


if __name__ == "__main__":
    main()
