"""Generate the Make Birdhouses CleanSeed window-feeder prototype.

CleanSeed is an original, window-mounted feeder frame built around a
removable stainless-mesh cup.  Seed is held by the washable metal cup, not
against printed layer lines.  The frame, rain hood, M3 hinge spine, cup-fit
ring, and hardware-fit card share one guarded Bambu Lab X1C plate.

This script exports millimetre-native STL, STEP, and generic 3MF files,
renders release artwork, writes a digital validation report, and assembles
the public download ZIP.  Digital checks do not replace slicing, printing,
hardware fitting, loaded-window testing, cleaning tests, or field review.
"""

from __future__ import annotations

import json
import math
from pathlib import Path
import warnings
import xml.etree.ElementTree as ET
import zipfile

import cadquery as cq
from cadquery import exporters, importers
import matplotlib
import numpy as np
import trimesh

matplotlib.use("Agg")
import matplotlib.pyplot as plt
from matplotlib.patches import Circle, FancyArrowPatch, Rectangle
from mpl_toolkits.mplot3d.art3d import Poly3DCollection


OUTPUT_DIR = Path(__file__).resolve().parent / "cleanseed-window-feeder-m3"

FRAME_STL_PATH = OUTPUT_DIR / "cleanseed-frame.stl"
HOOD_STL_PATH = OUTPUT_DIR / "cleanseed-rain-hood.stl"
SPINE_STL_PATH = OUTPUT_DIR / "cleanseed-m3-hinge-spine.stl"
CUP_RING_STL_PATH = OUTPUT_DIR / "cleanseed-84mm-cup-fit-ring.stl"
HARDWARE_CARD_STL_PATH = OUTPUT_DIR / "cleanseed-m3-suction-fit-card.stl"
PLATE_STL_PATH = OUTPUT_DIR / "cleanseed-x1c-one-plate.stl"
PLATE_3MF_PATH = OUTPUT_DIR / "cleanseed-x1c-one-plate.3mf"
STEP_PATH = OUTPUT_DIR / "cleanseed-window-feeder-m3.step"
PREVIEW_PATH = OUTPUT_DIR / "cleanseed-assembled-preview.png"
PLATE_PREVIEW_PATH = OUTPUT_DIR / "cleanseed-x1c-plate-preview.png"
CLEANING_PATH = OUTPUT_DIR / "cleanseed-cleaning-workflow.png"
VALIDATION_PATH = OUTPUT_DIR / "validation-report.json"
README_PATH = OUTPUT_DIR / "README.md"
ZIP_PATH = OUTPUT_DIR / "cleanseed-window-feeder-m3-v1.zip"

EXPORT_TOLERANCE_MM = 0.10
EXPORT_ANGULAR_TOLERANCE = 0.12

# Nominal X1C build envelope and a conservative front-left cutter exclusion.
X1C_X_MM = 256.0
X1C_Y_MM = 256.0
X1C_DEFAULT_Z_MM = 250.0
X1C_EXCLUSION_X_MM = 18.0
X1C_EXCLUSION_Y_MM = 28.0
OCC_EXPORT_BOUNDS_EPSILON_MM = 0.05

# Mounted frame geometry.
FRAME_WIDTH_MM = 136.0
FRAME_HEIGHT_MM = 116.0
BACKPLATE_THICKNESS_MM = 4.5
SHELF_DEPTH_MM = 98.0
SHELF_BOTTOM_MM = 24.0
SHELF_THICKNESS_MM = 5.0
SHELF_TOP_MM = SHELF_BOTTOM_MM + SHELF_THICKNESS_MM
LANDING_RAIL_LENGTH_MM = 122.0
LANDING_RAIL_DIAMETER_MM = 10.0
LANDING_RAIL_CENTER_Y_MM = 96.0
LANDING_RAIL_CENTER_Z_MM = 33.2

# Removable stainless-mesh cup interface.  The cup is not printed or included.
CUP_CENTER_Y_MM = 56.0
CUP_NOMINAL_RIM_DIAMETER_MM = 84.0
CUP_BODY_CLEARANCE_DIAMETER_MM = 76.0
CUP_GUIDE_INNER_DIAMETER_MM = 86.0
CUP_GUIDE_OUTER_DIAMETER_MM = 92.0
CUP_GUIDE_HEIGHT_MM = 2.4
CUP_OPEN_GAP_WIDTH_MM = 28.0
CUP_NOMINAL_DEPTH_MM = 34.0

# Four generic flat-head / mushroom-head suction-cup receivers.
SUCTION_CUP_NOMINAL_DIAMETER_MM = 40.0
SUCTION_HEAD_PASS_DIAMETER_MM = 16.0
SUCTION_STEM_SLOT_MM = 7.2
SUCTION_SLOT_TRAVEL_MM = 10.0
SUCTION_X_POSITIONS_MM = (-42.0, 42.0)
SUCTION_NECK_Z_POSITIONS_MM = (45.0, 82.0)
SUCTION_GAUGE_SLOTS_MM = (6.2, 7.2, 8.2)

# M3 heat-set insert and bolt interface.
INSERT_THREAD = "M3"
INSERT_NOMINAL_LENGTH_MM = 6.0
INSERT_NOMINAL_MAX_OD_MM = 5.0
INSERT_POCKET_DIAMETER_MM = 4.2
INSERT_POCKET_DEPTH_MM = 6.6
INSERT_ENTRY_DIAMETER_MM = 5.2
INSERT_ENTRY_DEPTH_MM = 0.9
INSERT_COUPON_DIAMETERS_MM = (4.0, 4.2, 4.4)
CLEARANCE_HOLE_DIAMETER_MM = 3.5

# The central hood hinge uses one pivot and two removable locking bolts.
HINGE_BLOCK_X_MIN_MM = -28.0
HINGE_BLOCK_X_MAX_MM = -6.0
HINGE_BLOCK_Y_MM = 18.0
HINGE_BLOCK_Z_MIN_MM = 72.0
HINGE_BLOCK_Z_MAX_MM = 114.0
HINGE_PIVOT_Y_MM = 10.0
HINGE_PIVOT_Z_MM = 106.0
HINGE_SLOPE_DEG = -7.0
SPINE_LOCAL_PIVOT = (0.0, 5.0, -16.0)
SPINE_LOCAL_LOCK_Z_MM = (-29.0, -42.0)

# Hood geometry and its four top-entry M3 insert bosses.
HOOD_WIDTH_MM = 154.0
HOOD_DEPTH_MM = 94.0
HOOD_THICKNESS_MM = 4.0
HOOD_FRONT_CHAMFER_MM = 7.0
HOOD_DRIP_LIP_DEPTH_MM = 4.0
HOOD_DRIP_LIP_HEIGHT_MM = 3.0
HOOD_BOSS_DIAMETER_MM = 14.0
HOOD_BOSS_DEPTH_MM = 5.0
HOOD_BOSS_X_MM = (-25.0, 25.0)
HOOD_BOSS_Y_MM = (18.0, 38.0)

# One-plate placements.  Shapes are moved to their own origin first.
PLATE_OFFSETS = {
    "frame": (22.0, 10.0, 0.0),
    "cup-fit-ring": (158.0, 10.0, 0.0),
    "hinge-spine": (150.0, 108.0, 0.0),
    "hardware-fit-card": (22.0, 116.0, 0.0),
    "rain-hood": (96.0, 160.0, 0.0),
}

PACKAGE_FILENAMES = (
    FRAME_STL_PATH.name,
    HOOD_STL_PATH.name,
    SPINE_STL_PATH.name,
    CUP_RING_STL_PATH.name,
    HARDWARE_CARD_STL_PATH.name,
    PLATE_STL_PATH.name,
    PLATE_3MF_PATH.name,
    STEP_PATH.name,
    PREVIEW_PATH.name,
    PLATE_PREVIEW_PATH.name,
    CLEANING_PATH.name,
    VALIDATION_PATH.name,
    README_PATH.name,
)


def to_origin(shape: cq.Shape) -> cq.Shape:
    """Move a shape's minimum corner to the global origin."""
    bounds = shape.BoundingBox()
    return shape.translate(cq.Vector(-bounds.xmin, -bounds.ymin, -bounds.zmin))


def make_cylinder(
    radius: float,
    length: float,
    origin: tuple[float, float, float],
    direction: tuple[float, float, float],
) -> cq.Solid:
    return cq.Solid.makeCylinder(
        radius,
        length,
        cq.Vector(*origin),
        cq.Vector(*direction),
    )


def make_insert_pocket_x(
    entry_x: float,
    center_y: float,
    center_z: float,
) -> cq.Shape:
    """Blind M3 insert pocket opening from +X and travelling toward -X."""
    pocket = make_cylinder(
        INSERT_POCKET_DIAMETER_MM / 2.0,
        INSERT_POCKET_DEPTH_MM + 0.1,
        (entry_x + 0.05, center_y, center_z),
        (-1.0, 0.0, 0.0),
    )
    entry = make_cylinder(
        INSERT_ENTRY_DIAMETER_MM / 2.0,
        INSERT_ENTRY_DEPTH_MM + 0.1,
        (entry_x + 0.05, center_y, center_z),
        (-1.0, 0.0, 0.0),
    )
    return pocket.fuse(entry)


def make_insert_pocket_z(
    entry_z: float,
    center_x: float,
    center_y: float,
    direction: float,
) -> cq.Shape:
    """Blind M3 insert pocket opening on a horizontal surface."""
    pocket = make_cylinder(
        INSERT_POCKET_DIAMETER_MM / 2.0,
        INSERT_POCKET_DEPTH_MM + 0.1,
        (center_x, center_y, entry_z - 0.05 * direction),
        (0.0, 0.0, direction),
    )
    entry = make_cylinder(
        INSERT_ENTRY_DIAMETER_MM / 2.0,
        INSERT_ENTRY_DEPTH_MM + 0.1,
        (center_x, center_y, entry_z - 0.05 * direction),
        (0.0, 0.0, direction),
    )
    return pocket.fuse(entry)


def make_keyhole_cut(x: float, neck_z: float) -> cq.Shape:
    """Create a gravity-locking suction-cup keyhole through the backplate."""
    access_z = neck_z + SUCTION_SLOT_TRAVEL_MM
    access = make_cylinder(
        SUCTION_HEAD_PASS_DIAMETER_MM / 2.0,
        BACKPLATE_THICKNESS_MM + 2.0,
        (x, -1.0, access_z),
        (0.0, 1.0, 0.0),
    )
    neck_slot = (
        cq.Workplane("XY")
        .box(
            SUCTION_STEM_SLOT_MM,
            BACKPLATE_THICKNESS_MM + 2.0,
            SUCTION_SLOT_TRAVEL_MM + SUCTION_STEM_SLOT_MM,
            centered=(True, False, True),
        )
        .val()
        .translate(
            cq.Vector(
                x,
                -1.0,
                neck_z + SUCTION_SLOT_TRAVEL_MM / 2.0,
            )
        )
    )
    neck_end = make_cylinder(
        SUCTION_STEM_SLOT_MM / 2.0,
        BACKPLATE_THICKNESS_MM + 2.0,
        (x, -1.0, neck_z),
        (0.0, 1.0, 0.0),
    )
    return access.fuse(neck_slot).fuse(neck_end)


def make_c_opening_cut(
    z_start: float,
    height: float,
    opening_diameter: float,
) -> cq.Shape:
    """Circular opening plus a +X escape slot for support-free side printing."""
    opening = make_cylinder(
        opening_diameter / 2.0,
        height,
        (0.0, CUP_CENTER_Y_MM, z_start),
        (0.0, 0.0, 1.0),
    )
    escape = (
        cq.Workplane("XY")
        .box(
            FRAME_WIDTH_MM / 2.0 + 3.0,
            CUP_OPEN_GAP_WIDTH_MM,
            height,
            centered=(False, True, False),
        )
        .val()
        .translate(cq.Vector(0.0, CUP_CENTER_Y_MM, z_start))
    )
    return opening.fuse(escape)


def make_frame_assembly() -> cq.Shape:
    """One-piece mounted frame in its installed orientation."""
    backplate = (
        cq.Workplane("XY")
        .box(
            FRAME_WIDTH_MM,
            BACKPLATE_THICKNESS_MM,
            FRAME_HEIGHT_MM,
            centered=(True, False, False),
        )
        .val()
    )
    for x in SUCTION_X_POSITIONS_MM:
        for neck_z in SUCTION_NECK_Z_POSITIONS_MM:
            backplate = backplate.cut(make_keyhole_cut(x, neck_z))

    shelf = (
        cq.Workplane("XY")
        .box(
            FRAME_WIDTH_MM,
            SHELF_DEPTH_MM,
            SHELF_THICKNESS_MM,
            centered=(True, False, False),
        )
        .val()
        .translate(cq.Vector(0.0, 0.0, SHELF_BOTTOM_MM))
    )
    shelf = shelf.cut(
        make_c_opening_cut(
            SHELF_BOTTOM_MM - 1.0,
            SHELF_THICKNESS_MM + 2.0,
            CUP_BODY_CLEARANCE_DIAMETER_MM,
        )
    )

    guide = make_cylinder(
        CUP_GUIDE_OUTER_DIAMETER_MM / 2.0,
        CUP_GUIDE_HEIGHT_MM,
        (0.0, CUP_CENTER_Y_MM, SHELF_TOP_MM),
        (0.0, 0.0, 1.0),
    )
    guide = guide.cut(
        make_cylinder(
            CUP_GUIDE_INNER_DIAMETER_MM / 2.0,
            CUP_GUIDE_HEIGHT_MM + 2.0,
            (0.0, CUP_CENTER_Y_MM, SHELF_TOP_MM - 1.0),
            (0.0, 0.0, 1.0),
        )
    )
    guide = guide.cut(
        (
            cq.Workplane("XY")
            .box(
                FRAME_WIDTH_MM / 2.0 + 3.0,
                CUP_OPEN_GAP_WIDTH_MM,
                CUP_GUIDE_HEIGHT_MM + 2.0,
                centered=(False, True, False),
            )
            .val()
            .translate(
                cq.Vector(
                    0.0,
                    CUP_CENTER_Y_MM,
                    SHELF_TOP_MM - 1.0,
                )
            )
        )
    )

    rail = make_cylinder(
        LANDING_RAIL_DIAMETER_MM / 2.0,
        LANDING_RAIL_LENGTH_MM,
        (
            -LANDING_RAIL_LENGTH_MM / 2.0,
            LANDING_RAIL_CENTER_Y_MM,
            LANDING_RAIL_CENTER_Z_MM,
        ),
        (1.0, 0.0, 0.0),
    )

    rear_wedge = (
        cq.Workplane("YZ")
        .polyline(
            [
                (BACKPLATE_THICKNESS_MM - 0.4, 7.0),
                (BACKPLATE_THICKNESS_MM - 0.4, SHELF_BOTTOM_MM + 0.6),
                (20.0, SHELF_BOTTOM_MM + 0.6),
            ]
        )
        .close()
        .extrude(FRAME_WIDTH_MM / 2.0, both=True)
        .val()
    )

    hinge_block = (
        cq.Workplane("XY")
        .box(
            HINGE_BLOCK_X_MAX_MM - HINGE_BLOCK_X_MIN_MM,
            HINGE_BLOCK_Y_MM,
            HINGE_BLOCK_Z_MAX_MM - HINGE_BLOCK_Z_MIN_MM,
            centered=(False, False, False),
        )
        .val()
        .translate(
            cq.Vector(
                HINGE_BLOCK_X_MIN_MM,
                0.0,
                HINGE_BLOCK_Z_MIN_MM,
            )
        )
    )

    local_holes = [
        SPINE_LOCAL_PIVOT[2],
        *SPINE_LOCAL_LOCK_Z_MM,
    ]
    hinge_centers: list[tuple[float, float]] = []
    pivot_y = SPINE_LOCAL_PIVOT[1]
    pivot_z = SPINE_LOCAL_PIVOT[2]
    slope = math.radians(HINGE_SLOPE_DEG)
    for local_z in local_holes:
        delta_z = local_z - pivot_z
        target_y = (
            HINGE_PIVOT_Y_MM - delta_z * math.sin(slope)
        )
        target_z = (
            HINGE_PIVOT_Z_MM + delta_z * math.cos(slope)
        )
        hinge_centers.append((target_y, target_z))

    for center_y, center_z in hinge_centers:
        hinge_block = hinge_block.cut(
            make_insert_pocket_x(
                HINGE_BLOCK_X_MAX_MM,
                center_y,
                center_z,
            )
        )

    frame = (
        backplate.fuse(shelf)
        .fuse(guide)
        .fuse(rail)
        .fuse(rear_wedge)
        .fuse(hinge_block)
        .clean()
    )
    if len(frame.Solids()) != 1:
        raise RuntimeError(
            f"CleanSeed frame should be one solid; generated {len(frame.Solids())}"
        )
    return frame


def make_frame_print(frame: cq.Shape) -> cq.Shape:
    """Lay the L-shaped frame on its side for support-free printing."""
    return to_origin(
        frame.rotate(
            cq.Vector(0.0, 0.0, 0.0),
            cq.Vector(0.0, 1.0, 0.0),
            -90.0,
        )
    )


def make_hood_assembly() -> cq.Shape:
    """Rain hood with a downturned drip lip and four blind insert bosses."""
    half_width = HOOD_WIDTH_MM / 2.0
    hood_plan = (
        cq.Workplane("XY")
        .polyline(
            [
                (-half_width, 0.0),
                (half_width, 0.0),
                (half_width, HOOD_DEPTH_MM - HOOD_FRONT_CHAMFER_MM),
                (
                    half_width - HOOD_FRONT_CHAMFER_MM,
                    HOOD_DEPTH_MM,
                ),
                (
                    -half_width + HOOD_FRONT_CHAMFER_MM,
                    HOOD_DEPTH_MM,
                ),
                (-half_width, HOOD_DEPTH_MM - HOOD_FRONT_CHAMFER_MM),
            ]
        )
        .close()
        .extrude(HOOD_THICKNESS_MM)
        .val()
    )

    drip_lip = (
        cq.Workplane("XY")
        .box(
            HOOD_WIDTH_MM - 2.0 * HOOD_FRONT_CHAMFER_MM,
            HOOD_DRIP_LIP_DEPTH_MM,
            HOOD_DRIP_LIP_HEIGHT_MM,
            centered=(True, False, False),
        )
        .val()
        .translate(
            cq.Vector(
                0.0,
                HOOD_DEPTH_MM - HOOD_DRIP_LIP_DEPTH_MM,
                -HOOD_DRIP_LIP_HEIGHT_MM,
            )
        )
    )
    hood = hood_plan.fuse(drip_lip)

    for x in HOOD_BOSS_X_MM:
        for y in HOOD_BOSS_Y_MM:
            boss = make_cylinder(
                HOOD_BOSS_DIAMETER_MM / 2.0,
                HOOD_BOSS_DEPTH_MM + 0.3,
                (x, y, -HOOD_BOSS_DEPTH_MM),
                (0.0, 0.0, 1.0),
            )
            hood = hood.fuse(boss)
            hood = hood.cut(
                make_insert_pocket_z(
                    -HOOD_BOSS_DEPTH_MM,
                    x,
                    y,
                    1.0,
                )
            )
    return hood.clean()


def make_hood_print(hood: cq.Shape) -> cq.Shape:
    """Print the hood top-down so the boss pockets face upward."""
    return to_origin(
        hood.rotate(
            cq.Vector(0.0, 0.0, 0.0),
            cq.Vector(1.0, 0.0, 0.0),
            180.0,
        )
    )


def make_hinge_spine_assembly() -> cq.Shape:
    """Central T-spine that bolts to the hood and pivots on the frame."""
    foot = (
        cq.Workplane("XY")
        .box(70.0, 40.0, 5.0, centered=(True, False, False))
        .val()
        .translate(cq.Vector(0.0, 8.0, -10.0))
    )
    arm = (
        cq.Workplane("XY")
        .box(12.0, 10.0, 37.0, centered=(True, False, False))
        .val()
        .translate(cq.Vector(0.0, 0.0, -42.0))
    )
    spine = foot.fuse(arm)

    for x in HOOD_BOSS_X_MM:
        for y in HOOD_BOSS_Y_MM:
            clearance = make_cylinder(
                CLEARANCE_HOLE_DIAMETER_MM / 2.0,
                7.0,
                (x, y, -11.0),
                (0.0, 0.0, 1.0),
            )
            spine = spine.cut(clearance)

    for center_z in (
        SPINE_LOCAL_PIVOT[2],
        *SPINE_LOCAL_LOCK_Z_MM,
    ):
        clearance = make_cylinder(
            CLEARANCE_HOLE_DIAMETER_MM / 2.0,
            14.0,
            (-7.0, SPINE_LOCAL_PIVOT[1], center_z),
            (1.0, 0.0, 0.0),
        )
        spine = spine.cut(clearance)
    return spine.clean()


def make_hinge_spine_print(spine: cq.Shape) -> cq.Shape:
    """Put the broad mounting foot on the plate with the arm rising."""
    return to_origin(
        spine.rotate(
            cq.Vector(0.0, 0.0, 0.0),
            cq.Vector(0.0, 1.0, 0.0),
            180.0,
        )
    )


def make_cup_fit_ring() -> cq.Shape:
    """Full-size C-ring that tests body clearance and rim seating."""
    ring = make_cylinder(
        CUP_GUIDE_OUTER_DIAMETER_MM / 2.0,
        SHELF_THICKNESS_MM,
        (0.0, 0.0, 0.0),
        (0.0, 0.0, 1.0),
    )
    ring = ring.cut(
        make_cylinder(
            CUP_BODY_CLEARANCE_DIAMETER_MM / 2.0,
            SHELF_THICKNESS_MM + 2.0,
            (0.0, 0.0, -1.0),
            (0.0, 0.0, 1.0),
        )
    )
    guide = make_cylinder(
        CUP_GUIDE_OUTER_DIAMETER_MM / 2.0,
        CUP_GUIDE_HEIGHT_MM,
        (0.0, 0.0, SHELF_THICKNESS_MM),
        (0.0, 0.0, 1.0),
    )
    guide = guide.cut(
        make_cylinder(
            CUP_GUIDE_INNER_DIAMETER_MM / 2.0,
            CUP_GUIDE_HEIGHT_MM + 2.0,
            (0.0, 0.0, SHELF_THICKNESS_MM - 1.0),
            (0.0, 0.0, 1.0),
        )
    )
    ring = ring.fuse(guide)
    gap = (
        cq.Workplane("XY")
        .box(
            CUP_GUIDE_OUTER_DIAMETER_MM / 2.0 + 2.0,
            CUP_OPEN_GAP_WIDTH_MM,
            SHELF_THICKNESS_MM + CUP_GUIDE_HEIGHT_MM + 2.0,
            centered=(False, True, False),
        )
        .val()
        .translate(cq.Vector(0.0, 0.0, -1.0))
    )
    return ring.cut(gap).clean()


def make_hardware_fit_card() -> cq.Shape:
    """M3 pilot pockets plus three generic suction-head keyhole gauges."""
    card = (
        cq.Workplane("XY")
        .box(80.0, 40.0, 9.0, centered=(False, False, False))
        .val()
    )
    x_positions = (15.0, 40.0, 65.0)
    for index, (x, pocket_diameter, stem_width) in enumerate(
        zip(
            x_positions,
            INSERT_COUPON_DIAMETERS_MM,
            SUCTION_GAUGE_SLOTS_MM,
            strict=True,
        ),
        start=1,
    ):
        pocket = make_cylinder(
            pocket_diameter / 2.0,
            INSERT_POCKET_DEPTH_MM + 0.1,
            (x, 30.0, 9.05),
            (0.0, 0.0, -1.0),
        )
        entry = make_cylinder(
            (pocket_diameter + 1.0) / 2.0,
            INSERT_ENTRY_DEPTH_MM + 0.1,
            (x, 30.0, 9.05),
            (0.0, 0.0, -1.0),
        )
        card = card.cut(pocket.fuse(entry))

        head_pass = make_cylinder(
            SUCTION_HEAD_PASS_DIAMETER_MM / 2.0,
            11.0,
            (x, 12.0, -1.0),
            (0.0, 0.0, 1.0),
        )
        fork = (
            cq.Workplane("XY")
            .box(
                stem_width,
                12.0,
                11.0,
                centered=(True, False, False),
            )
            .val()
            .translate(cq.Vector(x, 0.0, -1.0))
        )
        card = card.cut(head_pass.fuse(fork))

        # One, two, or three edge notches identify each paired gauge.
        for notch_index in range(index):
            notch_x = x + (notch_index - (index - 1) / 2.0) * 2.4
            notch = make_cylinder(
                0.7,
                2.2,
                (notch_x, 39.5, 9.5),
                (0.0, 0.0, -1.0),
            )
            card = card.cut(notch)
    return card.clean()


def place_parts(
    frame_print: cq.Shape,
    hood_print: cq.Shape,
    spine_print: cq.Shape,
    cup_ring: cq.Shape,
    hardware_card: cq.Shape,
) -> dict[str, cq.Shape]:
    """Place all printable pieces inside the guarded X1C plate."""
    source = {
        "frame": frame_print,
        "rain-hood": hood_print,
        "hinge-spine": spine_print,
        "cup-fit-ring": cup_ring,
        "hardware-fit-card": hardware_card,
    }
    return {
        name: to_origin(shape).translate(cq.Vector(*PLATE_OFFSETS[name]))
        for name, shape in source.items()
    }


def make_compound(shapes: list[cq.Shape]) -> cq.Compound:
    return cq.Compound.makeCompound(shapes)


def assembled_hood_parts(
    hood: cq.Shape,
    spine: cq.Shape,
) -> tuple[cq.Shape, cq.Shape]:
    """Rotate the hood group to its deployed rain-shedding angle."""
    pivot = cq.Vector(*SPINE_LOCAL_PIVOT)
    axis_end = cq.Vector(1.0, SPINE_LOCAL_PIVOT[1], SPINE_LOCAL_PIVOT[2])
    translation = cq.Vector(
        0.0,
        HINGE_PIVOT_Y_MM - SPINE_LOCAL_PIVOT[1],
        HINGE_PIVOT_Z_MM - SPINE_LOCAL_PIVOT[2],
    )
    hood_deployed = hood.rotate(
        pivot,
        axis_end,
        HINGE_SLOPE_DEG,
    ).translate(translation)
    spine_deployed = spine.rotate(
        pivot,
        axis_end,
        HINGE_SLOPE_DEG,
    ).translate(translation)
    return hood_deployed, spine_deployed


def export_models(
    frame_print: cq.Shape,
    hood_print: cq.Shape,
    spine_print: cq.Shape,
    cup_ring: cq.Shape,
    hardware_card: cq.Shape,
    placed: dict[str, cq.Shape],
) -> None:
    OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
    exports = (
        (FRAME_STL_PATH, frame_print),
        (HOOD_STL_PATH, hood_print),
        (SPINE_STL_PATH, spine_print),
        (CUP_RING_STL_PATH, cup_ring),
        (HARDWARE_CARD_STL_PATH, hardware_card),
    )
    for path, shape in exports:
        exporters.export(
            to_origin(shape),
            str(path),
            tolerance=EXPORT_TOLERANCE_MM,
            angularTolerance=EXPORT_ANGULAR_TOLERANCE,
        )

    plate = make_compound(list(placed.values()))
    exporters.export(
        plate,
        str(PLATE_STL_PATH),
        tolerance=EXPORT_TOLERANCE_MM,
        angularTolerance=EXPORT_ANGULAR_TOLERANCE,
    )
    exporters.export(plate, str(PLATE_3MF_PATH), exportType="3MF")

    # Editable catalog retains the exact manufacturing orientations.
    step_catalog = make_compound(
        [
            to_origin(frame_print),
            to_origin(hood_print).translate(cq.Vector(170.0, 0.0, 0.0)),
            to_origin(spine_print).translate(cq.Vector(0.0, 120.0, 0.0)),
            to_origin(cup_ring).translate(cq.Vector(190.0, 120.0, 0.0)),
            to_origin(hardware_card).translate(
                cq.Vector(0.0, 230.0, 0.0)
            ),
        ]
    )
    exporters.export(step_catalog, str(STEP_PATH))


def shape_mesh(shape: cq.Shape) -> trimesh.Trimesh:
    vertices, triangles = shape.tessellate(0.35, 0.18)
    return trimesh.Trimesh(
        vertices=np.asarray(
            [vertex.toTuple() for vertex in vertices],
            dtype=float,
        ),
        faces=np.asarray(triangles, dtype=int),
        process=True,
    )


def load_mesh(path: Path) -> trimesh.Trimesh:
    loaded = trimesh.load(path, force="mesh", process=True)
    if not isinstance(loaded, trimesh.Trimesh):
        raise RuntimeError(f"{path.name} did not load as a mesh")
    return loaded


def mesh_report(path: Path, expected_components: int) -> dict[str, object]:
    mesh = load_mesh(path)
    components = mesh.split(only_watertight=False)
    failures: list[str] = []
    if not mesh.is_watertight:
        failures.append("not watertight")
    if not mesh.is_winding_consistent:
        failures.append("inconsistent winding")
    if len(components) != expected_components:
        failures.append(
            f"{len(components)} components; expected {expected_components}"
        )
    if failures:
        raise RuntimeError(f"{path.name}: " + "; ".join(failures))
    return {
        "file": path.name,
        "bytes": path.stat().st_size,
        "vertices": int(len(mesh.vertices)),
        "faces": int(len(mesh.faces)),
        "watertight": bool(mesh.is_watertight),
        "winding_consistent": bool(mesh.is_winding_consistent),
        "connected_components": len(components),
        "bounds_mm": [
            [round(float(value), 3) for value in mesh.bounds[0]],
            [round(float(value), 3) for value in mesh.bounds[1]],
        ],
        "extents_mm": [
            round(float(value), 3) for value in mesh.extents
        ],
        "volume_mm3": round(abs(float(mesh.volume)), 3),
    }


def validate_3mf(path: Path, expected_meshes: int) -> dict[str, object]:
    required = {
        "[content_types].xml",
        "_rels/.rels",
        "3d/3dmodel.model",
    }
    with zipfile.ZipFile(path) as archive:
        names = {name.lower() for name in archive.namelist()}
        if not required.issubset(names):
            missing = sorted(required - names)
            raise RuntimeError(
                f"{path.name} missing 3MF members: {missing}"
            )
        model_xml = archive.read("3D/3dmodel.model")

    root = ET.fromstring(model_xml)
    namespace = {
        "m": "http://schemas.microsoft.com/3dmanufacturing/core/2015/02"
    }
    meshes = root.findall(".//m:mesh", namespace)
    build_items = root.findall("./m:build/m:item", namespace)
    if root.attrib.get("unit") != "millimeter":
        raise RuntimeError(f"{path.name} does not declare millimetres")
    if len(meshes) != expected_meshes:
        raise RuntimeError(
            f"{path.name} has {len(meshes)} meshes; expected {expected_meshes}"
        )
    if len(build_items) != 1:
        raise RuntimeError(
            f"{path.name} has {len(build_items)} build items; expected 1"
        )
    return {
        "file": path.name,
        "bytes": path.stat().st_size,
        "unit": root.attrib.get("unit"),
        "mesh_resources": len(meshes),
        "build_items": len(build_items),
        "required_members_present": True,
    }


def bounds_report(shape: cq.Shape) -> dict[str, float]:
    bounds = shape.BoundingBox()
    return {
        "xmin": round(bounds.xmin, 3),
        "ymin": round(bounds.ymin, 3),
        "zmin": round(bounds.zmin, 3),
        "xmax": round(bounds.xmax, 3),
        "ymax": round(bounds.ymax, 3),
        "zmax": round(bounds.zmax, 3),
        "xlen": round(bounds.xlen, 3),
        "ylen": round(bounds.ylen, 3),
        "zlen": round(bounds.zlen, 3),
    }


def boxes_overlap_xy(first: cq.Shape, second: cq.Shape) -> bool:
    a = first.BoundingBox()
    b = second.BoundingBox()
    return (
        min(a.xmax, b.xmax) - max(a.xmin, b.xmin) > 0.01
        and min(a.ymax, b.ymax) - max(a.ymin, b.ymin) > 0.01
    )


def validate_geometry(
    frame_print: cq.Shape,
    hood_print: cq.Shape,
    spine_print: cq.Shape,
    cup_ring: cq.Shape,
    hardware_card: cq.Shape,
    placed: dict[str, cq.Shape],
) -> dict[str, object]:
    printable = {
        "frame": frame_print,
        "rain-hood": hood_print,
        "hinge-spine": spine_print,
        "cup-fit-ring": cup_ring,
        "hardware-fit-card": hardware_card,
    }
    part_bounds = {
        name: bounds_report(to_origin(shape))
        for name, shape in printable.items()
    }
    placed_bounds = {
        name: bounds_report(shape) for name, shape in placed.items()
    }

    names = list(placed)
    for index, first_name in enumerate(names):
        first = placed[first_name]
        bounds = first.BoundingBox()
        if (
            bounds.xmin < -OCC_EXPORT_BOUNDS_EPSILON_MM
            or bounds.ymin < -OCC_EXPORT_BOUNDS_EPSILON_MM
            or bounds.zmin < -OCC_EXPORT_BOUNDS_EPSILON_MM
            or bounds.xmax
            > X1C_X_MM + OCC_EXPORT_BOUNDS_EPSILON_MM
            or bounds.ymax
            > X1C_Y_MM + OCC_EXPORT_BOUNDS_EPSILON_MM
            or bounds.zmax
            > X1C_DEFAULT_Z_MM + OCC_EXPORT_BOUNDS_EPSILON_MM
        ):
            raise RuntimeError(f"{first_name} is outside the X1C plate")
        if (
            bounds.xmin < X1C_EXCLUSION_X_MM
            and bounds.ymin < X1C_EXCLUSION_Y_MM
        ):
            raise RuntimeError(
                f"{first_name} touches the conservative cutter exclusion"
            )
        for second_name in names[index + 1 :]:
            if boxes_overlap_xy(first, placed[second_name]):
                raise RuntimeError(
                    f"{first_name} overlaps {second_name} in XY"
                )

    step_model = importers.importStep(str(STEP_PATH))
    step_solids = step_model.solids().vals()
    if len(step_solids) != 5:
        raise RuntimeError(
            f"STEP has {len(step_solids)} solids; expected 5"
        )

    frame_insert_floor = (
        HINGE_BLOCK_X_MAX_MM
        - HINGE_BLOCK_X_MIN_MM
        - INSERT_POCKET_DEPTH_MM
    )
    hood_insert_floor = (
        HOOD_THICKNESS_MM
        + HOOD_BOSS_DEPTH_MM
        - INSERT_POCKET_DEPTH_MM
    )
    if frame_insert_floor < 3.0 or hood_insert_floor < 2.0:
        raise RuntimeError("Insufficient solid floor behind an M3 pocket")
    if CUP_GUIDE_INNER_DIAMETER_MM <= CUP_NOMINAL_RIM_DIAMETER_MM:
        raise RuntimeError("Cup guide has no nominal rim clearance")
    if CUP_NOMINAL_RIM_DIAMETER_MM <= CUP_BODY_CLEARANCE_DIAMETER_MM:
        raise RuntimeError("Cup rim cannot seat over the body opening")

    slope_radians = math.radians(abs(HINGE_SLOPE_DEG))
    nominal_front_drop = round(
        HOOD_DEPTH_MM * math.sin(slope_radians),
        3,
    )

    return {
        "printable_part_bounds_mm": part_bounds,
        "x1c_placed_bounds_mm": placed_bounds,
        "mounted_envelope_mm": {
            "frame_width": FRAME_WIDTH_MM,
            "frame_depth_to_rail": (
                LANDING_RAIL_CENTER_Y_MM
                + LANDING_RAIL_DIAMETER_MM / 2.0
            ),
            "frame_height": FRAME_HEIGHT_MM,
            "hood_width": HOOD_WIDTH_MM,
            "hood_depth": HOOD_DEPTH_MM,
            "hood_deployed_slope_degrees": abs(HINGE_SLOPE_DEG),
            "nominal_hood_front_drop": nominal_front_drop,
        },
        "stainless_mesh_cup_interface_mm": {
            "nominal_rim_diameter": CUP_NOMINAL_RIM_DIAMETER_MM,
            "body_clearance_diameter": CUP_BODY_CLEARANCE_DIAMETER_MM,
            "guide_inner_diameter": CUP_GUIDE_INNER_DIAMETER_MM,
            "guide_outer_diameter": CUP_GUIDE_OUTER_DIAMETER_MM,
            "guide_radial_rim_clearance": round(
                (
                    CUP_GUIDE_INNER_DIAMETER_MM
                    - CUP_NOMINAL_RIM_DIAMETER_MM
                )
                / 2.0,
                3,
            ),
            "seat_radial_width": round(
                (
                    CUP_NOMINAL_RIM_DIAMETER_MM
                    - CUP_BODY_CLEARANCE_DIAMETER_MM
                )
                / 2.0,
                3,
            ),
            "open_arc_gap_width": CUP_OPEN_GAP_WIDTH_MM,
            "nominal_depth_for_preview_only": CUP_NOMINAL_DEPTH_MM,
        },
        "suction_interface_mm": {
            "cup_nominal_diameter": SUCTION_CUP_NOMINAL_DIAMETER_MM,
            "head_pass_diameter": SUCTION_HEAD_PASS_DIAMETER_MM,
            "frame_stem_slot": SUCTION_STEM_SLOT_MM,
            "fit_card_stem_slots": list(SUCTION_GAUGE_SLOTS_MM),
            "receivers": 4,
        },
        "m3_hardware": {
            "thread": INSERT_THREAD,
            "total_inserts": 7,
            "total_bolts": 7,
            "hood_attachment_bolts": 4,
            "hinge_pivot_bolts": 1,
            "hinge_lock_bolts": 2,
            "nominal_insert_length": INSERT_NOMINAL_LENGTH_MM,
            "nominal_insert_max_od": INSERT_NOMINAL_MAX_OD_MM,
            "pilot_pocket": INSERT_POCKET_DIAMETER_MM,
            "blind_depth": INSERT_POCKET_DEPTH_MM,
            "entry_diameter": INSERT_ENTRY_DIAMETER_MM,
            "entry_depth": INSERT_ENTRY_DEPTH_MM,
            "frame_solid_floor_behind_pocket": round(
                frame_insert_floor,
                3,
            ),
            "hood_solid_floor_behind_pocket": round(
                hood_insert_floor,
                3,
            ),
        },
        "step": {
            "file": STEP_PATH.name,
            "bytes": STEP_PATH.stat().st_size,
            "solids": len(step_solids),
        },
    }


def mesh_collection(
    mesh: trimesh.Trimesh,
    color: str,
    alpha: float = 1.0,
) -> Poly3DCollection:
    return Poly3DCollection(
        mesh.triangles,
        facecolor=color,
        edgecolor="none",
        linewidth=0.0,
        alpha=alpha,
    )


def render_preview(
    frame_assembly: cq.Shape,
    hood_assembly: cq.Shape,
    spine_assembly: cq.Shape,
) -> None:
    hood_deployed, spine_deployed = assembled_hood_parts(
        hood_assembly,
        spine_assembly,
    )
    figure = plt.figure(figsize=(12.0, 7.2), dpi=170)
    figure.patch.set_facecolor("#f6f0e3")
    axis = figure.add_subplot(1, 1, 1, projection="3d")
    axis.set_facecolor("#f6f0e3")

    axis.add_collection3d(
        mesh_collection(shape_mesh(frame_assembly), "#1d624a")
    )
    axis.add_collection3d(
        mesh_collection(shape_mesh(hood_deployed), "#2f7b5f")
    )
    axis.add_collection3d(
        mesh_collection(shape_mesh(spine_deployed), "#173f31")
    )

    # Illustrative stainless-mesh cup: transparent frustum and rim.
    theta = np.linspace(0.0, 2.0 * np.pi, 48)
    depth = np.linspace(0.0, CUP_NOMINAL_DEPTH_MM, 9)
    theta_grid, depth_grid = np.meshgrid(theta, depth)
    radii = (
        CUP_BODY_CLEARANCE_DIAMETER_MM / 2.0 - 2.0
        - depth_grid * 0.24
    )
    cup_x = radii * np.cos(theta_grid)
    cup_y = CUP_CENTER_Y_MM + radii * np.sin(theta_grid)
    cup_z = SHELF_TOP_MM + 1.0 - depth_grid
    axis.plot_surface(
        cup_x,
        cup_y,
        cup_z,
        color="#b8bdc3",
        edgecolor="#7b858b",
        linewidth=0.22,
        alpha=0.28,
    )
    rim_theta = np.linspace(0.0, 2.0 * np.pi, 120)
    axis.plot(
        CUP_NOMINAL_RIM_DIAMETER_MM / 2.0 * np.cos(rim_theta),
        CUP_CENTER_Y_MM
        + CUP_NOMINAL_RIM_DIAMETER_MM / 2.0 * np.sin(rim_theta),
        np.full_like(rim_theta, SHELF_TOP_MM + 1.2),
        color="#8b949a",
        linewidth=2.4,
    )

    # Four illustrative suction cups behind the backplate.
    disc_theta = np.linspace(0.0, 2.0 * np.pi, 60)
    for x in SUCTION_X_POSITIONS_MM:
        for neck_z in SUCTION_NECK_Z_POSITIONS_MM:
            axis.plot(
                x
                + SUCTION_CUP_NOMINAL_DIAMETER_MM
                / 2.0
                * np.cos(disc_theta),
                np.full_like(disc_theta, -5.0),
                neck_z
                + SUCTION_CUP_NOMINAL_DIAMETER_MM
                / 2.0
                * np.sin(disc_theta),
                color="#87a8a3",
                linewidth=1.1,
                alpha=0.58,
            )

    axis.set_xlim(-92.0, 92.0)
    axis.set_ylim(-28.0, 132.0)
    axis.set_zlim(-10.0, 145.0)
    axis.set_box_aspect((1.2, 1.15, 1.0))
    axis.set_proj_type("ortho")
    axis.view_init(elev=24.0, azim=122.0)
    axis.set_axis_off()
    figure.suptitle(
        "CleanSeed - one-plate window feeder prototype",
        fontsize=19,
        fontweight="bold",
        color="#173f31",
        y=0.95,
    )
    figure.text(
        0.5,
        0.07,
        (
            "Printed frame + removable stainless-mesh cup + "
            "M3 pivoting rain hood + four suction cups"
        ),
        ha="center",
        fontsize=10.5,
        color="#31483f",
    )
    figure.text(
        0.5,
        0.03,
        (
            "Metal cup, suction cups, and hardware are illustrative "
            "and are not included in the STL."
        ),
        ha="center",
        fontsize=8.5,
        color="#6f695f",
    )
    figure.savefig(
        PREVIEW_PATH,
        bbox_inches="tight",
        facecolor=figure.get_facecolor(),
    )
    plt.close(figure)


def render_plate_preview(placed: dict[str, cq.Shape]) -> None:
    figure = plt.figure(figsize=(8.0, 8.0), dpi=180)
    figure.patch.set_facecolor("#f6f0e3")
    axis = figure.add_subplot(1, 1, 1, projection="3d")
    axis.set_facecolor("#f6f0e3")

    bed = np.asarray(
        [
            [0.0, 0.0, 0.0],
            [X1C_X_MM, 0.0, 0.0],
            [X1C_X_MM, X1C_Y_MM, 0.0],
            [0.0, X1C_Y_MM, 0.0],
        ]
    )
    exclusion = np.asarray(
        [
            [0.0, 0.0, 0.12],
            [X1C_EXCLUSION_X_MM, 0.0, 0.12],
            [X1C_EXCLUSION_X_MM, X1C_EXCLUSION_Y_MM, 0.12],
            [0.0, X1C_EXCLUSION_Y_MM, 0.12],
        ]
    )
    axis.add_collection3d(
        Poly3DCollection(
            [bed],
            facecolor="#d7dad5",
            edgecolor="#68756e",
            linewidth=0.9,
            alpha=0.38,
        )
    )
    axis.add_collection3d(
        Poly3DCollection(
            [exclusion],
            facecolor="#ca4e43",
            edgecolor="#8a2c25",
            linewidth=0.7,
            alpha=0.42,
        )
    )
    colors = {
        "frame": "#1d624a",
        "rain-hood": "#2f7b5f",
        "hinge-spine": "#173f31",
        "cup-fit-ring": "#b6a070",
        "hardware-fit-card": "#c49542",
    }
    for name, shape in placed.items():
        axis.add_collection3d(
            mesh_collection(shape_mesh(shape), colors[name])
        )

    axis.set_xlim(0.0, X1C_X_MM)
    axis.set_ylim(0.0, X1C_Y_MM)
    axis.set_zlim(0.0, 160.0)
    axis.set_box_aspect((1.0, 1.0, 0.52))
    axis.view_init(elev=43.0, azim=-58.0)
    axis.set_axis_off()
    figure.suptitle(
        "CleanSeed - guarded X1C one-plate layout",
        fontsize=15,
        fontweight="bold",
        color="#173f31",
        y=0.93,
    )
    figure.text(
        0.5,
        0.055,
        (
            "Three feeder parts + two fit checks - "
            "millimetres - import at 100%"
        ),
        ha="center",
        fontsize=9.2,
        color="#31483f",
    )
    figure.savefig(
        PLATE_PREVIEW_PATH,
        bbox_inches="tight",
        facecolor=figure.get_facecolor(),
    )
    plt.close(figure)


def render_cleaning_workflow() -> None:
    """Render a simple lift-wash-dry workflow for the public release."""
    figure, axes = plt.subplots(1, 3, figsize=(12.0, 5.4), dpi=170)
    figure.patch.set_facecolor("#f6f0e3")
    titles = (
        "1. Lift out",
        "2. Wash",
        "3. Dry completely",
    )
    subtitles = (
        "Leave the printed frame on the glass.",
        "Remove old seed and scrub every surface.",
        "Refill only after cup and frame are dry.",
    )
    for axis, title, subtitle in zip(
        axes,
        titles,
        subtitles,
        strict=True,
    ):
        axis.set_facecolor("#f6f0e3")
        axis.set_xlim(0.0, 1.0)
        axis.set_ylim(0.0, 1.0)
        axis.set_aspect("equal")
        axis.set_axis_off()
        axis.text(
            0.5,
            0.93,
            title,
            ha="center",
            va="center",
            fontsize=14,
            fontweight="bold",
            color="#173f31",
        )
        axis.text(
            0.5,
            0.08,
            subtitle,
            ha="center",
            va="center",
            fontsize=8.8,
            color="#4c5b54",
            wrap=True,
        )

    # Lift-out frame and cup.
    axes[0].add_patch(
        Rectangle(
            (0.12, 0.25),
            0.76,
            0.10,
            facecolor="#1d624a",
            edgecolor="#173f31",
            linewidth=1.2,
        )
    )
    axes[0].add_patch(
        Circle(
            (0.5, 0.48),
            0.18,
            facecolor="#c8cecf",
            edgecolor="#7b858b",
            linewidth=1.5,
            alpha=0.75,
        )
    )
    axes[0].add_patch(
        FancyArrowPatch(
            (0.5, 0.42),
            (0.5, 0.76),
            arrowstyle="-|>",
            mutation_scale=18,
            color="#c49542",
            linewidth=2.0,
        )
    )

    # Wash basin, brush, and droplets.
    axes[1].add_patch(
        Rectangle(
            (0.18, 0.28),
            0.64,
            0.22,
            facecolor="#9fc6ca",
            edgecolor="#4c7478",
            linewidth=1.4,
        )
    )
    axes[1].add_patch(
        Circle(
            (0.5, 0.58),
            0.18,
            facecolor="#d8dddd",
            edgecolor="#7b858b",
            linewidth=1.4,
        )
    )
    axes[1].plot(
        [0.30, 0.66],
        [0.74, 0.55],
        color="#8b6038",
        linewidth=6.0,
        solid_capstyle="round",
    )
    for x, y in ((0.30, 0.59), (0.70, 0.68), (0.76, 0.58)):
        axes[1].add_patch(
            Circle((x, y), 0.018, facecolor="#5c9faa", edgecolor="none")
        )

    # Dry cup over a clean towel.
    axes[2].add_patch(
        Rectangle(
            (0.16, 0.24),
            0.68,
            0.18,
            facecolor="#e7dfcd",
            edgecolor="#a49a85",
            linewidth=1.2,
        )
    )
    axes[2].add_patch(
        Circle(
            (0.5, 0.58),
            0.18,
            facecolor="#d8dddd",
            edgecolor="#7b858b",
            linewidth=1.4,
        )
    )
    for x in (0.28, 0.40, 0.60, 0.72):
        axes[2].plot(
            [x, x + 0.04],
            [0.78, 0.85],
            color="#c49542",
            linewidth=1.8,
        )

    figure.suptitle(
        "CleanSeed's washable part comes out without removing the frame",
        fontsize=17,
        fontweight="bold",
        color="#173f31",
        y=0.98,
    )
    figure.text(
        0.5,
        0.015,
        (
            "Inspect suction cups and printed parts at every cleaning. "
            "Follow current local disease and wildlife guidance."
        ),
        ha="center",
        fontsize=8.5,
        color="#6a382f",
    )
    figure.tight_layout(rect=(0.0, 0.04, 1.0, 0.92), w_pad=1.3)
    figure.savefig(
        CLEANING_PATH,
        bbox_inches="tight",
        facecolor=figure.get_facecolor(),
    )
    plt.close(figure)


def write_validation_report(
    frame_print: cq.Shape,
    hood_print: cq.Shape,
    spine_print: cq.Shape,
    cup_ring: cq.Shape,
    hardware_card: cq.Shape,
    placed: dict[str, cq.Shape],
) -> dict[str, object]:
    geometry = validate_geometry(
        frame_print,
        hood_print,
        spine_print,
        cup_ring,
        hardware_card,
        placed,
    )
    report = {
        "schema_version": 1,
        "design": "Make Birdhouses CleanSeed window feeder M3",
        "revision": "v1",
        "units": "millimetres",
        "intended_use": (
            "Digitally validated prototype frame for a removable "
            "stainless-mesh bird-seed cup"
        ),
        "digital_validation_only": True,
        "physical_print_tested": False,
        "field_tested_with_birds": False,
        "printed_part_holds_seed_directly": False,
        "geometry": geometry,
        "meshes": {
            "frame": mesh_report(FRAME_STL_PATH, 1),
            "rain_hood": mesh_report(HOOD_STL_PATH, 1),
            "hinge_spine": mesh_report(SPINE_STL_PATH, 1),
            "cup_fit_ring": mesh_report(CUP_RING_STL_PATH, 1),
            "hardware_fit_card": mesh_report(HARDWARE_CARD_STL_PATH, 1),
            "x1c_plate": mesh_report(PLATE_STL_PATH, 5),
        },
        "three_mf": validate_3mf(PLATE_3MF_PATH, 5),
        "checks": {
            "five_printable_components_are_watertight": True,
            "single_guarded_x1c_plate": True,
            "x1c_plate_inside_256_x_256_x_250_mm": True,
            "conservative_front_left_exclusion_clear": True,
            "plate_objects_do_not_overlap": True,
            "step_contains_five_solids": True,
            "frame_cup_opening_is_open_toward_print_top": True,
            "seven_m3_insert_pockets_are_blind": True,
            "four_suction_receivers_present": True,
            "full_size_cup_fit_ring_included": True,
            "printed_parts_do_not_form_seed_container": True,
        },
        "physical_release_gates": [
            "Bambu Studio slice with no ignored collision or bounds warnings",
            "one complete X1C print and dimensional inspection",
            "M3 heat-set insertion and torque/pullout checks",
            "fit with the exact stainless-mesh cup and suction-cup lot",
            "24-hour proof load before any outdoor installation",
            "7-day loaded window retention test",
            "rain drainage and wind-response observation",
            "10 remove-wash-dry-reinstall cycles",
            "sun, heat, cold, moisture, and UV exposure",
            "hinge pivot and lock durability",
            "bird-friendly treatment on the exterior of the same window",
            "field review before promotional safety or performance claims",
        ],
        "not_validated": [
            "slicer-generated supports, time, or material estimate",
            "physical X1C print",
            "food-contact certification",
            "suction retention on any particular glass or temperature",
            "weather resistance",
            "wildlife preference or species suitability",
            "squirrel resistance",
            "window-collision prevention",
            "field use with birds",
        ],
    }
    VALIDATION_PATH.write_text(
        json.dumps(report, indent=2) + "\n",
        encoding="utf-8",
    )
    return report


def package_release() -> None:
    missing = [
        filename
        for filename in PACKAGE_FILENAMES
        if not (OUTPUT_DIR / filename).is_file()
    ]
    if missing:
        raise RuntimeError(
            "Cannot package release; missing: " + ", ".join(missing)
        )
    if ZIP_PATH.exists():
        ZIP_PATH.unlink()
    with zipfile.ZipFile(
        ZIP_PATH,
        mode="w",
        compression=zipfile.ZIP_DEFLATED,
        compresslevel=9,
    ) as archive:
        for filename in PACKAGE_FILENAMES:
            archive.write(OUTPUT_DIR / filename, arcname=filename)
        archive.write(
            Path(__file__).resolve(),
            arcname=Path(__file__).name,
        )


def print_report(report: dict[str, object]) -> None:
    plate = report["meshes"]["x1c_plate"]
    frame = report["meshes"]["frame"]
    print("CleanSeed window feeder M3 generated")
    print(
        "  Frame print extents: "
        + " x ".join(
            f"{value:.1f}" for value in frame["extents_mm"]
        )
        + " mm"
    )
    print(
        "  Plate extents: "
        + " x ".join(
            f"{value:.1f}" for value in plate["extents_mm"]
        )
        + " mm"
    )
    print(
        f"  3MF meshes: {report['three_mf']['mesh_resources']}"
    )
    print(f"  ZIP: {ZIP_PATH.name} ({ZIP_PATH.stat().st_size} bytes)")
    print("  Status: digital validation only; physical test gates pending")


def main() -> None:
    OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
    frame_assembly = make_frame_assembly()
    hood_assembly = make_hood_assembly()
    spine_assembly = make_hinge_spine_assembly()
    frame_print = make_frame_print(frame_assembly)
    hood_print = make_hood_print(hood_assembly)
    spine_print = make_hinge_spine_print(spine_assembly)
    cup_ring = make_cup_fit_ring()
    hardware_card = make_hardware_fit_card()
    placed = place_parts(
        frame_print,
        hood_print,
        spine_print,
        cup_ring,
        hardware_card,
    )
    export_models(
        frame_print,
        hood_print,
        spine_print,
        cup_ring,
        hardware_card,
        placed,
    )
    render_preview(frame_assembly, hood_assembly, spine_assembly)
    render_plate_preview(placed)
    render_cleaning_workflow()
    report = write_validation_report(
        frame_print,
        hood_print,
        spine_print,
        cup_ring,
        hardware_card,
        placed,
    )
    package_release()
    print_report(report)


if __name__ == "__main__":
    with warnings.catch_warnings():
        warnings.simplefilter("ignore", RuntimeWarning)
        main()
